Commit graph

120 commits

Author SHA1 Message Date
karan yadav
dc26127a42
Studio: propagate required backend version to repair pipeline (#8610) (#8670)
* Studio: propagate required backend version to repair pipeline (#8610)

Fix second-launch infinite repair loop when installed backend version is outdated (#8610).

When the Desktop App launches with an installed managed venv whose version is older than expected_backend_version() (e.g. 2026.8.4 < 2026.8.15), preflight flags the install as ManagedStale with desktop_backend_version_outdated. On auto-repair, unsloth studio update ran setup.sh/setup.ps1 from the old venv, which skipped python dependency installation because INSTALLED_VER == LATEST_VER on PyPI or PyPI timeout, leaving the venv unchanged. The installer fallback (install.sh/install.ps1) also lacked version floor pins on standard fresh paths, locking the user in a permanent repair error loop.

Key changes:
- Pass UNSLOTH_DESKTOP_BACKEND_VERSION from Tauri (update.rs & install.rs) to child process environments.
- Force Python dependency pass in setup.sh & setup.ps1 when UNSLOTH_DESKTOP_BACKEND_VERSION is set and INSTALLED_VER < UNSLOTH_DESKTOP_BACKEND_VERSION.
- Apply UNSLOTH_DESKTOP_BACKEND_VERSION floor constraint when updating core packages in install_python_stack.py.
- Ensure standard fresh install paths in install.sh and install.ps1 use "unsloth>=2026.8.15".
- Bump MIN_DESKTOP_BACKEND_VERSION in preflight/version.rs to "2026.8.15".
- Add shell and Python unit tests for the fast-path escape and desktop backend version constraint.

Fixes #8610

* Narrow desktop repair to version propagation

* Handle version suffixes in repair fallback

---------

Co-authored-by: imagineer99 <samleejackson0@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-08-19 19:12:22 +01:00
Daniel Han
0fb21e8a89
Revert "Studio: verify the installed version after the update pass (#8505)" (#8824)
This reverts commit fe79a5bbc2.
2026-08-14 07:01:19 -07:00
Nilay
fe79a5bbc2
Studio: verify the installed version after the update pass (#8505)
* Studio: verify installed version after the update pass

* Studio update verify: PEP 440 compare, Requires-Python escape, LATEST_VER init

* Studio update verify: accept only the newest interpreter-compatible release

* Studio update verify: reuse the fetched PyPI response in the ps1 probe

* Studio update verify: base64 the probe temp path (apostrophe-safe)

* Studio update verify: filter compatible releases by wheel tags / sdist

* Studio update verify: LiteralPath for the temp release table

* Studio update verify: skip the strict check when a custom package index is active

* Studio update verify: warn on older-but-successful, fail only when missing

* Studio update verify: keep missing check on custom indexes, PEP 503 probe names

* Studio update verify: run the missing-package probe even when PyPI is unreachable

* Studio update verify: isolate probes from PYTHONPATH, generic kept message

* Studio update verify: drop cwd from ps1 probe paths, non-fatal temp-table failures

* Studio update verify: require loadable payload, keep venv site-packages in scrub

* Studio update verify: drop the success manifest when the package is missing

* Studio update verify: retry, invalidate, and surface a stuck success manifest

* Studio update verify: keep the payload probe clear of the installer-helper guard

The post-update probe matched a package initializer by its joined filename. The
installer-helper guard in tests/test_installer_interactive_prompts.py scans every
installer for filename-shaped tokens and resolves each one against the script's own
directory, so that literal resolved to studio/__init__.py and the guard reported it
as a helper the installers invoke but nobody scans. Parity and the CPU repo tests
have been red on that since the probe landed.

Match on stem and suffix instead. Same predicate, no filename token to resolve, and
studio/__init__.py stays out of SCANNED_SCRIPTS where it does not belong.

Verified on 3.10, 3.11 and 3.13 against fabricated venv trees: a dist-info whose
payload was deleted still reports __MISSING__ through this branch on 3.10 and 3.11,
and a RECORD-only install whose payload is intact still confirms. From 3.12 on,
Distribution.files applies skip_missing_files, so this branch never sees a deleted
path there and the top_level.txt branch above carries the check.

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-14 07:00:53 -07:00
Nilay
1fb184deb4
Studio: self-repair sidecars whose extensions were built for another Python (#8705)
* Studio: detect ABI-mismatched sidecar extensions so self-repair fires after a Python upgrade

* Studio: scan extension basenames and flag stable-ABI binaries on free-threaded builds

Match the version tag on the file basename rather than the whole RECORD path, so a
directory component carrying a wheel-style tag does not wipe the sidecar. Report
.abi3 binaries only under a free-threaded interpreter, which cannot load them.

Fix the stale-tag pick in the detection test so it stays stale under cp313t.

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-14 06:05:14 -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
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
Wasim Yousef Said
f567ae8f39
Studio: skip redundant packaged frontend rebuilds (#8326)
* Desktop: skip frontend rebuild during updates

* Tests: tolerate rustfmt in updater UTF-8 contract

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

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

* Studio: use packaged frontend for PyPI installs

* Studio: keep the packaged frontend skip off source checkouts

STUDIO_LOCAL_INSTALL records where the Python package came from, not which
tree setup runs out of. An editable overlay separates the two: with
UNSLOTH_CI_SOURCE_OVERLAY, or in a venv left editable by an earlier --local
run, the mode stays 0 while SCRIPT_DIR is a checkout whose dist is a stale
build artifact rather than a release one. The skip then serves that stale
dist and a source change silently never reaches the browser, which is the
outcome the overlay legs of clean-machine-install-ci exist to catch.

A wheel ships no top-level files, so a pyproject.toml next to studio/ marks
the tree as source. Require its absence before trusting the packaged dist;
site-packages installs are unaffected and still skip.

Also check the Tauri branch before the packaged one in setup.ps1 so a
desktop update reports the same reason it reports on POSIX.

Covered by new cases in tests/sh/test_packaged_frontend_skip.sh and
tests/studio/test_node_decision.ps1.

---------

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 03:02:17 -07:00
Daniel Han
9f37302e2e
Report a denied UNSLOTH_LOCAL_LLAMA_CPP_DIR instead of aborting on errexit (#8143)
Some checks are pending
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Unsloth export capability / capability (ubuntu-latest) (push) Waiting to run
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-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 + 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 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
* Report a denied UNSLOTH_LOCAL_LLAMA_CPP_DIR instead of aborting on errexit

* Report a denied ancestor and stop on a denied install parent

Two gaps in the first pass, both reproduced.

A build under an unsearchable ancestor cannot be stat'd, so `[ ! -d ]` read it as
missing and setup said "does not exist", sending the user to correct a path that
was already right. `_report_denied_ancestor` walks up to the deepest ancestor it
can stat and reports that one, so the message names the directory that actually
needs the permission change. A genuinely absent path still reports as missing.

Keeping the textual path when the install parent could not be canonicalized only
moved the abort: `rm -rf` fails, `[ -e ]` is false because the parent cannot be
stat'd, and the following `ln -sfn` then aborts under errexit with no
[TAURI:ERROR]. Nothing can be written under a parent we cannot search, so report
there instead of continuing.

* Follow symlinks and terminate dirname options when locating a denied ancestor

* Strip trailing slashes before probing for a symlinked denied ancestor

* Tighten the denied-ancestor comments in setup.sh and its test

---------

Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com>
2026-08-08 01:18:24 -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
Michael Han
6f4cce68d1
Setup: clear stale WebView caches on install and update, keep user settings and data (#7361)
* Setup: clear stale WebView caches on install/update, keep user data

The desktop app's WebView caches (keyed by the Tauri bundle id
ai.unsloth.studio) hold copies of the previous frontend and keep serving
them after an update, so old styles linger even though the code on disk
is new. Clear cache-only paths on every install/update via setup.sh and
setup.ps1 (both fresh installs and 'unsloth studio update' route through
them).

Cleared: the HTTP/network caches, CacheStorage, service workers, and GPU
caches for the bundle id. Kept: LocalStorage, IndexedDB, cookies,
settings, models, and the studio database.

macOS: ~/Library/Caches/<bid> and WebsiteData/{CacheStorage,
ServiceWorkers,DiskCache}. Linux: XDG cache dir. Windows:
EBWebView\Default\{Cache,Code Cache,GPUCache,Service Worker}.

Adds tests/sh/test_setup_webview_cache_clear.sh covering both OS
branches, XDG overrides, and that user-facing storage survives.

* Address review: Linux data-dir caches, bash invocation, pre-webview clear

Three review findings, all verified:

1. wry keys the WebKitGTK base-cache dir to the app DATA dir (same as
base-data), so on Linux the stale frontend cache also lives under
~/.local/share/ai.unsloth.studio. Clear the cache-typed subdirs there
(WebKitCache, CacheStorage, serviceworkers) while keeping localstorage,
indexeddb, and cookies. Tests extended to 23 assertions.

2. run_all.sh invoked the new test with sh, but the extracted setup.sh
function uses bash arrays; dash aborts with a syntax error before any
assertion. Invoke with bash.

3. The in-app desktop update runs start_backend_update before
downloadAndInstall/relaunch, so setup.ps1/setup.sh clear caches while
the live WebView still holds them and silently fail. Clear the caches
from the Rust side in main() before the Builder runs: the config window
(and the WebView lock) exists by the time setup hooks fire, so this is
the one point where the profile is guaranteed unlocked. Same cache-only
path lists per OS; cargo check passes.

* Address review: gate the native clear on the app version, correct the macOS paths

* Tighten the comments added for PR #7361

* Serialize the WebView cache clear, stamp only a full clear, ignore relative XDG_DATA_HOME

* Take the profile lock before the stamp check, and detect dangling symlinks in the test

* Clear WebView caches only after the install-root override is validated

* Invalidate the app version stamp when setup clears the WebView caches

* Tighten the comments in the WebView cache clear

* Clear WebView caches after validating the override on Windows too

* Only clear WebView caches for a real Studio installation

* Tighten the comments added since the last pass

* Clear the WebView cache after single-instance arbitration

Resolve the profile through dirs::data_local_dir, the same call Tauri's
PathResolver makes for BaseDirectory::LocalData, so a launch with HOME or
LOCALAPPDATA stripped still finds the profile Tauri is about to use.

Move the clear into a plugin registered directly after single-instance.
Plugin setup hooks run inside Builder::build() in registration order and the
config window is only created later from App::run(), so a duplicate launch
has already exited by then and the WebView does not exist yet.

Make the no-venv fixture hermetic: report no system node/npm and opt out of
the isolated install so setup.sh skips Node provisioning and the frontend
build, and assert it still aborts at the venv check.

* Shorten the comments added in the last commit

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-07 06:35:01 -07:00
Michael Han
89206b63a8
Studio: cap llama.cpp source build parallelism by RAM (#8061)
* Studio: cap llama.cpp source build parallelism by RAM

The source build passed the logical core count straight to cmake -j. On a
20-thread 16 GB machine that is -j20, and each nvcc translation unit peaks
near 2 GB, so the build oversubscribed memory and left the machine
unresponsive with no way to stop it.

Budget the job count from total physical RAM instead: reserve 2 GB for the
OS, allow 2 GB per job, and clamp to the core count. RAM is only ever a
ceiling, so machines with headroom keep every core. When RAM cannot be read
the job count falls back to the old core count rather than guessing low.
UNSLOTH_LLAMA_BUILD_JOBS overrides both.

setup.ps1 carries its own copy because it cannot source setup.sh; the
reserve and per-job constants are pinned to matching values by the test.

* Address review: honour the cgroup memory limit when capping jobs

/proc/meminfo is not namespaced, so inside a memory-limited Docker or
Kubernetes container it reports the host's RAM. A 4 GB container on a
large host therefore still budgeted from the host and would be OOM-killed
during a source build, which is the same failure this cap exists to stop.

Read the cgroup v2 then v1 memory limit and take the lower of it and
MemTotal. v2's 'max' and v1's unlimited sentinel are not limits: the
former does not parse, the latter loses the comparison on its own.

Windows has no cgroups, so setup.ps1 is unchanged.

* Address review: resolve the binding cgroup, and budget from available memory

Two follow-ups on the cap.

The first cgroup reader only looked at the hierarchy root. That is the
binding limit inside a container with a private cgroup namespace, but under
Slurm, systemd slices or --cgroupns=host the limit sits on the process's own
path or an ancestor's, and the root reads 'max'. Those environments kept the
original OOM behaviour. Mirror unsloth/dataset_num_proc.py instead: read
/proc/self/cgroup, walk innermost-first to the root, pair each limit with the
usage of the directory that set it, and take the smallest remaining
allowance. memory.high binds as much as memory.max.

MemTotal also ignored what the machine is already doing, so a 16 GiB box with
8 GiB resident still got a 14 GiB compile budget, which is the thrashing this
change exists to stop. Budget from MemAvailable, which counts reclaimable page
cache, falling back to MemTotal on pre-3.14 kernels. Windows has no cgroups
but the same installed-versus-available gap, so it reads AvailableMBytes from
the raw perf class, which is not localized the way Get-Counter paths are, and
keeps installed RAM as the fallback.

The readers take the cgroup root and /proc/self/cgroup as arguments, so the
tests drive real trees rather than faking /sys.

* Address review: resolve the cgroup mounts instead of assuming their paths

The v1 branch assumed the memory controller sits at <root>/memory. It usually
does, but a co-mounted or relocated hierarchy was skipped even when
/proc/self/cgroup named a memory controller, and the budget then fell back to
host memory, which is the silent revert this cap exists to prevent. The v2
branch assumed the unified mount is the root, which systemd hybrid mode
breaks the same way.

Resolve both from /proc/self/mountinfo: match fstype cgroup2 for the unified
mount, and the memory controller by super option for v1, so a co-mount is
found by name rather than by path. The conventional layout stays as the
fallback when mountinfo is unreadable.

unsloth/dataset_num_proc.py still assumes <root>/memory for v1; this is
deliberately the more thorough of the two rather than a drift.

* Address review: map the process path through the cgroup mount root

_cg_mount kept mountinfo field 5 and discarded field 4, the filesystem root
that mount exposes. A bind-mounted subtree, which is what Docker without a
cgroup namespace and systemd slices produce, shows a mount root like /slice
while /proc/self/cgroup still reports the host-absolute /slice/job, and the
files are then at <mountpoint>/job. Joining the two unmapped walked a path
that does not exist, so the walk settled on an outer limit instead of the
binding one and over-estimated the allowance.

Return the mount root alongside the mount point and strip it from the process
path before walking. A process outside the mount's root gets only that
mount's own limit, since nothing below it describes that process.

* Address review: pick the cgroup mount that contains the process

A hierarchy can be mounted more than once, and _cg_mount stopped at the first
match. An unrelated subtree listed ahead of the binding mount meant the
process path did not map into the chosen mount, so the hierarchy was dropped
and the budget fell back to host memory.

Collect every matching mount and choose one whose root contains the path from
/proc/self/cgroup, most specific first, keeping the first seen when none of
them does. The process path is now read once, before mount selection, since
the choice depends on it.

* Address review: decode mountinfo escapes, keep colons in cgroup paths

mountinfo represents a space, tab, newline or backslash in a path as an octal
escape, and both path fields were used verbatim, so a hierarchy mounted under
such a path resolved to a directory that does not exist. Decode field 4 and
field 5 before use. strtonum is a gawk extension, so the conversion is plain
arithmetic that also runs under mawk and BSD awk.

/proc/self/cgroup was split with -F: and read as field 3, which truncates the
path at the first colon inside it. A systemd unit name may contain one. Only
the first two colons are delimiters, so parse those and keep the remainder,
for both the v2 line and the v1 controller lines.

* Address review: keep zero available memory, and stop the newline transports

The mount point is an arbitrary directory and may contain a newline, which
mountinfo escapes as \012. Decoding it where the fields are read put that
newline back into the reader's own line-oriented output, splitting one mount
record into two, and _cg_dirs then walked a line-delimited list of paths that
could be split the same way. Both records now travel escaped and NUL-delimited,
and a single path is decoded once it is in hand.

On Windows, an AvailableMBytes of 0 is a reading, not a failure. Falling back to
installed RAM there handed a machine with nothing left its full core count,
which is the case this cap exists for. Unreadable is now -1.

The suite's host-memory assertions compared a cached MemAvailable against a
second live read, which raced on Linux. _usable_ram_mb takes its meminfo path
like every other reader here, so the numbers are pinned.

* Pin the trailing-newline sentinel on the decoded mount path

* Address review: budget macOS from available memory too

The macOS branch still read hw.memsize, which is installed RAM, so a 16 GiB
Mac with 8 GiB resident was handed a 14 GiB compile budget, the same gap
MemAvailable closed on Linux.

macOS has no MemAvailable. free + inactive + speculative + purgeable is the
reclaim-aware equivalent, and the page size comes from the vm_stat header
rather than being assumed 4096, which is wrong on Apple Silicon. Installed RAM
stays the fallback when the output does not parse.

Verified on a 64 GiB Apple Silicon host: 65536 MiB installed, 33157 MiB
usable, 15 jobs rather than the bare core count.

* Address review: keep a failing read from aborting the install

setup.sh runs under `set -euo pipefail` and NCPU=$(_llama_build_jobs) is on
the install's critical path, so a helper that returns non-zero does not degrade
the job count, it aborts the install at the build step. The old NCPU line could
not fail; this one calls ten helpers.

_cg_read ran `head | tr`, and `[ -r "$1" ]` does not rule the pipeline
failing out: a directory passes it, and a cgroup can be torn down between the
test and the open. It now reads with the builtin and strips with an expansion,
so there is no pipeline and no subprocess left to fail. The two awk calls in
_cg_mounts and _cg_unesc are guarded for the same reason.

The suite sourced the helpers into a plain `bash -c`, which is why none of
this was visible. It now also drives them with the real shell options on,
including the two AND-lists in _llama_jobs_for that are the classic footgun.

* Guard the remaining reads, and pin them under POSIX mode

bash applies errexit to a failing assignment in POSIX mode, which it does not
do by default, so `m=$(awk ...)` inside a helper aborts the install for anyone
with POSIXLY_CORRECT exported or bash invoked as sh. The meminfo and cgroup
reads are guarded like the others, and the suite now drives the helpers under
--posix as well, which is what makes those guards observable.

Also pins the two properties the earlier guards left untested: _cg_read returns
rather than blocking on a FIFO, which is what the -f test is really for, and a
padded value is still parsed, which is what the whitespace strip is for.

* Pin the read guards in the form that distinguishes them

The POSIX assertions passed the reader as an argument, where the failure status
is discarded, so they held with the guards removed. The distinguishing form is
the assignment, which is what the real call site uses, so they assign now.

Removes the guard on the _cgroup_free_mb read: that function is guarded
internally and ends in `return 0`, so it cannot fail, and a guard no test can
make fire is worse than none.

* Ground the per-job budget in a measurement

The constant was justified as "an nvcc translation unit peaks near 2 GiB".
Measured against llama.cpp master with CUDA 13.1, the heaviest units are the
flash-attention template instances at ~400 MiB, flat in arch count, and a full
CUDA build at -j20 peaks at 8.2 GiB in aggregate across ~30 concurrent compiler
processes.

The number does not change: 2048 still has to cover the process fan-out, MSVC
and hipcc which are not measured here, older toolkits which were far heavier on
the same files, and the link step. But the comment now says what was measured
and why the budget sits above it, rather than asserting a figure the current
toolchain does not produce.

* Bring the macOS reader under the same guards, and make its test run on Linux

Two things on the vm_stat commit, both the same shapes already fixed on the
Linux side of this change.

_vm_stat_avail_mb ends in an unguarded awk, so under POSIX mode (POSIXLY_CORRECT
in the environment, or bash invoked as sh) a vm_stat that does not parse aborts
the install rather than falling back to installed RAM.

And "_usable_ram_mb prefers vm_stat over hw.memsize" says it runs on Linux
runners, but the branch it targets is an elif on sysctl succeeding, and Linux
sysctl has no hw.memsize, so the branch was never entered and the assertion held
vacuously off a Mac. It stubs sysctl now, which is what actually reaches it, and
a companion assertion covers the fallback when vm_stat gives nothing.

Mutation-checked: unguarding the awk fails 2, ignoring vm_stat fails 3,
assuming a 4096 page size fails 4, counting active and wired fails 3.

* Restore the vm_stat awk guard

Belongs with the previous commit; the guard was dropped when the tree was reset
between the mutation checks and the commit.

* Keep a zero vm_stat reading rather than treating it as a parse failure

Get-UsableMemoryMb returns 0 when AvailableMBytes is 0 and reserves -1 for
an unreadable one. The shell reader did not make that distinction: a Mac
with nothing reclaimable read as unparseable and fell back to hw.memsize,
which is installed RAM, so the machine got its full core count. That is
the oversubscription this PR exists to remove, on the one input where it
matters most. Zero is a reading now; only a missing page size is not.

* Treat a zero cgroup limit as a limit

_cg_limit rejected a zero-byte limit as though it were the absence of one, so a
cgroup that permits no further memory budgeted from host MemAvailable and took
the full core count, which is the oversubscription this cap exists to remove.

Only memory.high can be observed at zero: it throttles rather than killing
(cgroup-v2.rst, "Going over the high limit never invokes the OOM killer"), so a
process really does run under systemd's MemoryHigh=0. memory.max of 0 invokes
the OOM killer, so nothing survives to read it. Zero is now a reading here, the
same as on the Windows and macOS sides.

Mutation-checked: restoring the -gt 0 test makes the fixture report its
ancestor's 4 GiB instead of 0, and fails the new assertion.

* Inspect every containing cgroup mount, and make the suite hermetic

A limit above the narrower mount's root is invisible through that mount and
visible through the broader one, so picking only the most specific hid it. The
same hierarchy really is mounted twice with different subtree roots: rootless
podman inside rootless podman leaves a host-derived bind mount beside a
namespace-scoped one (containers/podman#21376). _cg_pick_mounts now yields every
mount whose root contains the process path and the walk takes the smallest
allowance across all of them, which is order-independent and can only lower the
budget, never raise it.

Three test-isolation defects alongside it, each of which would have gone red on
a runner rather than on this box:

- _usable_ram_mb hardcodes /sys/fs/cgroup, so inside a memory-limited container
  the assertions about HOST memory received the container's allowance instead of
  the fixture. In a 4 GiB cgroup they return 1 where they expect 5 and 20. The
  clean-machine install CI runs in containers, so this was reachable. Anything
  not testing the cgroup reader itself now stubs it out.
- The FIFO case ran wherever mkfifo exists, but stock macOS ships no GNU timeout,
  so it exited 127 and failed a correct reader. It requires both now.
- The suite inherited an exported UNSLOTH_LLAMA_BUILD_JOBS into every direct
  helper call, so a developer with the override set saw unrelated failures. It is
  unset once at the top; run_jobs still sets it explicitly per call.

Mutation-checked: most-specific-only fails 2, and the isolated zero-limit
fixture now fails 2 rather than 1 when the -gt 0 test is restored.

* Stop double-counting macOS reclaimable pages

free + inactive + speculative + purgeable overstates what can be reclaimed,
because neither of the last two is disjoint from the first two, and overstating
available memory buys back exactly the oversubscription this cap removes.

speculative is a subset of free, stated outright in xnu
osfmk/mach/vm_statistics.h: "NB: speculative pages are already accounted for in
free_count, so speculative_count is the number of free pages that are used to
hold data that was read speculatively from disk". purgeable is an attribute of a
page rather than a queue it sits on, so a volatile page is already counted on
whichever of the active or inactive queues holds it; the disjoint partition is
free + active + inactive + wired + throttled + compressor.

The sum is free + inactive. That under-counts by the purgeable pages on the
active queue, which are reclaimable but in neither term, and that is the safe
direction here: it costs build time on a busy Mac rather than the machine.

The fixture keeps non-zero speculative and purgeable counts so that adding
either back is visible. Mutation-checked: restoring speculative fails 6,
purgeable fails 6, both fails 6, and dropping inactive fails 6.

* Tighten the build job cap comments

---------

Co-authored-by: shimmyshimmer <133493246+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-07 04:12:10 -07:00
Long Yixing
d6b1b7dcf2
Studio: drop the mlx-lm 0.31.3 exclusion so current mlx-vlm resolves (#7061) 2026-08-07 02:41:04 -03:00
benj
6a58ea0f0e
Add Intel Arc GPU detection and XPU PyTorch install to Windows installer (#7706)
* Add Intel Arc GPU detection and XPU PyTorch install to Windows installer

The installer's GPU detection chain (NVIDIA -> AMD ROCm -> else)
has no Intel Arc/SYCL/XPU branch, so Intel Arc GPUs fall into the
"none (chat-only / GGUF)" branch and get CPU PyTorch despite
PyTorch publishing XPU wheels at download.pytorch.org/whl/xpu.

This adds:
- WMI-based Intel GPU detection (Arc, Iris, UHD, HD Graphics)
- Torch XPU availability check for migrated/upgraded environments
- An XPU PyTorch install path with the whl/xpu index
- CPU fallback with a pointer to the Intel oneAPI docs when XPU
  isn't available
- Updated messaging from "NVIDIA or AMD ROCm" to include Intel Arc

The XPU wheels ship their own oneAPI runtime (intel-sycl-rt et al.)
so no Intel oneAPI Base Toolkit is required for GPU training.

Tested on: Windows 11, Intel Arc 140V GPU (8GB), PyTorch 2.9.0+xpu

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* Fix Intel XPU detection and install path for PR #7706

The XPU index selected during GPU detection was overwritten by
Get-TorchIndexUrl before the install branch read it, so Intel hosts still
got CPU PyTorch while being told XPU wheels were being installed.

- Move the XPU reroute after Get-TorchIndexUrl, and let an explicit pin win
- Detect via Get-CimInstance (Get-WmiObject is absent in PowerShell 7)
- Match only Arc / Data Center GPU, so UHD / HD / Iris Xe are not promised XPU
- Split Intel GPU present from XPU-capable so the CPU fallback hint works
- Bound the XPU torch trio like every other index (bare names resolved
  torch 2.13.0 + torchaudio 2.11.0 and pulled unsloth back to an old release)
- Clear the XPU state after a CPU fallback, mirroring the ROCm path
- Teach the index family, GPU branch and torch flavor helpers about xpu

* Keep install.sh diagnostics in parity with the install.ps1 xpu family

install.ps1 now classifies an /xpu index leaf as family xpu / branch xpu, so
mirror the same two cases in _tauri_torch_index_family and _tauri_gpu_branch.
These feed the [TAURI:DIAG] line only, and a Linux user can already reach the
xpu index via UNSLOTH_TORCH_INDEX_FAMILY, where it previously reported
auto/unknown. Linux Intel auto-detection is not added here.

* Tighten the Intel XPU comments in install.ps1

Comment and whitespace only, no code change.

* Address Codex review on the Intel XPU path

- Run the Intel scan before the GPU report chain instead of inside its final
  else. A WMI-named-only AMD adapter set ROCmGpuLabel and took that chain, so a
  discrete Arc card next to an AMD CPU's integrated Radeon was never detected.
  The scan is gated on no usable NVIDIA or AMD, and the Intel branch ranks above
  the two AMD-present-but-unusable branches, so a usable AMD host is unaffected.
- Let a migrated env's torch veto the hardware match only when it is itself an
  XPU build. A CPU build reports torch.xpu.is_available() False for lacking XPU
  support, not for unsuitable hardware, and was blocking the CPU to XPU upgrade.
- Detect Intel in studio/setup.ps1 too. It only knew NVIDIA and AMD, so every
  successful Intel install printed none (chat-only / GGUF) right after
  install.ps1 reported a usable Arc GPU. Self-contained so studio update works.

* Address the second Codex round on the Intel XPU path

- Reset $script:IsIntelXpu at the start of each invocation. Under the documented
  irm | iex path $script: is the caller's session scope, so a second run in the
  same session inherited a stale true, skipped the scan on a now-NVIDIA host and
  still rerouted to the xpu index. Reproduced in pwsh before fixing.
- Gate the Intel scan on whether AMD actually gets a wheel, not on whether an AMD
  arch was seen. An arch missing from the family map has no ROCm wheels and lands
  on CPU torch, so it must not outrank a usable Arc card. The map is hoisted above
  the scan and consumed unchanged by the AMD reroute.
- Select the XPU index in studio/setup.ps1, not just report it. Previously setup
  printed Intel GPU detected and then installed CPU torch, so studio update never
  migrated an Arc box off CPU. Adds a bounded XPU install with a CPU fallback,
  teaches the stale-venv check about +xpu, and mirrors the wheel-aware AMD gate so
  the two files agree instead of wiping the venv on every update.

* Address the third Codex round on the Intel XPU path

- Force the dependency pass on an Arc host whose torch is not XPU-capable, the
  Intel counterpart of the existing AMD escape. Without it the fast up-to-date
  path skipped the install block, so the xpu index selection was never reached
  and a CPU venv never migrated.
- Confirm a working XPU runtime before treating an xpu venv as stale. If CIM is
  unavailable or returns an Intel name outside the Arc match, the expected tag
  fell through to cpu and a valid XPU environment was rebuilt and lost.
- Force-reinstall the XPU trio only when the installed wheel is not already
  +xpu, or the pin changed. It was unconditional, so a fresh install re-fetched
  multiple GB immediately and again on every update.
- Warn when torch.xpu.is_available() is false after installing XPU torch, naming
  the Intel driver floor. Otherwise the installer promised GPU training while
  unsloth raised NotImplementedError at import on a stale driver.
- Stop the detection probe vetoing the hardware match. Its cpu fallback could not
  displace the installed +xpu wheel, so it only mislabelled a capable GPU as
  unusable; the driver warning covers that case honestly, and setup.ps1 agrees.

* Bound the XPU probes, repair xpu pins in install.sh, and floor bitsandbytes on the Intel path

install.sh: teach _torch_flavor_tag, _expected_torch_flavor_tag and
_torch_index_repairable about the xpu leaf. The diagnostic already reported
gpu_branch=xpu, but an xpu pin fell to the custom arm so a migrated env kept
its CPU wheel. The +xpu flavor arm is required alongside, otherwise a correct
2.10.0+xpu wheel reads as cpu and gets force-reinstalled every run.

install.ps1 / studio/setup.ps1: route every torch probe through a new bounded
Invoke-BoundedPythonProbe (ProcessStartInfo, both streams drained async,
WaitForExit, kill on timeout). A hanging Intel driver init is exactly what
these probes detect, and an unbounded one would hang the installer instead of
reaching the warning. Timeouts read as not-available. Get-InstalledTorchTag
now shares the helper rather than carrying a second copy of the pattern.

install.ps1: install bitsandbytes>=0.50.0 on the XPU path. unsloth's floor is
>=0.45.5, so a migrated venv keeps a pre-0.49 wheel with no XPU library and
4-bit QLoRA silently turns off. Same floor the AMD paths use, since <=0.49.2
NaNs at 4-bit decode and an Arc card can sit next to a Radeon.

* Floor bitsandbytes on the Studio XPU migration and on an explicit xpu pin

studio/setup.ps1: `unsloth studio update` migrating a CPU venv to XPU replaced
only the torch trio. install_python_stack.py then upgrades unsloth and
unsloth-zoo alone, so an installed bitsandbytes 0.45.x kept satisfying the base
floor while carrying no Windows XPU kernels, and 4-bit QLoRA silently turned
off. Adds the same bitsandbytes>=0.50.0 --no-deps pass install.ps1 got, placed
after the stack so it is the last word, gated on $XpuIndexUrl (the CPU fallback
clears it, no-torch never sets it) and still inside the -not $SkipPythonDeps
block so the up-to-date escape does not reach it.

install.ps1: key the bitsandbytes pass off the index leaf instead of
$script:IsIntelXpu. An explicit UNSLOTH_TORCH_INDEX_FAMILY=xpu pin on a
non-Intel host skips the XPU branch but still installs the trio from the xpu
index, so torch is +xpu and needs the same floor. The CPU fallback rewrites
$TorchIndexUrl, so a failed XPU install reads as cpu and stays quiet.

* Tighten the Intel XPU comments across the three installers

Comment-only pass now that the review has settled: several blocks grew over
successive rounds and were restating the code or narrating the review. Net 36
lines removed, with the load-bearing facts kept -- why ProcessStartInfo rather
than the call operator, why both probe streams drain async, why the helper is
defined above the Intel scan, the 0.50.0 bitsandbytes floor and why not the
curated extra, and why PEP 440 means a migrated env can confirm but never veto
the Intel match.

Also records why the Studio bitsandbytes pass must stay above the
ErrorActionPreference restore: Fast-Install needs EAP=Continue or PS 5.1 turns
pip stderr into a terminating error.

No code tokens changed; verified with a PowerShell token-stream diff of
install.ps1 and setup.ps1, and by hand for install.sh.

* Bound the Intel WMI scan, bound the stale flavor probe, and stop CUDA Triton shadowing XPU

studio/setup.ps1: the stale-venv flavor probe read StandardOutput.ReadToEnd()
before WaitForExit, so the timeout was unreachable and a wedged import torch
hung studio setup forever; stderr was never drained either. Routed through
Invoke-BoundedPythonProbe, which already drains both streams and kills on
timeout. A timeout now reads as unreadable flavor, so the venv rebuilds.

install.ps1 / studio/setup.ps1: bound the Win32_VideoController query and add a
registry fallback. -ErrorAction suppresses errors but bounds nothing, and
-OperationTimeoutSec is not enforced for the local COM session this uses, so a
degraded WMI repository blocks forever. install_llama_prebuilt.py already runs
this query out of process for the same reason and documents an Arc A770 being
misrouted by it. The registry class key answers in-process; it is the fallback
rather than the fast path because a stale driver config can outlive the
hardware, and here a false positive would install XPU torch on a host with no
Arc.

studio/setup.ps1: replace triton-windows with torch's own XPU triton after the
stack. Both distributions own the top-level triton package, sharing 151 paths
including __init__.py and _C/libtriton.pyd, so an in-place cu-to-xpu repair
leaves the CUDA build shadowing the XPU one. Removing it alone would delete the
shared files the XPU wheel overwrote, and unsloth declares triton-windows as a
win32 dependency so an earlier removal is reinstalled by the stack: uninstall
and reinstall, after the stack, only while triton-windows is present. The spec
is read from the installed torch, since the name changed from
pytorch-triton-xpu to triton-xpu in torch 2.10.

* Tighten the comments added with the bounded scan and Triton replacement

Comment-only pass over the previous commit's additions, which had not been
through one: 15 lines removed across the two bounded-scan headers, the two
registry-fallback headers and the Triton block.

Kept the facts that cost measurement: -OperationTimeoutSec not being enforced
for a local COM session, Ok being false on an empty answer because a Windows
host always has an adapter, the registry class key being fallback rather than
fast path here, the 151 shared Triton paths, and why the uninstall has to be
paired with a reinstall after the stack.

No code tokens changed; verified with a PowerShell token-stream diff of both
files, which also confirms the two helper copies stay identical.

* Stage the Triton replacement behind a download so the uninstall cannot strand the venv

The replacement uninstalled triton-windows and then installed the XPU triton
from the index. A failure between the two left the venv with a partially
deleted triton, since the uninstall drops the paths shared with the XPU
distribution, and the warning made that look like a skipped optional repair.

The uninstall cannot go last, because it removes the paths in triton-windows'
own record and those are the shared ones. So fetch first: pip download the
wheel, confirm one is actually on disk (exit 0 alone is not enough, an
sdist-only mirror satisfies that), and only then uninstall and install the
local file. A local wheel installs with the network refused, so nothing after
the destructive step depends on the index. A failed fetch leaves
triton-windows in place, which is the pre-existing shadowing rather than a
broken venv, and says so.

Past that point only disk or permissions can fail, so restore triton-windows
if the local install does, leaving a triton that imports. If both fail the
message is loud and carries the repair command, with the index URL redacted
since a mirror pin can carry a token.

pip only: uv has no pip download (astral-sh/uv#3163).

* Windows: harden the Intel registry fallback and declare the XPU install state up front

Get-IntelRegistryAdapterNames wrapped the whole enumeration in a single try, so one
unreadable subkey discarded every adapter found before it. windows_intel_gpu_in_registry(),
the in-process Python probe over the same class key, skips per subkey and continues; the
PowerShell copy now does too. It also matched on the PCI vendor id but returned DriverDesc,
which the callers re-filter on "Intel", so a localized or OEM-branded Arc was found here and
dropped there. Both installers carry the same copy and a test asserts they stay identical.

setup.ps1 read $installedTorchTag and $XpuIndexUrl from outside the blocks that assign them.
Unset and $null are both falsy so behaviour is unchanged, but a caller running with
Set-StrictMode -Version Latest turned those reads into terminating errors, and install.ps1
is documented as irm | iex into the caller's own session.

Two comment corrections: 0.48.2, not 0.49.0, is the first win_amd64 bitsandbytes wheel
carrying libbitsandbytes_xpu.dll, and the triton package overlap is version-dependent
rather than a fixed 151 paths.

The new test drives the shipped helper with the registry cmdlets mocked rather than reading
a hive, so it runs on Linux and macOS as well as Windows.

* Studio: show the Intel XPU runtime row in the About tab

hardware.py has always emitted versions["xpu"], but HardwareInfo only ever declared cuda and
rocm. On an Arc host both of those are null, so the runtime row disappeared entirely while
the GPU name and VRAM rows still rendered, leaving a host that looks half detected. That was
unreachable on Windows until the installer learned to select XPU wheels, which is what makes
it worth fixing here.

The three-way choice is lifted into a helper at module scope: inlining it pushes AboutTab
past the cognitive-complexity ceiling. The label is a proper noun, so every locale carries
the same literal.

* Windows: reach Intel XPU through a localized name, a stale fast path and an old wheel

Four holes in the XPU paths, all found by driving the shipped code rather than reading it.

The registry fallback only ran when the CIM scan failed. When it succeeds and returns a
localized adapter name, which on non-English Windows carries no ASCII "Intel", the filter
dropped the adapter and the host went to CPU torch. The registry now re-labels an adapter
WMI already reported, matched by name so an entry naming nothing WMI listed stays ignored:
a driver record outliving its card still cannot promote a host WMI answered for.

The XPU trio accepted torch 2.4 and 2.5, which unsloth/models/_utils.py rejects at import
for an XPU device. An xpu mirror carrying only an older wheel produced an install that
reported success and then failed on the first import, and an existing 2.5+xpu venv was kept
because it satisfied the range. The floor is 2.6 on the XPU paths only; the CPU fallback
keeps 2.4.

The "package is up to date" fast path escaped for an Arc host on CPU torch, but not for one
already on XPU torch whose bitsandbytes predates the XPU kernels or whose triton-windows
still shadows the XPU Triton. Those two live in the dependency pass, so a venv that reached
+xpu without them, an explicit pin or an update whose first pass ran the pre-XPU setup.ps1,
never got them on any later update either. An unreadable version reads as stale.

install_python_stack.py writes its completion manifest immediately before returning, so an
interrupt between the triton-windows uninstall and the XPU wheel install left a venv with no
triton that the next update read as complete. The manifest is now held aside across the swap
and restored only once a triton is importable again.

* Windows: move the install manifest across the Triton swap instead of rewriting it

Two problems with the hold added in 2603fc809, both on the restore side.

Reading and rewriting the file cannot survive a manifest carrying a non-ASCII path. Windows
PowerShell 5.1 writes Set-Content in the ANSI code page by default, and its -Encoding utf8
emits a BOM that install_manifest.read_manifest's json.load rejects outright
("Unexpected UTF-8 BOM"); Get-Content is ANSI on a BOM-less file too, so the read lost bytes
before the write got a chance to. The manifest is now MOVED into the wheel's temp directory
and moved back, so no encoding is involved at either end. That directory is already removed
in the finally, which is what keeps an unrestored manifest gone.

A manifest that would not move left the old valid one in place for the whole destructive
window, since the failure only cleared the saved copy and carried on into the uninstall.
That is the case the hold exists for, so it now skips the swap entirely and says so:
triton-windows keeps shadowing the XPU Triton, which costs torch.compile on the GPU and is
repairable on the next run, rather than risking a venv with no Triton that reads as complete.

* Windows: confirm the install manifest actually moved before the Triton swap

Move-Item across volumes is a copy followed by a delete, and it reports success when only
the delete fails, leaving the original exactly where it was. So the guard added in af928dd88
could believe it had set the manifest aside while a valid one sat there for the whole
destructive window, which is the case that guard exists to prevent.

Found by modelling the manifest in the setup.ps1 scenario matrix, which this had no coverage
for: with the parent directory read-only the swap still ran, and the locked scenario passed
for the wrong reason. The move is now confirmed by testing the source path afterwards, and a
manifest still standing aborts the swap like any other failure to move it.

Four new scenarios cover it: the swap keeping a byte-identical manifest, a swap where neither
Triton reinstalls correctly leaving it gone, a failed fetch never touching it, and a manifest
that cannot move aborting the swap.

* Windows: key the XPU fast-path remediation off the installed wheel, not just the GPU scan

$HasNvidiaSmi suppresses the Intel scan, so on a mixed NVIDIA + Intel box under an explicit
xpu pin $script:IsIntelXpu stays false while the pin still lands the venv on a +xpu wheel.
The staleness check added in 2603fc809 was gated on that flag alone, so those hosts kept
taking the fast path and never reached the bitsandbytes floor or the Triton replacement.

This is the same gating mistake the bitsandbytes pass had in round 4, where the fix was to
key off the index leaf rather than the scan. The leaf is not resolved yet at the fast path,
but the installed flavor tag is, and whatever put the venv on a +xpu wheel the two
remediations still apply. The runtime probe above stays on the scan: reinstalling XPU torch
is only right where an Intel GPU was actually found.

A pure NVIDIA host on a cu wheel never runs the probe, which the matrix asserts alongside the
two new mixed-host rows.

* Windows: reconcile Intel names for hybrid GPUs, and stop the XPU escapes firing where XPU is unreachable

Five fixes from a review of the XPU work so far.

The registry reconciliation was gated on "no ASCII Intel name present", so a hybrid laptop
reporting its Intel UHD alongside a localized Arc stopped at the UHD and left the Arc
unrecognised. It is now gated on the absence of an XPU match, and the regex behind both that
gate and the classification is defined once so they cannot drift.

The two fast-path escapes cleared $SkipPythonDeps for any Intel host, but the XPU install and
its two remediations are all gated on $XpuIndexUrl, which an explicit cpu / rocm / custom-leaf
pin never sets, and no-torch mode has no torch pass at all. Those hosts ran the whole
dependency pass, installed nothing new, and re-fired the identical condition on every later
update. Both escapes now require XPU to be reachable.

The manifest path was learned by a subprocess whose output parsing could not work: `& python`
returns one array element per line, interpolating that joins on $OFS, a SPACE, so splitting
on newlines yields a single element and a banner ahead of the answer arrives glued to the
path. Any such failure then skipped the hold silently and swapped anyway, which is the
window the hold exists to close. manifest_path() is venv_root()/MANIFEST_NAME and venv_root()
is sys.prefix, which is $VenvDir here, so it is assembled like Get-PersistedNoTorch already
does. A test asserts the literal still matches MANIFEST_NAME.

The uninstall's exit code was discarded. A triton-windows that will not uninstall, which on
Windows means Studio is running and holding libtriton.pyd open, still shadows the XPU Triton,
so installing over it achieved nothing and restored the manifest onto a venv this pass was
supposed to have changed.

The restore had no verification and an empty catch, while the finally deletes the held copy
either way, so a failed restore lost the manifest with nothing on screen.

* Windows: keep the WMI adapter list an array so the Intel re-label appends instead of concatenating

`$_gpuNames = if (...) { @(...) } else { @(...) }` wraps each branch, and a one-element array
unrolls on its way out of the if, so on any single-adapter host $_gpuNames was a String. The
`+=` that re-labels a localized adapter then concatenated two strings rather than appending a
name, and the GPU reported to the user came out doubled:

    Intel(R) UHD Graphics 620Intel Intel(R) UHD Graphics 620

No install decision changes. The re-label only appends a registry name that already contains
the WMI name, so the concatenation matches the Arc / Data Center regex exactly when the
registry name alone would, and every scenario in the matrix records the same verdict either
way. It is the displayed adapter name that was wrong.

Widened by the previous commit: gating on the absence of an XPU match rather than of any Intel
name brought ordinary single Intel iGPU hosts into the re-label for the first time.

@() now wraps the whole if in both installers, with a test asserting it stays that way.

* Windows: give pin-only XPU installs the 2.6 floor, and treat an unreadable dependency probe as stale

The XPU install branch required $script:IsIntelXpu as well as an xpu index leaf, so an
explicit FAMILY=xpu or URL pin on a host whose Intel scan never ran -- a mixed NVIDIA box,
where $HasNvidiaSmi suppresses it -- fell through to the generic branch and its torch>=2.4.
Against a mirror carrying an older +xpu wheel that installs a torch unsloth rejects at import.
Keyed off the leaf alone now, which is what the bitsandbytes gate below it already does and
says in its own comment. install.sh had the same gap from the other direction: its xpu leaf is
reachable only by an explicit pin and kept the generic floor, so it gets the same 2.6 trio.

The fast-path dependency probe treated "did not answer" as "nothing to do". A timeout, or a
malformed .dist-info making distributions() raise, then left the fast path intact and an XPU
migration never reached the bitsandbytes floor or the Triton replacement on any later update
either. It now clears the fast path, the same direction an unparseable version already took.

Two install.ps1 rows move, both FAMILY=xpu pins on non-Intel hosts, both onto the XPU branch.
The CPU fallback after a failed XPU install keeps its 2.4 floor.

* Windows XPU: probe the preserved venv, drop torchaudio on ARM64, and give POSIX XPU the bitsandbytes floor

Three fixes to the Intel XPU paths.

install.ps1, migrated-runtime probe: a rerun over an existing install moves the old
venv to $script:StudioVenvRollbackDir and creates an empty one in its place, both
before this probe runs, so it always asked an interpreter with no torch and answered
"no XPU". Ask the preserved environment when there is one, which is the migrated
runtime the fallback exists for.

install.ps1, Windows on ARM: no win_arm64 torchaudio wheel exists on any index.
Keying the XPU branch off the index leaf alone routes an arm64 interpreter into a
branch that hardcoded the trio, so the install aborted. Ask the interpreter for its
platform tag, as the generic path already does, and drop that one pin on arm64.
The CPU fallback below it gets the same treatment.

install.sh, XPU pins: bitsandbytes ships XPU kernels (libbitsandbytes_xpu2025.so and
_xpu2026.so) from 0.50.0 on manylinux, and nothing on the POSIX side raised the floor
for them, so a migrated environment kept a pre-XPU build and lost 4-bit QLoRA on a
torch that otherwise works. Matches what the Windows XPU pass already installs.

* Studio: stop the xpu label test from forbidding a partial locale

check-parity.ts states the contract plainly: "Locale files may be partial; missing keys
must fall back to English." The new test required every overlay to carry the xpu label,
which contradicts that and breaks on the next locale anyone adds. It already did: it.ts
landed on main after this branch, so the merged tree fails on all three runners even
though nothing about the label is wrong there. The label is a proper noun, so the
English fallback is byte-identical to a translation and the requirement bought nothing.

Assert what actually renders wrong instead: en.ts must carry the key, because it is the
fallback every locale resolves to, and no overlay may define a value that disagrees with
it. Both halves were checked against a merged working tree, and both still fail when the
condition they guard is broken.

* Linux XPU: hoist the bitsandbytes pass out of the fresh-install arm

It sat inside `elif [ -n "$TORCH_INDEX_URL" ]`, which a migrated environment never
enters because the `_MIGRATED` arm above it wins, so the one environment the pass
existed for was the one that skipped it. The AMD passes handle this by existing twice,
once per arm; this gate needs nothing branch-specific, so it moves past the chain
instead and both arms reach a single copy.

tests/sh/test_xpu_bitsandbytes_reachable.sh guards both halves: the block must be
placed where every arm reaches it, and it must still fire only on the xpu leaf.
25 checks over [migrated, fresh] x [xpu, mirrored xpu, cuda, rocm, cpu, none] x
[torch, no-torch], run against the block and the leaf parser extracted from install.sh.
Moving the block back inside an arm fails it.

* Report the XPU runtime before the hardware summary, and show every runtime in About

setup.ps1: the hardware report runs ~1300 lines before the torch.xpu.is_available()
check that keeps an XPU environment, so a host the WMI scan and the registry fallback
both miss (wedged CIM service, an Intel part outside the Arc|Data Center regex) was told
"none (chat-only / GGUF)" and then watched setup keep the XPU venv. Ask the same question
before printing, so the report and the decision cannot disagree.

A free disk read gates the interpreter launch: torch/version.py carries the local label,
so a CPU-only host never pays for an `import torch` on every `studio update` just to be
told it has no Intel GPU. The dist-info name cannot be used for this -- pip normalises the
local label out of it (torch-2.9.1.dist-info for a +cu128 wheel). The promotion carries its
own try: it must still run when the scan threw, which is the case it exists for, and a junk
UNSLOTH_STUDIO_HOME would otherwise abort setup from Join-Path.

about-tab.tsx: hardware.py reads versions["cuda"] off torch.version.cuda and sets
versions["xpu"] from an independent torch.xpu.is_available() probe, and UNSLOTH_FORCE_XPU=1
is a supported configuration where CUDA is present but XPU is selected. Both are non-null
there, so returning the first match hid the XPU row on exactly the host it was added for.
Collect every reported runtime instead.

tests/studio/test_setup_xpu_runtime_prereport.ps1 covers the two new helpers with the
filesystem mocked, so it runs on all three runners: override precedence, ~ expansion, the
four wheel flavours, a missing or unreadable version.py, and wiring assertions that the
promotion precedes the report and that the cheap read gates the probe. The About-tab test
gains a case that fails if the picker returns early again.

* POSIX: recognise a working XPU runtime, and raise the bitsandbytes floor on the update path

The hardware summary tested NVIDIA, AMD and Apple Silicon and then fell through to
"none (chat-only / GGUF)", so a Linux host running the +xpu wheel install.sh had just
installed was told training needs an NVIDIA or AMD GPU. Added an arm ranked below both,
matching setup.ps1.

The bitsandbytes floor was also unreachable on the route an existing XPU user actually
takes. `unsloth studio update` runs this file, never install.sh (see the note at the top
of setup.sh), and neither this file nor install_python_stack.py had an XPU floor, while
unsloth's own dep floor is 0.45.5 -- which a pre-XPU wheel satisfies indefinitely. So
4-bit QLoRA stayed unavailable on a torch that otherwise works.

One detection serves both, but they read different signals on purpose. The floor keys on
the WHEEL (+xpu, read off torch/version.py) and the summary keys on the RUNTIME
(torch.xpu.is_available()): a +xpu wheel installs fine on a host whose driver never
initialises, and that host should still get the kernels while no GPU is claimed for it.
The disk read gates the interpreter launch, so a CPU-only host pays nothing per update.

tests/sh/test_setup_xpu_posix_summary.sh builds real venv trees, version.py files and
stub interpreters rather than mocking, so the disk read and the runtime probe genuinely
execute: 13 checks over the four wheel flavours, working/dead/missing runtime, no venv,
and the arm's rank. Removing the arm fails four of them.

* POSIX XPU: make the bitsandbytes step nonfatal, bound the probe, and act on an XPU pin

Three defects in the POSIX XPU code from the previous commit.

run_quiet routes failure to setup_fail and exits, so the best-effort bitsandbytes
upgrade could abort an otherwise fine `studio update` over a transient download, and
the warning after it was unreachable. run_quiet_no_exit is the nonfatal wrapper.

The runtime probe had no timeout. A stalled Intel driver wedges inside `import torch`,
which is exactly the host this probe classifies, so it could hang every update forever.
Bounded at 60s rather than the 10s the smi probes use: a cold `import torch` takes
seconds by itself and a short bound would read a healthy host as having no GPU. Systems
without coreutils timeout keep the previous behaviour rather than losing detection.

An explicit XPU pin was protected but never acted on. An xpu leaf names no family the
cuda/rocm repair helpers know, so _explicit_unknown_family_torch_index_url makes both
skip it, and `unsloth studio update` never runs install.sh -- so switching a CPU install
to UNSLOTH_TORCH_INDEX_FAMILY=xpu left the CPU wheel in place indefinitely. The fix goes
in install_python_stack.py, which already parses the pin, rather than setup.sh, which has
no pin awareness at all: _ensure_xpu_torch mirrors the existing _ensure_cpu_torch, the
xpu leaf is classified so the backend is no longer unknown, and the ROCm helper skips an
xpu backend so it cannot treat the pin as an AMD host. Windows is excluded because
setup.ps1 owns torch there and installs the trio itself.

That put the XPU trio in a third file, so tests/sh/test_xpu_torch_spec_parity.sh asserts
the floors match across install.sh, install_python_stack.py and install.ps1 plus the
wiring. Each of its four structural guards was mutation-tested: a drifted floor, a lost
classification, wiring at one call site instead of two, and the ROCm skip removed all
fail it. The POSIX summary suite gains checks for the nonfatal wrapper and the bound.

* Linux XPU: swap generic Triton, gate the pin repair on the version, and escape the fast path

Three defects in the XPU code from the previous commit.

_ensure_xpu_torch returned on the +xpu tag alone, so a migrated 2.5+xpu venv was left
in place even though unsloth/models/_utils.py raises at import for an XPU device below
2.6. It now returns only when the flavour and the supported range both match.

That repair was also unreachable on the route it was written for. setup.sh skips
install_python_stack entirely when the package version is current, and that pass is the
only thing that acts on an XPU pin, so a CPU install switched to the xpu family stayed
CPU. Added a third fast-path escape beside the anyio and incomplete-manifest ones.

Generic triton and torch's pytorch-triton-xpu / triton-xpu both own the top-level triton
package, and resolving unsloth against a pinned +xpu torch pulls both -- uv reports
pytorch-triton-xpu 3.5.0 alongside triton 3.7.1 -- so the CUDA-oriented build lands last
and torch.compile loads the wrong library on an Intel GPU. This is the POSIX half of the
Windows swap: the spec is read from torch's own metadata, so the pytorch-triton-xpu to
triton-xpu rename at torch 2.10 needs no hardcoding, and the fetch happens before the
uninstall because the uninstall drops the shared paths from generic triton's own record.

test_torch_installs_do_not_use_deprecated_index_url forbade --index-url on
"$TORCH_INDEX_URL" anywhere in install.sh. That rule is about uv, which deprecated the
flag in favour of --default-index; pip never had --default-index, so the pre-fetch
legitimately uses it. The assertion is now per occurrence and exempts pip download only,
and it joins backslash continuations first, since the flag and its command are routinely
on different physical lines. Both a same-line and a continuation-line uv offender were
mutation-tested and are still caught.

tests/sh/test_xpu_triton_swap_posix.sh asserts the swap by execution -- ordering, the
rename, no generic triton, torch wanting CUDA triton, non-xpu index, no-torch, empty
index, and a dead mirror that must warn without removing anything.

* XPU: move the Triton swap where both routes reach it, and bootstrap pip for it

Five defects in the XPU code from the previous commits.

The Triton pre-fetch could never have run. `uv venv` is created without --seed, so a
fresh venv has no pip and `python -m pip download` fails with "No module named pip"
every time, leaving the swap a no-op that only ever warns. My shell test missed it
because its stub interpreter answered pip commands. install.sh already bootstraps pip
this way before its pre-release bitsandbytes wheel.

The swap also never ran on `unsloth studio update`, which runs setup.sh and never
install.sh. Both fixes fall out of moving it: install.sh runs setup.sh, which runs
install_python_stack.py, so that module is the one place both routes pass through. The
install.sh copy is deleted rather than duplicated, and the shell test is replaced by
tests/studio/test_xpu_triton_swap.py, which covers the no-pip case and asserts install.sh
carries no second copy.

The fast-path pin match missed authenticated and fragmented mirrors
(https://mirror/whl/xpu?token=...), which read as "no XPU pin" and skipped the repair;
query and fragment are now stripped before the leaf test.

That escape also launched an interpreter, which a wedged Intel driver hangs inside. It
now reads the local label out of torch/version.py instead: nothing to bound, and a
CPU-only host pays nothing per update.

setup.ps1's fast path asked only whether XPU was available. A 2.5+xpu build answers yes
and is still rejected by unsloth/models/_utils.py at import, so it now checks the
supported range too, via Test-TorchXpuVersionSupported.

The POSIX suite is up to 22 checks; the three new guards were mutation-tested by removing
the query strip, the fragment strip, and by making the escape launch an interpreter.

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

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

* XPU: make a failed Triton swap unsurvivable, and widen the fast-path escapes

Five defects in the XPU code from the last two commits.

The Triton uninstall ignored its return code. A read-only or locked venv leaves generic
triton registered, so installing over it lets a later upgrade of that distribution delete
the shared files again, and every dependency pass repeats the swap. A failed uninstall now
changes nothing at all.

Past the uninstall the venv has no triton, because the uninstall takes the shared
top-level files with it, so a warning there let the caller write a completion manifest
over a venv whose torch.compile is broken -- and the next update fast-paths straight past
it, since no generic distribution is left to trigger on. That install is now fatal.

_ensure_xpu_torch returned when the probe timed out. On this path a wedged `import torch`
is evidence rather than noise: the usual cause is a stalled Intel driver under an
unsupported +xpu wheel, which the resolver keeps because it satisfies the base range. An
authoritative pin now repairs on an inconclusive probe. This deliberately differs from the
CPU counterpart, where a wedge has no such likely cause.

The fast-path pin match stripped one trailing slash, so a ".../whl/xpu//" pin still read
as no pin. It now strips them all, like the shared leaf parsers.

Moving the Triton swap into the Python stack left the fast path with no reason to run it:
a migrated environment with supported +xpu torch and a leftover generic triton kept the
CUDA-oriented build forever. A stale generic triton now forces the dependency pass too,
detected from the dist-info name so no interpreter is launched.

The POSIX suite is up to 26 checks and the Triton tests to 16. Two of the guards were
rebuilt after their own negative controls found them vacuous: the stale-triton check
matched the detection loop rather than the branch that acts on it, and a fixed line window
had drifted off the code it was meant to cover, so it is now anchored on the block.

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

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

* Do not require the XPU pin again after install

Three Intel paths still assumed the pin was still in the environment, or that
every XPU host looks like x64 Linux.

install_python_stack.py: the generic-Triton swap returned unless
UNSLOTH_TORCH_INDEX_URL / _FAMILY was set. That pin is one-shot -- a user who
ran UNSLOTH_TORCH_INDEX_FAMILY=xpu ./install.sh has nothing left in the
environment by the next plain `unsloth studio update`, yet that update's
dependency pass can pull generic triton back in and shadow torch's XPU build
again. The installed +xpu wheel is the durable signal (setup.sh already raises
the bitsandbytes floor off it), so fall back to it and to the default xpu
index. The label is read off disk: importlib.metadata drops the local version
label, and `import torch` loads the SYCL runtime, which can wedge.

install.ps1: the flavor repair built its own XPU trio including torchaudio,
which has no win_arm64 wheel on any index. A migrated ARM64 venv skips the
fresh XPU branch and takes this path, so the repair failed outright before
setup.ps1 could reach its ARM-aware fallback. One builder now serves both
sites, since the two copies drifted the moment only one learned about ARM.

install.sh: adding the xpu tag made the final flavor guard reachable on an
Intel pin, and it probes with an unbounded `import torch`. On a host whose
driver initialization wedges that hangs the installer, with no timeout
anywhere before setup.sh's bounded probes. The xpu path reads torch/version.py
off disk instead; every other family keeps the interpreter read unchanged.

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

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

* Key the setup XPU paths on the installed wheel, not the pin

Follow-up to d07c179f: install_python_stack now treats the installed +xpu
wheel as the durable signal, but the two callers upstream of it did not.

setup.sh fast path: the escape only ran under `case $_setup_pin in *xpu`, so
after a one-shot UNSLOTH_TORCH_INDEX_FAMILY=xpu install every later `studio
update` saw no pin, kept _SKIP_PYTHON_DEPS=true and never reached the Triton
swap at all -- generic triton kept shadowing the XPU build forever. The disk
read now happens unconditionally and the swap escape keys on the wheel. The
pin leaf is also compared exactly, like the shared index parsers: a custom
mirror ending in -xpu was classified as the curated family, which cleared the
skip flag on every up-to-date run while _ensure_xpu_torch declined to act.

setup.ps1: bounding the flavour probe turned a timeout into "rebuild", and the
host most likely to time out inside `import torch` is an Arc box whose compute
driver stalled -- where torch/version.py still names a good +xpu wheel. With no
currently exported pin the stale path then deleted the venv. It now falls back
to the same disk check and warns about the driver. Other families still
rebuild on an unreadable flavour.

setup.sh summary: a +xpu wheel whose runtime will not initialise fell through
to "none (chat-only / GGUF)", telling an Arc owner their hardware is
unsupported and hiding the driver update that fixes it. It gets its own arm.

* Stop the XPU paths from stranding or wiping a venv

Four ways the Intel paths could still leave a user worse off than before they
ran anything.

setup.ps1 stale check: on a hybrid NVIDIA + Arc host the XPU promotion is
gated on -not $HasNvidiaSmi, so a pinless `unsloth studio update` expects a
cu* tag, calls the working Arc venv stale and DELETES it -- then exits,
because only install.ps1 creates venvs. A direct update now keeps any +xpu
venv and says to re-run install.ps1, which rebuilds with a rollback copy.

setup.ps1 Triton swap: when the staged XPU wheel failed to install after
triton-windows was removed AND the generic restore also failed, the branch
only printed. $stackExit stayed 0, so setup reported success and install.ps1
committed a venv with no importable triton over its rollback. It now carries
the real failure code into the existing handler.

install_python_stack: the `pip download` that stages the XPU Triton wheel
inherited the user's pip index environment. PIP_NO_INDEX makes pip ignore
--index-url outright, and PIP_EXTRA_INDEX_URL / PIP_FIND_LINKS are consulted
in addition to it, so the fetch could fail (leaving generic Triton shadowing
the XPU build) or serve the wheel from an index the pin never named. It now
takes the same _install_env_for_cmd scrub every other pinned install gets.

setup.sh runtime probe: the arm taken when coreutils `timeout` is absent ran
the probe with no deadline, on exactly the stalled-driver host the bounding
exists for. The deadline now lives inside the probe as signal.alarm, which
terminates the process even while the driver blocks in C.

* Keep a preserved XPU venv on the XPU index

Follow-up to 10ba6e31c, which stopped a direct update wiping a +xpu venv on a
hybrid NVIDIA + Arc host but left the rest of the pass believing the host was
CUDA. The index chain prefers NVIDIA over Intel, and the CUDA arm does not
--reinstall-package torch, so uv left the +xpu wheel in place as satisfied
while installing triton-windows over torch's XPU triton -- and with
$XpuIndexUrl null nothing swapped it back. A half-converted venv is worse than
either end state, so the preserved case now selects the xpu leaf, ahead of the
NVIDIA arm and behind an explicit pin. The hardware report is untouched: there
really is an NVIDIA GPU in the machine.

install_python_stack: an inconclusive XPU probe was always read as a flavour
mismatch, but on a stalled Intel driver under a SUPPORTED wheel that is two
90-second hangs and two force-reinstalls of the whole trio on every update,
repairing nothing. The disk answers what the probe cannot, so a supported
wheel now yields the driver warning and an unsupported or missing one still
repairs.

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

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

* Lowercase the setup.sh pin leaf like every other index parser

install.sh's _torch_index_url_leaf, setup.ps1's Get-TorchIndexLeaf and
install_python_stack's _torch_index_leaf all lowercase before classifying. This copy did
not, so UNSLOTH_TORCH_INDEX_FAMILY=XPU (or a URL ending in /XPU) left the leaf uppercase,
the equality test against "xpu" failed, and the fast path stayed on. Those same classifiers
call that pin XPU once they are reached, so the wheel was never migrated and the update
silently repaired nothing. The comment above the line already claimed to match the shared
parsers; now it does.

Three cases added to tests/sh/test_setup_xpu_fastpath_escape.sh (FAMILY=XPU, FAMILY=Xpu,
a URL ending /XPU), plus one that lowercasing must not widen the match: a custom leaf like
PRIVATE-XPU stays an unknown family. All three fail against the previous line and pass now.

* Do not promise CPU training when the XPU runtime will not start

The unavailable-runtime arm said training and GPU inference run on CPU until the driver is
fixed. They do not: with neither CUDA nor XPU available, get_device_type() in
unsloth/device_type.py raises NotImplementedError, so importing unsloth fails outright
rather than falling back. llama.cpp is unaffected, which is what chat and GGUF actually run
on, so say that instead.

The drift guard added with it needed two passes to be worth anything. Anchoring the arm on
the flag name alone matched the bitsandbytes block instead, whose own "4-bit QLoRA may be
unavailable" warning made both assertions pass on any wording; and the arm's explanatory
comment quotes the phrase it must not use, so comment lines have to go before the grep.
Restoring the old message now fails both checks.

* Let an explicit non-XPU pin migrate off an XPU wheel

Two halves of the same gap: asking for CUDA/ROCm/CPU on a host already running +xpu did
nothing.

setup.sh: the fast-path escape fired only when the pin itself was xpu, or when a stale
generic triton shadowed the build. With an up-to-date install, a +xpu wheel and the pin
switched to another family, neither arm matched, install_python_stack never ran, and the
authoritative pin was ignored. Added an arm for that case, digit-gated like the shared
classifiers so a custom verbatim leaf (rocm-current, cu-private) stays UNKNOWN and does not
force a pass that repairs nothing.

install_python_stack: _ensure_cpu_torch classifies the installed build and returns early on
"already a CPU build". Its probe tested hip, rocm, cuda and +cu<digits>; an XPU wheel sets
neither torch.version.cuda nor .hip, so it read as CPU and an explicit CPU pin over it did
nothing at all. Keyed on the +xpu local label, since torch.version.xpu is None on some
builds. Additive: +cu128 and +rocm still read gpu, +cpu and untagged still read cpu.

The escape suite's extractor stopped after the second _SKIP_PYTHON_DEPS assignment, so
adding a third arm truncated the block and the new cases failed while the old ones passed.
It now stops at the next outer arm and asserts exactly three arms extract, so a future arm
fails loudly instead of disappearing.

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

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

* Stop a wedged Intel driver from blocking the paths that repair it

Both of these are fallout from making the CPU repair XPU-aware: it now has to classify an
XPU wheel, and every route to that classification went through `import torch`, which loads
the SYCL runtime and blocks on the exact host these paths exist to rescue.

install_python_stack: the classifier probe times out after 90s and the except branch
returned, so an explicit CPU pin over a wedged +xpu venv stayed a no-op. Classify off disk
on timeout via _installed_torch_label_on_disk (find_spec, no interpreter) and fall through
to the repair. Gated on a GPU label so a slow but healthy CPU-only host does not
force-reinstall torch every update.

install.sh: the rollback preservation probe read torch.__version__ through the interpreter
at venv-replacement time, ahead of every bounded probe in setup.sh, so a hang there took the
whole installer with it. It now reads torch/version.py, the same source
_installed_torch_version_for_tag already uses for this reason. The interpreter stays as the
fallback for a layout without one, where torch is absent and the import fails fast.

The install.sh test executes the block against a fake venv whose stub interpreter records
being called, so "read off disk" is proven by the interpreter never running rather than by
reading the source.

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

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

* Match torch index families exactly, and judge the XPU fast path on the wheel

studio/setup.sh classified a pin as a known non-XPU family with prefix globs
(cu[0-9]*, rocm[0-9]*), so cu128-private, cu128rc1, cu128.1, rocm7.2-private,
rocm7. and rocm7.2.1 all read as known while install_python_stack calls every
one of them UNKNOWN and runs no repair: the fast path was cleared and the
dependency pass that followed applied nothing, every update. It now matches
exact families like install.sh _is_pip_rocm_family_leaf and
install_python_stack _is_cuda_family_leaf: cpu, cu<digits>,
rocm<digits>[.<digits>], gfx<digit>... (gfx stays a prefix on all three sides,
since gfx120x-all is a real Radeon index leaf).

studio/setup.ps1 keyed the same escape on torch.xpu.is_available(), which is
also false for a supported +xpu wheel on an old or wedged compute driver. No
dependency pass can repair a driver, and the pass force-reinstalls nothing when
the flavour already matches, so each studio update repeated the bounded probes
and a full resolution just to reach the warning Assert-XpuRuntimeReady already
prints. The escape now asks Test-VenvTorchIsXpuSupported, which reads
torch/version.py off disk and applies the same 2.6 <= v < 2.11 window, matching
what setup.sh does on POSIX and removing the last import torch from a path an
Arc host with a stalled driver is most likely to hit. Its only caller gone,
Test-TorchXpuVersionSupported is removed.

Tests: the escape test now also asks install_python_stack itself about a
28-leaf corpus and asserts the shell predicate agrees leaf for leaf, so the two
cannot drift again (75 checks; 11 fail against the previous globs). The
pre-report test covers the new helper and asserts the fast-path escape names no
readiness probe and launches no interpreter.

* Trim comments across the Intel XPU detection changes

* Normalise setup.ps1 line endings before the wiring regexes

A Windows checkout returns CRLF, so the fast-path escape pattern, which is
anchored on a literal \n, matched nothing on windows-latest: the region came
back empty, "the escape was found" failed, and the two -not checks inside it
reported PASS with nothing to look at. Cross-platform parity caught it on
windows-latest with 3 failures.

$setupText is now normalised to LF once at the read, which covers both literal
newline patterns in the file, and a new check asserts the raw CRLF form does
NOT match the same pattern, so it is the normalisation rather than luck that
makes this work. Verified against a CRLF copy of setup.ps1: the previous test
fails there with exactly those 3 checks and the new one passes.

* Run the Triton swap after every torch migration, not between two of them

_ensure_xpu_triton keys off the installed +xpu label when no explicit XPU pin
is set, and it ran ahead of _ensure_cpu_torch. So an existing +xpu venv updated
with an explicit CPU pin had generic triton removed and XPU triton installed,
and only then did _ensure_cpu_torch replace torch with the CPU build: a CPU
environment whose top-level triton package is the XPU implementation, with the
generic triton its own dependency set declares now gone.

The CUDA and ROCm repairs already ran ahead of the swap, so their pins left the
label correct by the time it read it; CPU was the one migration that did not.
Moving the swap to the end of both repair blocks fixes it for every family at
once and removes the ordering assumption entirely.

The new test asserts the order on the AST at both call sites, so a reflow
cannot fake it; against the previous order it fails on the first assertion.

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

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

* Bound the wedged-driver probe without GNU timeout

macOS ships no GNU timeout (Homebrew coreutils installs it as gtimeout), so on
the macOS parity leg the `timeout 30 python3 ...` line exited 127 the instant it
was called. The test reads only the exit code, and 127 is non-zero with an
elapsed time of 0, so both assertions passed without python ever starting: the
alarm behaviour they exist to prove was never exercised on macOS.

Replaced with the script's own background watchdog, which behaves the same on
every platform, and added a lower bound on the elapsed time. The alarm is 2s, so
a run that returns instantly did not execute the probe, which is precisely how
the missing-timeout case looked.

Verified by shimming `timeout` to exit 127: the previous test still reports 37
passed, and by shimming python3 to return instantly: the previous test still
reports 37 passed while this one fails on the deadline check.

* Trim comments in the Intel XPU detection changes

---------

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Co-authored-by: danielhanchen <danielhanchen@gmail.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-04 03:03:10 -07:00
Daniel Han
ef08ee999e
Linux/macOS: tell an unreadable llama.cpp install apart from someone else's (#7771)
* Linux/macOS: tell an unreadable llama.cpp install apart from someone else's

setup.ps1 learned this in #7735 and #7757; setup.sh never did.

A tree that is ours, carrying our own marker, reads as somebody else's the
moment it stops being searchable, because every probe inside it reports the
marker absent. The ownership guard then said the folder is not an
Unsloth-owned install and told the user to move it aside or pick an empty
UNSLOTH_STUDIO_HOME, when the actual fix is a permission change. Search (+x)
is what those probes need, not read (+r): a directory can be readable and
unsearchable (mode 444) or searchable and unreadable (mode 111), so the new
probe tests search.

The two destructive replaces also ran blind. Under errexit a failing rm -rf
aborts on a raw permission error with no [TAURI:ERROR], so the desktop app
shows a bare exit code and the freshly built llama.cpp is left in the temp
directory with nothing saying where. Both sites now tolerate the failed
remove, check that the directory is actually gone, and report either the
permission problem or where the new build was left, exiting 3 as the Windows
side does for a build that succeeded but could not be installed.

Neither message tells the user to delete a folder Unsloth cannot prove is its
own, matching the rule the Windows guard follows.

* Keep the rm error when a replace fails

The redirect this PR added swallowed the one line that names which subtree
refused to go. rm reports the deepest failing path, llama.cpp/build say, while
the message below it can only name the install root, so a user whose build
directory alone is locked lost the part that told them where to look.

|| true is what keeps errexit from aborting; the redirect was never doing that
job, so stderr stays.

* Tighten the comments on the denied-install-tree guards
2026-08-03 00:58:23 -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
2d61c3aa04
Studio setup: report a missing llama.cpp in Tauri mode instead of aborting the install (#7720)
* Studio setup: report a missing llama.cpp in Tauri mode instead of aborting the install

setup.sh ends by exiting non-zero when the llama.cpp prebuilt did not produce a
usable server and it was called from install.sh, so the installer can report the
GGUF failure after finishing PATH and shortcut setup.

In Tauri mode that non-zero exit is not "report", it is "abort": install.rs
turns it into "Installation failed". So one transient prebuilt download failure,
and a single HTTP 403 rate limit is enough, fails the entire first-launch install
of the desktop app, moments after setup.sh's own footer said Installed. Everything
except GGUF inference is in fact working, and whisper.cpp in this same script
already degrades rather than failing for exactly this case.

Tauri mode now emits a [TAURI:STEP] line naming what is missing and how to get it
back. Every other caller keeps the non-zero exit unchanged.

* Use the progress channel for the notice, and degrade on Windows too

Two problems with the first version, both found in review.

The notice used [TAURI:STEP], which is the wrong channel twice over. install.rs
maps it to install-step, and use-tauri-backend.ts counts those without ever
storing the payload, so the text was thrown away. Worse, install.sh already
emits exactly seven unique STEP markers against the seven-entry INSTALL_STEPS
list, so an eighth pushed the counter past the end and rendered 'Step 8 of 7'.
[TAURI:PROGRESS] becomes install-progress-detail, which the installing screen
renders verbatim.

Windows had the identical defect and was not covered. install.ps1 sets both
SKIP_STUDIO_BASE=1 and UNSLOTH_TAURI_MODE, then turns any non-zero setup.ps1
status into Exit-InstallFailure, which install.rs reports as 'Installation
failed'. setup.ps1 now gates on UNSLOTH_TAURI_MODE the same way Exit-SetupFailure
already does, and still fails for every other caller.

Tests: 8 cases, adding one that the notice never emits [TAURI:STEP] and a
setup.ps1 block check for the Windows parity.
2026-08-01 04:12:25 -07:00
Daniel Han
07fb20f5b0
Route the explicit Vulkan setup failures through the setup failure helpers (#7645)
#7188 added three bare exits to studio/setup.sh and three to studio/setup.ps1
for the explicit-Vulkan paths. Bare exits skip setup_fail / Exit-SetupFailure,
so the [TAURI:ERROR] line that #7529 added never reaches Studio desktop and a
Vulkan install failure shows up there with no actionable context.

tests/sh/test_tauri_retry_failure_context.sh already asserts that each setup
script has exactly one explicit exit and that it lives inside the helper. That
assertion was failing, but the failure was invisible: Backend CI skips the
Shell installer tests step whenever the auto-discovered pytest step fails
first, and it had been failing on an unrelated encoding assertion until #7642.

Exit codes are unchanged: setup_fail 1 and Exit-SetupFailure both exit 1, and
the Tauri line is still emitted only when UNSLOTH_TAURI_MODE is set.
2026-07-30 05:37:40 -07:00
Ayushman
dbfae7d92c
Studio: support Vulkan GPU selection and explicit Vulkan installation (#7188)
* feat(studio): GGUF host-residency memory mode + Vulkan gpu_ids pinning

Narrowed to complement #6414 (merged), which already provides the GGUF GPU
device picker, gpu_memory_mode (auto/manual), gpu_layers, n_cpu_moe, and
tensor_split. This adds only what #6414 lacks:

1. Host-residency `gguf_memory_mode` (auto/pinned/resident) mapping to
   llama.cpp --mlock / --no-mmap. Distinct from #6414's gpu_memory_mode (that
   controls VRAM/layer offload; this controls host-RAM residency). Default auto
   is a no-op: an omitted value launches identical argv/env to main. First-class
   field shadows and scrubs inherited --mmap/--no-mmap/--mlock and
   LLAMA_ARG_MLOCK/NO_MMAP/MMAP so a stale value can't leak. Backend/API-driven
   (mirrored from /status), no new UI control.

2. Vulkan-ordinal gpu_ids hardening. #6414 rejects gpu_ids on a Vulkan build
   with HTTP 400; this replaces that with real pinning: gpu_ids are treated as
   ggml Vulkan ordinals (matched against the probe directly, never remapped
   through CUDA_VISIBLE_DEVICES), pinned via --device Vulkan<i>, with conflicting
   user --device / inherited LLAMA_ARG_DEVICE stripped under a pin, and the
   training coexistence guard budgeting conservatively against the least-free
   card (ordinals can't map to physical free-VRAM).

Backend: gguf_memory_mode field + validator (LoadRequest / ValidateModelRequest,
echoed on responses); _memory_mode_flags / _canonical_memory_mode; strip_memory_mode
+ strip_device in llama_server_args; is_vulkan_build / assert_requested_gpu_ids_resolvable;
memory-mode reload-dedup in _already_in_target_state and the route matcher
(device-stripped under a pin, mirroring the launch); gpu_ids_are_vulkan_ordinals
budgeting in training_vram. Frontend: a read-only activeMemoryMode mirror wired
into the existing reload flow (no dropdown). #6414's gpu_ids picker is untouched.

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

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

* Preserve inherited memory flags and reject GPU pins on CPU-only llama.cpp builds

- strip_shadowing_flags: default strip_memory_mode to False (opt-in, matching
  strip_offload / strip_tensor_split / strip_device). The inherit resolver now
  strips an inherited --mlock/--mmap/--no-mmap only when the request supplies
  gguf_memory_mode, so a same-model Apply that omits the field keeps a user's
  pass-through memory flag instead of silently dropping it.
- /load and /validate: on the CUDA path, reject explicit gpu_ids when the
  llama.cpp build ships no cuda/hip/vulkan ggml lib (CPU-only). Such a build
  ignores CUDA_VISIBLE_DEVICES, so the pin would run on CPU while the API
  reports it active. Mirrors the non-CUDA resolvable check.
- Document that gpu_ids are ggml Vulkan ordinals on a Vulkan build (enumerated
  independently of CUDA_VISIBLE_DEVICES), not CUDA physical indices.
- Add regression tests: CPU-only route reject, inherit preserve/strip of memory
  flags, and the flipped strip_memory_mode default.

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

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

* Detect versioned Vulkan libs and sync memory mode across all load paths

- _is_vulkan_backend now matches versioned Vulkan sonames (libggml-vulkan.so.0)
  via a shared _lib_dir_has_ggml_backend matcher, unified with
  _backend_lacks_gpu_lib so Vulkan detection and the CPU-only-build check agree.
  A distro/split-lib Vulkan install without the dev-only unversioned symlink is
  now detected as Vulkan and gets the --device Vulkan<i> pin instead of being
  misread as CUDA. This also keeps the Chat Settings picker reliably disabled on
  Vulkan builds (main.py gates gguf_gpu_ids_supported on this detection), so the
  UI never emits physical indices into the Vulkan-ordinal route.
- Frontend: fold activeMemoryMode into loadedGpuMemoryFields so every successful
  load path (primary, rollback, compare-load, GGUF/non-GGUF/MTP auto-load) resets
  it from the LoadResponse, not just the primary path. Prevents a stale pinned
  mode from an earlier model being re-sent by an immediate same-model Apply.
- Add test_is_vulkan_backend_matches_versioned_soname.

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

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

* Pin get_device in gpu_ids GGUF route test for GPU-less CI

test_inference_route_validates_gpu_ids_for_gguf patches resolve_requested_gpu_ids
to exercise the CUDA physical-ID resolver, but did not pin the device. On a GPU-less
CI runner get_device() returns non-CUDA, so the route took the non-CUDA/Vulkan
deferral branch whose 'no GPU backend detected' 400 contains the substring 'not
supported', tripping the test's assertNotIn. Pin get_device to CUDA so the test
deterministically covers the path it intends.

* fix(tests): simplify ordering assertions and tighten GPU validation error message

* fix(studio): add GGUF variant check before reusing draft extras; apply aggregate headroom to Vulkan multi-GPU pins

- inference.py /load path: check extra_args_source and gguf_variant before
  reusing a loaded server's extra_args for draft-device validation, so a
  different quant of the same repo no longer inherits stale --spec-draft-device.

- inference.py /validate path: same variant-aware check before inspecting
  stored backend extras for draft-device rejection.

- training_vram.py: Vulkan multi-GPU pin now also enforces the aggregate
  _MULTI_GPU_OVERHEAD (0.85) check instead of returning early on per-GPU
  min_free alone, preventing a chat load from starting with too little
  protected headroom and OOMing an active training run.

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

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

* fix(studio): harden Vulkan probe/budget and diffusion classification for GGUF placement

- Use the versioned-soname matcher in the Vulkan free-memory probe too, matching
  _is_vulkan_backend: a split-library install that ships only libggml-vulkan.so.0
  is now sized correctly instead of returning no devices and rejecting gpu_ids.
- Budget the Vulkan multi-GPU aggregate over the least-free N pinned cards, not all
  visible GPUs: with the ordinal to physical mapping unknown, summing every visible
  card could approve a pin that no N-card placement can actually hold.
- Classify a GGUF from its local header before the name heuristic: a normal
  llama-server GGUF whose path or repo merely contains "diffusion" is no longer
  rejected for gguf_memory_mode, since the loader routes on the decoded header.
- Compute the Vulkan-ordinal flag before diffusion_gpu and gate diffusion_gpu off
  when it fires, so an unclassified GGUF pinned on a Vulkan build is budgeted as
  ordinals instead of the single-device CUDA path sizing the wrong physical card.

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

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

* fix(studio): gate XPU gpu_ids rejection to non-Vulkan; skip GGUF memory-mode reject for non-GGUF

- /load and /validate paths now detect the Vulkan build before the XPU
  rejection so Vulkan-backed Intel GPUs can use gpu_ids via --device
  Vulkan<i> ordinals instead of being blocked by the torch-xpu device check.

- _reject_diffusion_memory_mode now returns early for non-GGUF configs so
  a stale or shared payload with gguf_memory_mode cannot block unrelated
  non-GGUF loads.

* fix(studio): skip llama.cpp GPU-lib check for diffusion; resolve versioned Vulkan libs in probe

- /load and /validate paths now skip _backend_lacks_gpu_lib for confirmed
  DiffusionGemma GGUFs, since the diffusion runner bypasses llama-server and
  handles gpu_ids via --gpu/DG_GPU independently of the llama.cpp build.

- _vulkan_probe.py now resolves versioned sonames (libggml-vulkan.so.0 etc.)
  via a directory scan so split-lib installs that lack the unversioned symlink
  are no longer rejected with no visible devices.

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

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

* test(studio): cover diffusion gpu_ids skip + versioned Vulkan soname; mirror is_vulkan_build on draft-device stubs

- Add is_vulkan_build to three draft-device test stubs so they mirror the real
  backend: the gpu_ids validation now reads it before the draft-device reject.
- test_cpu_only_llama_build_skips_reject_for_diffusion_gguf: a diffusion GGUF is
  served by the visual-server runner, so a CPU-only llama.cpp build does not
  reject its gpu_ids (the flow reaches the id resolver past the skipped reject).
- test_versioned_only_vulkan_soname_is_probed: a split-library install shipping
  only libggml-vulkan.so.0 is still classified Vulkan and probed, not rejected.

* fix(studio): route confirmed diffusion gpu_ids through the CUDA path on a Vulkan build

A confirmed diffusion GGUF is served by the visual-server runner, which takes a
CUDA physical id via --gpu/DG_GPU (it remasks CUDA_VISIBLE_DEVICES), never a
Vulkan ordinal. The gpu_ids preflight previously sent it to the Vulkan-ordinal
branch whenever the installed llama-server was a Vulkan build, so a valid
physical pick could be rejected (no matching Vulkan ordinal) or an ordinal
validated that the runner then applies to the wrong CUDA GPU. Route a confirmed
diffusion GGUF through the CUDA resolver at both /load and /validate even on a
Vulkan build, matching the training guard's existing diffusion_gpu handling.

* Keep llama.cpp fit headroom

* Adapt host memory modes to llama.cpp load mode

* feat(studio): expose GGUF host memory controls

* Clarify GGUF host memory semantics

* Expose Vulkan ordinals in the GGUF GPU picker

* Show Vulkan hardware names in the GPU picker

* Clarify the llama.cpp Vulkan backend selector

* Keep explicit Vulkan choices authoritative

* Preserve requested and effective GGUF GPU pins

* Align GGUF dedupe fixture with requested GPU pins

* Restore Vulkan diffusion pin guard

* Close GPU backend integration gaps

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

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

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

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

* Simplify GGUF placement state and validation

* Trim GGUF placement comments

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

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

* Address GGUF placement review findings

* Address GGUF placement review follow-ups

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

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

* Reject invalid remote diffusion placement before teardown

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

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

* Correct Vulkan GGUF placement boundaries

* Simplify Vulkan metadata handling

* Preserve deferred GPU index namespaces

* Preserve deferred GPU and memory settings

* Preserve host memory in compare loads

* Handle DiffusionGemma memory settings

* Use semantic preset summary font size

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

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

* Enforce host memory environment overrides

* Keep host memory default implicit

* Clarify host memory settings

* Clarify host memory choices

* Clarify Host RAM choices

* Explain Host RAM behavior

* Limit Host RAM wording change

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

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

* Guard GPU selections by backend namespace

* Correct Host RAM placement semantics

* Restore the GGUF fit target

* Remove GGUF Host RAM controls

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

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

* Correct GPU placement comments

* Fix DiffusionGemma GPU selection capabilities

* Make GPU selections own main device placement

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

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

* Trim GGUF placement regression coverage

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

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

* Remove orphaned memory policy and close Vulkan gaps

* Correct draft-device recovery guidance

* Fix Vulkan source and training device handling

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

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

* Align automatic Vulkan device pools

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

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

* Fail closed on explicit Vulkan no-space, refresh the GPU picker on probe recovery

setup.sh and setup.ps1 both dispatch the prebuilt installer's exit status
before checking whether Vulkan was explicitly requested, so status 4 (no disk
space) took the no-space branch and never reached the strict-backend check in
the else. With an older CUDA, ROCm or CPU llama-server already installed,
_has_local_llama_server kept _LLAMA_CPP_DEGRADED false, so setup exited 0 and
Studio carried on using the backend the user asked to replace. Driving the
real dispatch block with a stubbed installer shows the same explicit request
exiting 1 on a generic failure and 0 on a no-space failure. Apply the same
check to the no-space path in both scripts.

useGpuDevices fetched /api/system once from an effect with no dependencies and
subscribed to nothing. When the Vulkan probe is unavailable, main.py answers
gguf_devices as an empty list rather than omitting it, so the "?? devices"
fallback does not apply and the picker starts hidden. useInferenceGpuInfo then
retries every 3 seconds and refreshes the shared cache, but nothing told the
mounted device hook, so the picker stayed hidden until it was remounted.
Notify subscribers when a refetch replaces the cache and have useGpuDevices
subscribe, comparing by value since each refresh builds a fresh array and an
unconditional set would re-render on every retry tick.

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

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

* Fix Vulkan placement state and installation

* Use published Vulkan bundles

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

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

* Consolidate llama.cpp backend selector

* Specify UTF-8 for file operations

* fix(studio): harden GGUF GPU placement

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

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

* fix(studio): align backend selection contracts

* fix(studio): align MTP draft device placement

* fix(studio): address Codex P2s on legacy Vulkan env var and cold-cache GPU pins

- llama_backend_from_env() (and setup.sh/setup.ps1's shell-level mirror) falls
  back to the legacy UNSLOTH_LLAMA_BACKEND when UNSLOTH_LLAMA_CPP_BACKEND is
  unset, so an existing UNSLOTH_LLAMA_BACKEND=vulkan environment keeps forcing
  Vulkan across the setup.sh/setup.ps1 consolidation instead of silently
  reinstalling CUDA/ROCm/CPU.
- loadedGpuMemoryFields() no longer drops a just-applied gpu_ids pin to null
  when cachedPinnableGpuIndexKind() returns undefined (cache cold / Vulkan
  probe not ready yet). That state is deferred, not rejected: the pin is kept
  so a reload/rollback during that window still sends gpu_ids instead of
  letting llama.cpp fall back to every device.

The third P2 (force-compile skipping the Vulkan-source-build rejection) was
already fixed by an earlier commit on this branch -- verified
_NEED_LLAMA_SOURCE_BUILD is set before the guard runs in both setup.sh and
setup.ps1, and added a regression test locking in the ordering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

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

* Studio: fix Vulkan backend env fallback and GGUF preflight token

Three narrow correctness fixes on the Vulkan GPU selection path.

setup.sh / setup.ps1 consulted the legacy UNSLOTH_LLAMA_BACKEND only when
UNSLOTH_LLAMA_CPP_BACKEND was empty, while install_llama_prebuilt.py's
llama_backend_from_env() consults it whenever the new var is not an explicit
backend name. With UNSLOTH_LLAMA_CPP_BACKEND=auto beside a legacy
UNSLOTH_LLAMA_BACKEND=vulkan the shell dropped the request, leaving
_explicit_vulkan_backend and _explicit_vulkan_source_build false while the
installer it launches still planned Vulkan, so a Vulkan install that could not
be satisfied silently degraded to a CUDA/ROCm/CPU build instead of failing
closed. Mirror the Python rule in both scripts; an unrecognized value with no
legacy backend is still preserved so the warning can name it.

The new GGUF diffusion preflight in the chat load path sent the raw stored HF
token to /validate before validateModel/loadModel could run
prepareHfTokenForUse. The Hub rejects an invalid Authorization header with 401
even on a public repo, so a stale saved token aborted the whole load instead of
offering the existing continue-anonymously/replace-token recovery. Prepare the
token the same way the compare path already does.

The training guard handed can_load_chat_during_training an empty Vulkan
free-VRAM map for an unclassified GGUF on a Vulkan build. That reads as no free
VRAM anywhere, so every uncached remote GGUF was refused with 409 while
training ran. Only an explicit pin is unbudgetable (the ordinal belongs to one
namespace and neither can stand in for the other); automatic placement has no
ordinal to mis-map, so it keeps the torch view.

Each fix has a regression test that fails without it.

* Let the diffusion runner take physical ROCm indices

device_backend is a display label: _backend_label swaps CUDA for "rocm" when
IS_ROCM is set, but ROCm reuses torch.cuda.* and the DiffusionGemma runner
selects by the same physical index either way. Gating on "cuda" alone marked
every physical ROCm device unpinnable, so the picker vanished on multi-GPU
ROCm hosts and status hydration could discard a valid selection.

* fix(studio): keep unavailable Vulkan inventory out of CUDA fallback, prepare autoload HF token, restore legacy hip/rocm opt-out

Three P2s from the latest Codex review round on PR #7188:

- toGpuDevices(): a confirmed-Vulkan inference backend with a still-cold or
  transiently-empty device probe no longer falls through to the torch/CUDA
  inventory. That fallthrough was marking physical CUDA/ROCm devices
  diffusionPinnable, exposing a GPU picker for DiffusionGemma that sent IDs
  the backend rejects outright whenever is_vulkan_build is true. Now returns
  no devices until the Vulkan probe actually succeeds.

- loadAutoLoadCandidate(): the startup auto-load path's diffusion-classifying
  fetchGgufStagedMetadata probe (fired when a cached GGUF has saved gpu_ids)
  sent the raw stored HF token, bypassing prepareHfTokenForUse. Unlike
  validateModel/loadModel (which prepare internally), fetchGgufStagedMetadata
  does not, so an expired token could 401 even a public repo and abort the
  candidate before the anonymous/replace-token recovery flow got a chance to
  run. Now prepares the token first, mirroring performLoad's identical guard.

- _normalized_llama_backend(): dropped hip/rocm entirely during the backend-
  selector consolidation, despite force_vulkan_requested()'s own comment
  ("so =hip is a real opt-out a stale UNSLOTH_FORCE_VULKAN cannot overrule")
  and _route_to_vulkan_prebuilt()'s ("an explicit hip/cpu is the opt-out")
  describing behavior it no longer delivered. An existing
  UNSLOTH_LLAMA_BACKEND=hip/rocm environment on an unsupported HIP arch read
  as "no backend named" and silently routed to Vulkan (auto-fallback, or a
  stale UNSLOTH_FORCE_VULKAN=1). Restored hip/rocm recognition (canonicalized
  to "hip"), matching the pre-consolidation mapping.

Regression tests added for all three.

* Cover the auto-load HF token preflight behaviourally

The contract test added alongside the fix asserts on the source text. This
runs the real classification block out of chat-adapter.ts under node with a
stubbed Hub that 401s any non-null Authorization value, so it fails on the
token value that actually reaches /api/inference/validate rather than on a
symbol being present.

Reverting only the source (leaving the import in place) reddens it with the
simulated 401, not an ImportError.

* Converge Vulkan backend selection and review fixes

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

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

* Stop the structlog stub shadowing the real package for PR #7188

The bare setdefault parked an empty placeholder before anything imported
the real structlog, so every later module calling structlog.get_logger at
import time raised AttributeError. It only bit when this file was collected
first, which is why the 8 hardware-dispatch cases passed alone and failed
under pytest tests/studio. Only stub when the package is genuinely absent.

* Prepare model settings GGUF token preflight

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

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

* Keep the established llama.cpp backend selector

* Preserve GPU namespace while Vulkan inventory recovers

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

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

* Fix final PR 7188 review findings

* Normalize llama.cpp device flag aliases

* Converge PR 7188 review findings

* Honor backend opt-outs for Intel Vulkan routing

* Retry cold system discovery for GPU selection

* Probe structlog with find_spec instead of a bare import

The availability check imported the module purely for its side effect, so
the import-hoist verifier flagged it as an added-but-unused import and
failed Source lint. find_spec answers the same question without binding a
name.

* Brace the PowerShell variable in the setup.ps1 backend-choice probe

* Leave an existing structlog entry alone in the availability probe

find_spec raises ValueError on a module already in sys.modules whose
__spec__ is None, which is what a bare types.ModuleType stub is. Check
sys.modules first so anything already present, real or stubbed, is left
untouched and only a genuinely absent package gets stubbed.

* Consolidate duplicated Vulkan selector tests without changing behaviour

Three of the tests added for the llama.cpp backend selector asserted on the
same two helpers with the same inputs, and two more differed only in whether
the backend arrived from the environment or from --llama-backend. Fold them
into the parametrized cases that already covered those helpers:

- test_llama_cpp_backend_env_requests_vulkan,
  test_llama_cpp_backend_auto_does_not_trigger_vulkan and
  test_hip_backend_env_opts_out_of_vulkan become rows on
  test_force_vulkan_requested_accepts_public_selector_and_legacy_alias, which
  now also asserts llama_backend_from_env() alongside force_vulkan_requested().
  The unset case and the whitespace-padded HIP/ROCM values keep their coverage
  as new rows.
- test_explicit_non_vulkan_backend_suppresses_intel_auto_route and
  test_non_vulkan_backend_argument_suppresses_intel_auto_route become one test
  parametrized over the env and argument paths.
- test_unclassified_gguf_without_pin_keeps_the_torch_budget and
  test_unclassified_gguf_with_pin_refuses_to_budget shared the same four
  patches and differed only in the pin, so they become subtests of one case.

No source changes and no assertion is dropped. Each behaviour was re-checked by
reverting the source hunk it guards and confirming the surviving test fails on
a value.

* Keep the standing GPU mode on diffusion loads and the ARM64 Vulkan fallback for PR #7188

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

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

* Make the Intel auto-route guard test able to fail and stop the ps1 harness doubling --force-cpu

test_non_vulkan_backend_suppresses_intel_auto_route ran on Linux x86_64, where
dropping the explicit_backend guard changes only a log line, so the test passed
either way. Move it to Linux ARM64 + Intel GPU, the host where the guard decides
the routed repo, and assert that value.

_run_ps1 composed the --force-cpu snippet twice: it is already inside the
normalized block the helper extracts. The substring assertion hid the duplicate.
Compose it once and compare the whole argv. setup.ps1 appends the flag once and
is unchanged.

* Dot-source Get-HostMachineArch in the setup.ps1 Pester suite

#7549 taught Test-VCRedistInstalled to consult the host architecture before
trusting the System32 DLL. The Pester suite dot-sources a fixed list of
functions out of setup.ps1 one at a time, and that list did not gain the
helper, so the two clean-box cases (registry miss, and an old sub-14.20
redist) throw "Get-HostMachineArch is not recognized" instead of returning
false. The registry hit returns early, which is why the other cases pass.

#7597 made exactly this fix to the sibling list in the VC++ round-trip job
but left the Pester suite alone. Verified with pwsh 7.6 + Pester: 3 failed
before, 31 passed 0 failed after.

* Drop the branch's Pester Get-HostMachineArch fix now that #7606 landed

This branch carried a local fix for the same Pester dot-source gap that
#7606 has since merged to main (f21b280cc). Keeping both leaves the PR
diff with an unrelated, now-duplicated change, so drop the branch copy
and take main's.

---------

Co-authored-by: danielhanchen <unslothshared@gmail.com>
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: alkinun <alkinunl@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-07-29 06:24:58 -07:00
Daniel Han
132438282c
Installer: accept wget in studio/setup.sh, as install.sh already does (#7600)
* Accept wget in studio/setup.sh, as install.sh already does

install.sh takes either transport everywhere: download() and _http_get both try
curl then wget, and the transport gate only fires when both are missing. A
wget-only box therefore installs fine, reaches studio/setup.sh, and finds curl
as the only way to fetch anything there.

Two sites: the uv bootstrap, where the fallback is silent (USE_UV stays false and
fast_install degrades to python -m pip), and the PyPI version check. Both now go
through a helper with install.sh's preference order.

Verified in a PATH containing wget but no curl: both helpers return 0 and the uv
installer is fetched, while the bare `curl -LsSf` this replaces exits 127.

* Cap the wget version check at one attempt

wget's --timeout is per operation and it retries 20 times by default, so a
server that accepts the request and then stalls stretched the bounded PyPI
version check to 245s against a local stalling server; --tries=1 brings it
back to the 5s curl's --max-time gives.

Adds tests/sh/test_setup_http_get.sh covering both helpers: curl preferred,
wget accepted, neither is a non-zero return, and the wget flags.

* Bound the wget version check by wall clock, not per operation

wget's --timeout is per network operation, so a response that dribbles a byte
inside every interval never ends: against a local drip server the check was
still running after 45s, where curl's --max-time 5 stops at 5s. Wrapping wget
in coreutils timeout gives the same ceiling (5s, rc 124 in the same repro).

timeout is not in a base macOS install, so its absence falls back to the
per-operation bound instead of dropping the check; that path only matters
without curl, which macOS ships.

* Tighten the setup.sh fetch helper comments
2026-07-29 03:05:42 -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
oobabooga
01c856c6c5
Surface actionable installer failures in Studio desktop (#7529)
* Studio: surface actionable installer failures

* Correct installer failure attribution

* Preserve desktop installer failure context

* Use explicit setup failure attribution

* Preserve package manager failure details
2026-07-28 10:19:44 +02:00
Daniel Han
dc24bba43e
install.sh, setup.sh: apply the no-tty consent fix to the remaining sites (#7470)
Follow-up to #7435, which fixed _smart_apt_install. Three sites were left.

studio/setup.sh: the WSL GGUF build-deps block is the pre-#7435 install.sh
pattern verbatim. It probes with 'test -r /dev/tty', assumes REPLY=y when that
fails, and then runs the elevated apt-get with stdin open. Its own guard
comment says a password is needed on WSL, so this is exactly the scenario from
issue #7307, and install.sh runs setup.sh in the same install. Give it the same
treatment: a real open probe, -n -k with stdin closed on the headless path, and
the manual command plus the existing _SKIP_GGUF_BUILD degradation on failure.
The helper is defined locally because setup.sh runs as its own process.

install.sh autostart prompt: still used 'test -r /dev/tty' and printed the
question before checking, leaving a dangling prompt in container logs. Reuse
_can_read_tty and move the printf inside the branch.

install.sh interactive escalation: a sudoers denial, a wrong password or an apt
error aborted on the bare message while the headless branch printed what to run
by hand. Make both symmetric.

Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-07-26 05:22:28 -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
Souravrajvi0
09b6bf6c39
fix(studio): opt-in source-build GPU smoke validation (#7322)
* fix(studio): opt-in source-build GPU smoke validation (#5854)

Gap 1 (empty CUDA arch -> CPU) already landed in #6481. Wire gap 2: after a
GPU source build, optionally run the same staged llama-server smoke test as
the prebuilt path, then CPU-fallback on failure. Gated by
UNSLOTH_LLAMA_STAGED_VALIDATION (default off) to avoid Blackwell JIT stalls.

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

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

* fix(install): normalize staged validation env in setup.sh (#7322)

Strip and lowercase UNSLOTH_LLAMA_STAGED_VALIDATION before the shell
gate so values like True and surrounding whitespace match the Python
staged_validation_enabled() helper.

* Rebuild visual server after staged-validation CPU fallback (#5854)

Mirror the primary source-build path by best-effort building
llama-diffusion-gemma-visual-server after smoke-failure CPU fallback.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-23 19:13:54 -07:00
Michael Han
d5cf96d628
Studio: add local speech-to-text dictation engine (#7095)
* Studio: add Voice settings tab (dictation, dictionary, read aloud)

New Voice tab in Settings, placed just before About:

- Dictation: microphone picker, browser STT engine, recognition language,
  and an inline mic test with a live transcript
- Dictation dictionary: entries rewrite matching speech to their exact
  spelling and casing, applied in both dictation paths
- Recent dictations: last 20 final transcripts with copy and clear, so
  text can be recovered if it lands in the wrong place
- Read aloud: optional button on assistant responses with two engines,
  curated system voices (novelty and legacy voices filtered, quality
  ranked, capped at 20) or the TTS audio model loaded in Unsloth via
  /audio/generate (e.g. Orpheus), plus speed, pitch, volume and preview

Settings persist in localStorage (unsloth_voice_settings) and are read
at call time so changes apply without reloading the runtime. Adds en
keys plus the tab label for ja, zh-CN and pt-BR.

* Studio: drop the single option STT engine select, rename TTS option

The STT engine dropdown only had one entry, so it added noise without
giving a real choice. The engine row can come back once local STT
models land. Also renames the TTS engine option Unsloth TTS model to
Load TTS model to make the action clearer.

* Studio: harden Voice settings against edge cases found in simulation

Simulated the feature across Chromium, Firefox and WebKit plus node
level unit runs and backend contract checks. Fixes from the findings:

- Dictionary rewrite used a replacement string, so entries containing
  dollar patterns corrupted transcripts (A$$AP became A$AP, $& injected
  the match). Switched to the callback form of String.replace
- Persisted voice settings now validate types on hydration: non string
  micDeviceId, dictationLanguage and ttsVoiceURI, and non boolean
  ttsEnabled fall back to defaults instead of flowing into the UI
- Dictionary entries are trimmed, capped at 120 chars and re-sanitized
  on hydration
- The Test dictation panel now falls back to the default microphone
  when the saved device is unplugged, matching the composer adapter

Test coverage: 46 unit assertions (dictionary regex edge cases across
unicode, word boundaries and injection, voice curation for simulated
macOS, Windows and Linux voice inventories, corrupt storage merge),
13 backend contract checks against /audio/generate on an isolated
instance, and 60 browser assertions across the three engines covering
rendering, degradation without SpeechRecognition, curation in a real
DOM, dictionary persistence with unicode and dollar entries, the
no-model preview error path and corrupt localStorage recovery.

* Studio: address Voice settings review feedback

Verified each review comment before acting. Confirmed and fixed:

- Editing a dictionary entry was broken in two ways: the store trimmed
  on every keystroke so spaces could not be typed, and clearing the
  field deleted the entry and unmounted the input mid edit. Updates now
  keep the raw value and a blur commit trims or removes the entry
- The unplugged mic fallback checked instanceof DOMException, but a
  cross browser probe showed Firefox and WebKit throw
  OverconstrainedError objects that are not DOMExceptions, so the
  fallback never fired there. Matching on the error name now
- When the browser ended a dictation test on its own (silence timeout),
  the mic stream stayed open. All recognition end paths now stop the
  tracks and save the transcript through a single finalize path
- The studio TTS audio element now releases its WAV data URL as soon as
  playback ends, fails or is cancelled
- Allow microphone now reports insecure contexts (no mediaDevices)
  accurately instead of claiming access was blocked
- Voice tab copy moved into i18n keys per src/i18n/AGENTS.md, so locale
  overlays can translate it; en is the baseline and parity passes
- unsloth_voice_settings added to the Reset all local preferences key
  list so voice preferences obey the reset
- Non default microphones note that the system default is used when the
  browser speech engine cannot bind a specific device, since browsers
  without the start(track) overload ignore the argument silently

Re-ran the full simulation set after the changes: 46 unit assertions,
13 backend contract checks and 60 browser assertions across Chromium,
Firefox and WebKit all pass, plus a dedicated browser probe for the
dictionary editing behavior.

* Studio: use the chat mic icon in Voice settings for consistency

The Voice tab and its buttons used the hugeicons Mic02 glyph while the
chat composer uses a custom filled mic. Extract that composer icon into
a shared lib/mic-icon component, drop the duplicate inline copies in
thread.tsx and shared-composer.tsx, and use it for the Voice tab icon
and the tab's mic buttons so the microphone looks the same everywhere.

* Studio: address second round of Voice settings review feedback

Verified each new comment against the current code first. One item was
already fixed in the previous round (recording transcripts when the
browser ends a dictation test on its own). Confirmed and fixed:

- The microphone row showed a picker with generic names when browsers
  enumerate unlabeled devices before permission, leaving no way to
  grant access from the row. It now branches on whether labels are
  visible and shows Allow microphone otherwise
- Compare chat dictation ignored the selected microphone. It now opens
  the chosen device with the same fallback rules as the main adapter,
  passes the track to recognition where supported and releases the
  stream when recognition ends
- Closing the Voice tab cancelled the shared speechSynthesis even when
  read aloud was playing a chat message. Cleanup now only cancels when
  the tab owns an active preview
- Double clicking Start test could race two recognizers and leak the
  first stream. A starting flag set before the getUserMedia await makes
  start reentrancy safe
- Turning off the read aloud setting mid playback removed the only stop
  control. The stop button now renders whenever a message is speaking
- When an engine lacks the start(track) overload, both dictation paths
  now release the selected device stream before retrying with the
  default microphone instead of holding it open
- Read aloud support no longer requires Web Speech synthesis: the
  Unsloth TTS engine only needs audio playback, so it stays available
  in WebViews without speechSynthesis, with a clear error if the system
  engine is chosen there

Not addressed here: cancelling in flight backend TTS generation on
stop. The route runs generation in a worker thread without a
cancellation path, which is shared pre existing behavior with audio
chat generation and belongs in a backend change.

All suites re-run green: 46 unit, 13 backend contract and 60 browser
matrix assertions across Chromium, Firefox and WebKit, plus probes for
the unlabeled device branch and the double click race.

* Studio: drop empty and duplicate voiceURIs so the Voice tab never renders a crashing Select item

* Studio: guard dictation mic lifecycle in Voice test and Compare composer

Release a microphone opened after the component unmounts, and stop Compare
dictation on a permission or security failure instead of silently recording
from the default device, matching the main chat adapter.

* Studio: fix dictation and read-aloud lifecycle edge cases in Voice settings

- Join final dictation chunks with a space so recorded transcripts do not merge words
- Ignore a stale recognizer onend so a quick stop then restart is not torn down
- Use previewingRef so a double click on TTS preview does not orphan the first request
- Keep the read-aloud stop control visible when a new run starts while a message is spoken
- Stop the dictionary remove button from deleting an adjacent entry on a blur then click race

* Studio: trim redundant Voice settings comments

* Studio: fix Voice preview and Compare dictation edge cases

- Only cancel the shared speechSynthesis for a system-voice preview, so stopping
  a Studio preview no longer stops an unrelated chat read-aloud
- Release the Studio preview audio and its WAV data URL on normal completion
- Iterate every finalized result in Compare dictation so batched phrases are kept
- Cap persisted recent dictations to the last 20 on hydration

* Studio: use clipboard fallback for recents and release failed preview audio

- Copy recent dictations via the copyToClipboard helper so the execCommand
  fallback works in Safari and insecure http LAN contexts
- Release the Studio preview audio when play() rejects, not just on ended/error

* Studio: add local speech-to-text dictation engine

Add an offline dictation engine that transcribes with a local faster-whisper
model, alongside the existing browser (Web Speech) engine. The browser engine
streams audio to Apple or Google speech services and needs internet; the new
engine runs on the server, works offline, and drives any chat model without
evicting it (it loads in the backend process, separate from the model
subprocess). It also gives Firefox dictation, which has no Web Speech support.

Backend: a lazily-loaded, kept-warm faster-whisper sidecar and three routes
under /api/inference/audio (stt/status, stt/load, transcribe). faster-whisper
is torch-free, so this does not disturb the existing model stack.

Frontend: a Dictation engine setting (browser or local model), a curated model
picker with sizes, and MediaRecorder capture posted to the transcribe route.
The model warms automatically when the engine is selected, with live status.

* Studio: stream local STT transcription as you speak

Local dictation showed nothing until you stopped, because the whole clip was
transcribed once on stop. Now the growing recording is re-transcribed on a
fast pass every second and emitted as live interim text, with an accurate
final pass on stop. Partial recordings decode fine, and the model refines
earlier words as more audio arrives.

Adds an interim flag to the transcribe route (beam 1, no VAD) for the fast
preview pass; the final stop uses the accurate path.

* Studio: make local dictation stop instant and reliable

Stopping local dictation waited for a final network transcription before the
session ended, so the stop button did not flip and a second click ended the
session early and dropped the text. Now stop commits the live transcript
immediately, releases the mic at once, and ignores a second stop while
finalizing. Previews run more often so the committed text is current.

* Studio: record local dictation in short clips for reliable streaming

Re-transcribing a growing buffer every second got slower as it grew, flooded
the backend, showed stale words, and could leave the stop button stuck waiting
on a backlog. Record short independent clips instead and transcribe each once,
appending the text as you speak. Work per clip is bounded, so stopping is
prompt (with a hard timeout as a safety net) and long dictations stay smooth.

* Studio: dictate then transcribe once on stop, ChatGPT style

Local STT dictation streamed by re-transcribing the growing clip, which
was quadratic and saturated the backend (multi-second lag), and stop only
halted the recorder without releasing the mic, so it kept recording. Record
the microphone continuously, release it the instant the user stops, and
transcribe the whole clip once. Stopping is immediate and the transcript
lands in about a second. Also add the tiny model for the fastest option.

* Studio: surface dictation and read-aloud failures instead of failing silently

- Compare dictation reports microphone and speech-recognition errors via toast,
  reusing the main chat adapter's describeMediaError and describeSpeechError
- Read-aloud toasts genuine model or synthesis failures while ignoring cancellations

* Studio: ChatGPT-style recording bar for dictation

Clicking the mic now drops the composer into a dedicated recording bar
with a live waveform, a discard (X) and a confirm (tick), instead of a
plain stop button. The tick stops recording and transcribes the clip;
the X throws the recording away and keeps whatever text was already in
the composer. The model adapter taps the mic with an analyser to drive
the waveform, and the router tracks the live session so the X can cancel
it without transcribing.

* Studio: transcribe dictation while speaking, ChatGPT layout

Match ChatGPT's recording layout: the bar now renders in place of the
input with the left plus button kept, the waveform in the middle, and
the discard and confirm buttons together on the right.

Cut the post-confirm delay by transcribing in the background as the user
talks. The audio is split at natural pauses (voice-activity detection off
the same analyser that drives the waveform) and each clip is transcribed
as it is cut, so confirming only has to finish the short final tail. The
model is also warmed when recording starts so the first run never pays a
cold load.

* Studio: ChatGPT waveform, hide tools while dictating, faster STT

Make the recording UI read like ChatGPT: the waveform is now a dense row
of round dots that rise into thin centered bars, and while dictating only
the plus button shows, with the mode badge and tool toggles hidden so the
bar is just the waveform and controls.

Speed up transcription: decode greedily (beam_size=1), which is several
times faster on CPU with negligible accuracy loss on short dictation
clips, and cap background segments at 6s so the final tail after confirm
stays short.

* Studio: finish ChatGPT voice bar and low-latency STT

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

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

* Studio: full-width waveform with a timer that freezes on stop

Use the full-width waveform for the recording bar: brighter, bigger bars
that advance on a fixed cadence (keeping peaks between advances) so they
glide instead of racing by, inset from the composer edges. Keep a visible
timer and the green confirm button, matching the ChatGPT reference, and
freeze the timer and waveform the moment the user confirms.

* Studio: fix multilingual local dictation

* Studio: speed up dictation and release local STT

* Studio: harden dictation finalization and STT decoding

* Studio: restore Firefox dictation fallback

* Studio: add dictation history manager

* Studio: manage speech model downloads

* Studio: remove em dash from voice model label

* Studio: move dictation history into Voice

* Studio: source local STT from Unsloth Whisper models

Point the dictation STT sidecar and its Model Hub download entries at
Unsloth's Hugging Face Whisper repos (small, large-v3-turbo, large-v3)
and run them through Transformers, so Studio only ever downloads
Unsloth-uploaded weights. Drop faster-whisper and the Systran/mobiuslabs
repos; keep the Model Hub as the only download path via local_files_only,
and keep PyAV for audio decoding.

Device selection uses float16 on CUDA and float32 on MPS and CPU, since
Whisper's decoder is unstable in float16 on MPS and repeats tokens.

Shorten the model picker labels to name plus download size and update the
STT tests for the new backend.

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

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

* Studio: smooth dictation waveform and keep pill height

* Studio: align STT model dropdown width and tidy voice copy

* Studio: guide to local engine when browser dictation is offline

* Studio: clarify voice section and STT model copy

* Studio: keep STT warm with training-aware eviction

* Harden STT lifecycle and browser compatibility

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

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

* Fix model discovery test lint

* Harden cross-browser microphone errors

* Harden cross-browser microphone errors

* Surface voice test recognition errors and fall back to Studio TTS

- Voice test now toasts non-abort speech-recognition failures instead of
  ending silently, matching the main and Compare dictation paths.
- Read-aloud routes to the backend model when the runtime lacks Web Speech
  synthesis (audio-only WebView), so it no longer errors immediately.

* Fix reviewed STT lifecycle races

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

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

* Fix read-aloud fallback controls

* Guard read-aloud stop when deleting a non-speaking message

aui.message().stopSpeaking() throws unless this message is the one being
read aloud, so calling it unconditionally rejected the delete handler before
the message was removed. Only stop speech when this message is speaking.

* Cap recent dictation transcript length before persisting

Recent dictations only limited entry count, so a long transcript stored the
full text in the persisted voice settings and a few could exceed the
localStorage quota, throwing synchronously from the uncaught dictation cleanup
path. Truncate each entry on save and on hydration, matching the dictionary cap.

* Studio: keep dictation mic clickable and guide to local model

Register the dictation adapter unconditionally so the mic stays enabled
for any engine and starts working right after switching to the local
model on an already-open thread.

When the browser engine cannot run (Firefox, Brave, non-secure origins),
clicking the mic shows a toast that points to the local speech-to-text
model instead of leaving a disabled button. The toast stacks its action
below the text with a fully rounded button.

* Studio: add bottom padding below the dictation guidance toast button

* Studio: increase bottom padding under the dictation toast button

* Studio: add bottom padding inside the dictation toast button

* Studio: add five Whisper defaults and custom model search

Add private UnslothAI Tiny and Base mirrors to the curated local STT choices while keeping Small as the default. Let users search or paste a Transformers-compatible Whisper repository and validate it end to end.

Keep short dictations in one clip to avoid repeated padded encoder work, then split longer recordings near Whisper's 30-second boundary.

Update hidden model filters and tests, including the CPU-only CI runtime stub for PyAV.

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

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

* Studio: use public Unsloth Whisper repositories

Point the Tiny and Base dictation defaults to the public unsloth repositories and remove the private mirror references from model filtering and tests.

* Studio: update Whisper download sizes

Reflect the cleaned public Tiny and Base repositories in the curated model labels.

* Studio: right-align STT model size, fix dropdown wheel scroll, refresh sizes

- Show the download size on the right of each model row so long names
  like Whisper Large v3 Turbo no longer hide it
- Update curated Whisper sizes to the safetensors weights actually
  downloaded: Tiny 151 MB, Base 290 MB, Small 967 MB
- Drive the model list scroll from a wheel handler so the mouse wheel
  scrolls it inside the Settings dialog, not just the scrollbar
- Add a search icon and shorten the placeholder to Search model

* Studio: do not search when a dictation model is picked, shrink repo label

- Treat the filled-in model text as a selection, not a query, so choosing
  a model no longer kicks off a Hugging Face search
- Make the repository line under each model name smaller

* Studio: tighten dictation model and local engine descriptions

* Studio: keep model display on pick instead of the query, shrink row text

- Guard the combobox input so selecting a model shows its name and does
  not echo the typed query back or start a search
- Map the item label to the friendly display so picks fill the field
- Reduce the model name and size text in each row

* Studio: show only the model name in the dictation field, shrink size label

- Drop the download size from the search field; the name alone is shown
  once a model is selected, with sizes kept in the dropdown list
- Reduce the size label text in each row

* Studio: clarify the dictation model description

* Studio: drop Hugging Face from the dictation model description

* Studio: move the dictation dictionary to its own Manage subpage

- Replace the inline entry list with a Manage row, matching Dictation
  history, so a long dictionary no longer crowds Voice settings
- Add a DictationDictionaryView subpage that holds the entry editor

* Studio: match STT field font, use best voice for System default

- Bump the dictation model field text to text-sm so it matches the
  engine dropdown next to it
- Resolve the System default read-aloud voice to the top curated voice
  instead of the browser default, which is a robotic legacy voice on macOS

* Studio: rerank read-aloud voices and drop duplicate voice entries

- Rank by vendor quality, then the user's locale, then a preferred list of
  natural voices, so the best voice leads instead of the first alphabetically
- Collapse voices that macOS reports twice under one name and language

* Studio: fold dictionary and recents into the dictation section

- Drop the separate Dictation dictionary and Recent dictations headings;
  their Manage rows now sit under Dictation, split by the row divider
- Shorten the custom spellings description

* Studio: add search and sort to dictation history

- Filter saved dictations by text with a search field
- Sort by newest, oldest, or A to Z; show a no-matches message
- Keep Clear all available regardless of the current filter

* Studio: settle cancelled STT loads before training and fix dictation review items

Wait for a cancelled STT load to exit and release its memory before
reporting it freed for training, so the loader cannot still be inside
from_pretrained()/.to(device) holding VRAM when the training subprocess
starts. A load that finishes before observing the cancel now gets
unloaded so the memory is actually reclaimed.

Clear the accelerator cache before the CPU fallback in load() so a failed
CUDA/MPS load does not strand reserved VRAM once the sidecar is marked
CPU-resident.

Send the saved Hugging Face token when polling STT download progress so a
gated or private repo resolves and shows the correct Load/Downloaded
state instead of reporting missing.

Mark the composer Dictate button as type="button" so clicking it does not
also submit the draft when the composer already has text or attachments.

* Studio: pin dictation settings per session and close STT startup races

Capture the STT model and language when a dictation session starts and
pass them to every queued segment and the warm-up load, so changing the
model or language mid-recording no longer transcribes the same clip with
the wrong model or a model that is not downloaded.

Check the local runtime at the top of transcribe(), before the model
cache lookup and the bounded audio decode, so a server missing PyTorch or
Transformers returns 501 up front instead of decoding a long clip first.

Treat the training startup window as active for STT device selection.
start_training frees VRAM in before_spawn but only assigns _proc later, so
a concurrent STT load could take the GPU that was just cleared. A startup
flag now reports training active from the free until the process is live,
forcing those loads to CPU; a finally clears it on every exit.

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

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

* Studio: stub the STT runtime check in transcribe orchestration tests

transcribe() now verifies the local runtime up front, so the unit tests
that exercise transcription orchestration must treat the runtime as
present to keep passing where PyTorch, Transformers, and PyAV are not
installed. Stub ensure_stt_available in the shared fixture and restore
the real check in the availability and load-rejection tests.

* Harden custom Whisper dictation models

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

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

* Add whisper.cpp dictation engine with per-engine downloads and history rework

Engines
- New GGML STT sidecar that runs a managed whisper-server subprocess with
  idle unload, plus a pinned static build script (scripts/build_whisper_cpp.sh)
- Dictation engine picker now offers Browser, Local transcription
  (whisper.cpp), and Local transcription (Transformers)
- Both local engines serve the same five curated Whisper models and download
  them directly with byte-level progress reported by /audio/stt/status
- Models auto load on selection and when their download finishes
- Unload and training admission account for both engines

Benchmarks (Apple Silicon, greedy, warm, same checkpoints)
- whisper.cpp transcribes 2.4x to 5x faster than Transformers and loads in
  about 0.45s vs 0.86s for Whisper Small
- whisper.cpp GGUF path is unchanged by the Transformers addition
  (load 0.445s -> 0.444s, short clip 0.391s -> 0.347s, long 1.197s -> 1.129s)

Voice settings UI
- Plain curated model select replaces the searchable combobox
- Single download progress bar with transfer rate for both engines
- Dictation history now stores every dictation with Show more pagination,
  a top Clear history action, and links back to the chat it was spoken into
- Archived chats dialog gets the same pagination
- Delete dialog offers deleting a dictation together with its chat

Tests: 88 backend STT tests pass, including new snapshot download coverage.
Frontend typecheck, lint, i18n parity, and production build pass.

* Merge local engines into one option and source GGML models from unslothai

Engine selection
- The dictation engine dropdown is back to two choices: Browser and Local
  transcription. The selected model decides the backend: curated ids run
  GGML checkpoints through whisper.cpp, searched Hugging Face repositories
  run safetensors through Transformers
- Model picker lists the curated models and searches Hugging Face for other
  Whisper repositories, validating them before selection. The trigger is a
  plain button so the selection never renders inside a text input
- /audio/stt/status accepts a model query param so downloaded state works
  for custom repositories; the engine param on load, transcribe, and
  download routes is derived from the model everywhere

Model source
- Curated GGML checkpoints now download from the Unsloth-hosted
  unslothai/whisper-*-GGUF repositories (one repo per model) instead of
  ggerganov/whisper.cpp; cache lookups, progress totals, and in-flight blob
  tracking are per-model

Fixes
- Voice settings and dictation history were not persisting: the quota-safe
  localStorage wrapper was declared after the store that uses it, so the
  persist storage factory failed silently. Every settings write also threw
  mid-click, which kept the model picker popover from closing on selection
- is_model_downloaded now verifies config, preprocessor config, and real
  weight files instead of trusting an offline snapshot lookup, so a partial
  download left by an aborted fetch shows the Download button instead of
  failing to load
- Removed whisper.cpp mentions from user-facing text: the ready status
  shows Loaded instead of the runtime name, picker rows show the source
  repository, and runtime error messages say local transcription runtime

Verified with automated browser sessions and live API checks: selection
closes the picker with no page errors, persisted settings hydrate on
reload, a stale partial snapshot triggers download then loads on MPS and
transcribes, and curated models download from the unslothai repos. 88
backend STT tests, typecheck, lint, i18n parity, and build pass.

* Skip the duplicate source line for custom models in the STT picker

A custom repository's display name is its id, so search results and the
appended current selection rendered the same string twice. The source
line now only renders when it differs from the name; curated rows keep
their name, unslothai source repository, and download size.

* Verify every shard of a sharded checkpoint in the downloaded check

A snapshot holding one of N shards (or a corrupt shard index) passed the
downloaded check and then failed at load. When model.safetensors.index.json
exists, every shard in its weight map must now be present. Found by
simulation; covered by a regression test.

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

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

* Rename stale _starting references in the pump resilience tests

The startup flag on TrainingBackend was renamed to _spawn_in_progress but
two tests added alongside it still asserted on the old name, failing the
Python 3.11 to 3.13 CI jobs.

* Make the selected model row clearly highlighted in the STT picker

The current selection was a faint background tint. It now uses the accent
background with a medium weight name. Two line rows use a small corner
radius; single line custom repo rows keep the pill shape.

* Address review feedback on STT snapshot checks, VRAM release, and dictation UX

Verify snapshot completeness in the load preflight so a partial download
fails before the audio is decoded, for curated and custom repos alike.
Drop the failed accelerator traceback before the CPU retry so the cache
clear can actually release that memory. Keep unloading the GGUF sidecar
after cancelling an in-flight Transformers load; both engines can hold
memory at once. Allow Auto language with English-only .en checkpoints,
matching the backend which sends no forced language. Keep the discard
button usable while a transcription is pending so a slow or hung request
cannot trap the composer in dictation mode. Stop linking Compare and
settings test dictations to the unrelated active single chat thread.

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

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

* Move the CPU retry out of the exception handler

On Python 3.10 the interpreter exception state keeps its own reference
to the traceback, so dropping it from the caught exception was not
enough to release the failed accelerator load during the retry. Leaving
the handler before clearing the cache works on every supported version.

* Address review feedback on session handoff, chat pinning, and server lifetime

Starting a dictation from a second entry point now cancels the session
it replaces, so the old recording cannot keep the microphone open or
save a transcript with no discard button pointing at it. The linked
chat is pinned when recording starts, so switching threads while a
transcription finalizes cannot relink the transcript to the newly
opened chat. whisper-server is now bound to Studio's lifetime like the
other long-lived children: PDEATHSIG on Linux, the parent job object on
Windows, and pid adoption so the shutdown sweep reaps it; before this
it survived a Ctrl+C exit as an orphan still holding the model.

* Remove the dictation mic test from Voice settings

The composer dictate button covers the same check, so the test row, its
transcript panel, the unsupported fallback row, and their strings and
search entry are gone.

* Studio STT: gate GGUF whisper-server on training and fix dictation retry and dictionary edits

GGUF (whisper.cpp) sidecar:
- Launch whisper-server with --no-gpu while training is active, mirroring the Transformers sidecar's CPU device choice, so a mid-training dictation cannot reclaim the VRAM training just freed.
- Report is_loading() during whisper-server startup so training VRAM admission accounts for the accelerator memory it is about to bind.
- Require PyAV in is_available() so /audio/stt/status reports the engine unavailable when uploads cannot be decoded, instead of loading fine and then 501ing at transcription.
- Reject a missing model before decoding audio, matching the Transformers download preflight.

Voice settings:
- The download Retry button now restarts the download; the sidecar error is sticky until a new start(), so re-polling alone never cleared it.

Dictation dictionary:
- Tabbing from an emptied entry to its remove button no longer commit-splices the row first, which shifted indices and deleted the wrong entry.

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

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

* Studio STT: fix curated GGUF whisper filenames to match hosted repos

The unslothai/whisper-*-GGUF repos host the checkpoint as whisper-<id>.bin,
not ggml-<id>.bin, so every curated dictation download and cached-path
lookup 404'd and the whisper.cpp engine could never load a model. Point
GGML_STT_MODELS at the real filenames and guard the naming with a test.

* Studio STT: validate a custom dictation repo before downloading it

The Transformers STT engine accepts an arbitrary owner/model repo, but the
download route handed it straight to snapshot_download, pulling a possibly large
non-Whisper repository into the shared HF cache. Confirm the repo is a Whisper
checkpoint first with the existing metadata-only validate_remote_model (no
weights); curated ids short-circuit and the GGUF engine (curated-only) is
unaffected. A non-Whisper repo now 422s before any download.

* Studio STT: preempt a still-loading GGUF server for training admission

A whisper-server still in its startup window binds accelerator memory but has no
loaded_model yet, so training admission could miss it and launch into an OOM.
Make the GGUF startup cancellable (cancel_pending_load signals an abort event and
terminates the starting process without the load lock; _wait_for_server observes
it and raises SttLoadCancelledError; wait_for_load_to_settle blocks on the lock
until the killed server is reaped), and always fold the GGUF sidecar into the
resident-STT summary so a resident Transformers model cannot mask a loading GGUF
server. free_stt_model_for_training now cancels an in-flight load and waits for it
to settle before training claims the memory.

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

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

* Studio STT: fall back to Transformers when whisper-server is absent

A curated dictation model (including the default small) hard-pinned the GGUF
engine, but standard installs do not ship whisper-server, so every recording
501'd instead of using the Transformers engine that serves the same checkpoint
-- the GGUF sidecar's own documented contract. Add _resolve_serving_stt_engine:
a GGUF request for a curated id (the only ids GGUF accepts, all Transformers-
servable) downgrades to Transformers when whisper-server is unavailable, applied
consistently to download, load and transcribe (not unload, which targets a
specific engine). The Voice tab likewise falls back to the Transformers status so
the model is not shown unavailable and download is not blocked.

* Studio STT: hide custom Whisper caches from the legacy model pickers

The legacy /cached-models (and /cached-gguf) routes called is_hidden_model with
only the owner/model id, which cannot reach the config-based Whisper check, so a
downloaded custom (non-curated) Whisper checkpoint was still offered as a chat
model. Pass the cached snapshot path so _path_is_whisper_model inspects the repo
config and hides it, matching the discovery route.

* Studio STT: hide GGUF dictation repos, lock-free status, unload fallback, split training eviction

- Hide the curated GGUF dictation repos (unslothai/whisper-*-GGUF) from the chat
  model inventory and pickers, backend and frontend. Only their Transformers
  safetensors companions were hidden; the GGUF repos use a different org and a
  -GGUF suffix and carry a raw .bin with no whisper config.json, so they leaked
  into chat pickers.
- Make the GGUF sidecar loaded_model/device accessors lock-free, mirroring the
  Transformers sidecar. transcribe() holds self._lock across the whole inference
  call, so /audio/stt status polls and training admission previously blocked
  behind an in-flight transcription.
- stt_unload resolves through the serving resolver: a "gguf" pick on a host
  without whisper-server is served by the Transformers fallback, so unload must
  target that engine or the resident model is never freed. Unload also attempts
  every engine even if one raises, so a failure freeing one backend no longer
  skips the other.
- free_stt_model_for_training frees the Transformers and GGUF sidecars under
  independent exception boundaries so a failure unloading one no longer skips
  the other before training claims the memory.

Adds tests/test_stt_review_fixes.py covering all four.

* Studio STT: resolve Auto dictation language for the model engine + snapshot process liveness

- The model dictation adapter sent the raw setting (the literal "auto") to the
  backend, while the browser engine resolves Auto via resolveDictationLanguage.
  A batch of non-English voice notes came back mostly English on Auto. Add
  resolveModelDictationLanguage: only the literal "auto" is resolved to a
  concrete locale, gated so it becomes a language the model AND Whisper can
  honor (mirroring the backend's known-whisper-languages set); an explicit
  language, or a locale Whisper cannot honor, stays unchanged/auto-detect. Wire
  it into both adapter call sites.
- GgmlSttSidecar._process_alive() read self._process twice; a concurrent
  unload() nulls it under the lock while loaded_model/device read lock-free, so
  a null between the two reads called None.poll(). Snapshot once. Adds a
  deterministic regression test.

* studio: tighten comments and docstrings in the dictation modules

* studio: harden dictation model downloads, GGML readiness, and recording paths

Address review findings on the STT dictation feature:

- build_whisper_cpp.sh refuses to delete a whisper.cpp tree under a custom
  Studio home unless it carries the Studio ownership marker, matching the
  setup.sh policy, and marks trees it creates
- _snapshot_is_complete validates every shard of a sharded PyTorch
  (pytorch_model.bin.index.json) checkpoint like the safetensors path, and
  requires tokenizer assets (tokenizer.json or vocab.json + merges.txt)
- custom-repo downloads pin the revision resolved at validation time and
  restrict snapshot_download to the model/tokenizer/config/preprocessor file
  classes Studio loads
- the GGML sidecar holds its port reservation until just before spawning
  whisper-server and only accepts readiness from a responder that both looks
  like whisper.cpp's server and belongs to the still-running managed child,
  probing twice, so mic audio cannot be posted to a foreign local process
- the recording adapter transcribes every non-empty segment; the RMS meter
  only shapes segment boundaries and can no longer discard quiet speech
- Compare-pane dictation can cancel a pending transcription on second click,
  with the button relabeled while finalizing
- localStorage quota recovery halves the dictation history until the save
  fits, so small histories shrink too
- the System default TTS voice resolves to the platform default voice
- new dictation UI imports go through the chat and hub feature barrels

Regression tests cover the build-script gate, sharded PyTorch and tokenizer
completeness, revision pinning and allow patterns, and the whisper-server
readiness probe.

* Fix STT download and voice picker follow-ups

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

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

* Add dictation button regression coverage

* Studio: prebuilt whisper.cpp via the shared llama.cpp install core, slim bundles paired to the llama prebuilt (#7294)

* Studio STT: add prebuilt whisper.cpp (whisper-server) installer

New install_whisper_prebuilt.py downloads a per-platform whisper-server
bundle published by the unslothai/whisper.cpp prebuilt CI into the managed
whisper.cpp dir (build/bin/whisper-server) so local dictation needs no
compiler. Mirrors install_node_prebuilt.py / install_llama_prebuilt.py:
host + backend detection, sha256 pins (whisper_prebuilt_pins.json) as the
trust anchor, staging + install lock + atomic swap, traversal-safe extract,
co-located shared libs (RUNPATH=$ORIGIN), an UNSLOTH_WHISPER_PREBUILT_INFO.json
marker with idempotent "already matches", and exit codes 0/1/2/3. Not wired
into setup yet; the pins ship empty so every asset fails closed until the
first fork release is published and its digests are reviewed in.

* Studio STT: install prebuilt whisper.cpp during setup and update

Add a fail-open whisper.cpp block to setup.sh after the llama.cpp section so
`unsloth studio update` (and a fresh install) fetch the prebuilt whisper-server
into the managed whisper.cpp dir the sidecar discovers. It skips a user-set
WHISPER_SERVER_PATH/UNSLOTH_WHISPER_CPP_PATH, honors UNSLOTH_SKIP_WHISPER_INSTALL,
forwards the resolved ROCm gfx, and never aborts setup: a busy install keeps the
existing runtime, and an unavailable prebuilt stays quiet (source build is opt-in
via UNSLOTH_WHISPER_FORCE_COMPILE) since Transformers STT and browser dictation
remain. Register UNSLOTH_WHISPER_PREBUILT_INFO.json as Studio-owned evidence.

* Studio STT: harden whisper-server child env + WSL ROCm detection

- Sidecar spawns whisper-server with a scrubbed child env that prepends the
  binary dir (co-located GPU libs) to the loader path, and on WSL2 ROCm loads
  the system HIP first (HSA_ENABLE_DXG_DETECTION=1) so a bundle's bare-metal HIP
  does not segfault on /dev/dxg. Secret-bearing vars are dropped from the child.
- find_whisper_server_binary now requires an executable, not just a file.
- Installer rocm probe passes HSA_ENABLE_DXG_DETECTION and falls back to
  /opt/rocm/bin/rocminfo so a WSL ROCm host is not misdetected as CPU-only;
  gfx parsing skips the gfx000 CPU agent and generic ISA lines.
- Tests for the child env (secret scrub, lib dir, WSL HIP precedence), the
  executable check, and the WSL rocm detection.

* Studio STT: in-app whisper.cpp prebuilt update stack + ship pins in the wheel

Mirror the llama.cpp update stack for the whisper.cpp prebuilt so Studio can
detect and install a newer whisper-server release from inside the app:
- backend/utils/whisper_cpp_freshness.py: read UNSLOTH_WHISPER_PREBUILT_INFO.json
  and compare the installed release against the newest unslothai/whisper.cpp
  release. Whisper tags are v<upstream>-unsloth.<N>, so is_behind compares a
  (major, minor, patch, serial) key with a strict downgrade guard; 24h cache;
  fail-open.
- backend/utils/whisper_cpp_update.py: run install_whisper_prebuilt.py to fetch
  and atomically swap the newest bundle, unloading the warm GGUF sidecar first.
- backend/routes/whisper.py mounted at /api/whisper (update-status + update).
- pyproject: add whisper_prebuilt_pins.json to studio package-data so the
  installer's trust anchor ships in the wheel (it is a data file, not a .py
  module, so package discovery alone does not include it; node_prebuilt_pins.json
  is listed for the same reason). Without this a pip-installed wheel had no pins
  and the prebuilt install aborted to Transformers STT.
Adds test_whisper_cpp_freshness.py (version parser, is_behind matrix + downgrade
guard, marker layouts, stale decision, fail-open).

* Studio STT: verify whisper prebuilts via the release checksum index, like llama.cpp

Re-align the whisper.cpp prebuilt installer to install_llama_prebuilt.py's trust
model: instead of a committed whisper_prebuilt_pins.json, verify every download
against the release's own whisper-prebuilt-sha256.json checksum index, fetched
from the same GitHub release.

- parse_release_checksums / fetch_release_checksums / expected_sha256_for replace
  the pins layer. The index is validated for schema/component and that its
  release_tag matches the resolved release; an asset absent from it, a release
  that does not publish it, or a manifest sha256 that disagrees with it all fail
  closed to a source build.
- resolve_release_tag now resolves the newest published release at runtime (or an
  explicit --published-release-tag), matching llama and the freshness check;
  removed the pinned-default and the UNSLOTH_WHISPER_ALLOW_UNVERIFIED opt-in.
- Delete studio/whisper_prebuilt_pins.json and drop its pyproject package-data
  entry (nothing to ship now, same as llama which has no committed pins).
- Adds test_install_whisper_prebuilt_checksums.py (index parser, fail-closed on
  uncovered asset, tampered-manifest guard, newest-release resolution).

This is a same-origin checksum (integrity, not authenticity), identical to the
llama.cpp installer; pair releases with GitHub artifact attestations for provenance.

* Resolve whisper prebuilt release via the download host (no GitHub API)

Mirror install_llama_prebuilt.py's fast path: resolve the release tag from
the releases/latest redirect and fetch the manifest + checksum index from
constructed releases/download URLs, so the common install path makes zero
api.github.com calls (unauthenticated api.github.com is capped at 60 req/hour
per IP; the download host is not). Fall back to the GitHub API only on a 404,
malformed asset, or tag mismatch.

* Studio STT: coverage-aware whisper prebuilt selection via a shared core

whisper's select_artifact returned the first os/arch/backend manifest match and
ignored the SM-coverage fields the release manifest already carries, so a
Blackwell B200 (sm_100) was served cuda12-legacy (sms 50-61) -- runnable only via
forward PTX JIT. install_llama_prebuilt.py on the same host correctly picks
cuda13-newer.

Extract the coverage-aware selection into a shared, component-agnostic core under
studio/backend/utils/prebuilt/ (selection + GPU host-capability detection), lifted
from llama's linux_cuda_choice_from_release / _artifact_covers_sms / _sm_range and
generalised over a normalised artifact. whisper's HostInfo now records the GPU
compute caps + driver CUDA version (honoring CUDA_VISIBLE_DEVICES), and
select_artifact routes CUDA/ROCm through the shared selector: every visible SM
must be covered, the tightest-covering profile wins (Blackwell-aware runtime-line
ordering), ROCm matches the gfx target exactly, and an uncovered GPU falls back to
the CPU bundle. CPU/Metal/Vulkan keep first-match. The resolver JSON, exit codes,
and "already matches" contract are unchanged.

On the B200 the installer now resolves cuda13-newer, matching llama.

* Studio STT: gate whisper CUDA selection on the on-disk runtime, like llama

The prebuilt CUDA bundles are dynamically linked and intentionally do NOT ship
libcudart/libcublas -- they load the same runtime the host already has. So the
driver's advertised CUDA version is only an upper bound: a cuda13 bundle still
needs cuda13 runtime libraries present on disk. Port llama's on-disk runtime
scan (detected_linux_runtime_lines / detected_windows_runtime_lines) into the
shared core and intersect it with the driver-compatible lines in
select_cuda_attempts. A host with a cuda13 driver but only cuda12 runtime (e.g.
torch-cuda12) now correctly gets a cuda12 bundle instead of an unloadable cuda13
one; a host with no CUDA runtime at all falls back to CPU.

Fixes a glob bug in the port (any(Path(d).glob(p) for d in dirs) tests generator
truthiness, not a match) that made every major report present; add a real
filesystem test that exercises the scan.

* studio: harden shared prebuilt core to full llama parity

Apply the review findings on the shared coverage-aware prebuilt-consumer
core so whisper.cpp selection is exactly equivalent to the llama.cpp path.

hosts.py: port llama's CUDA_VISIBLE_DEVICES handling. A GPU hidden by an
index/UUID selector now reports has_usable_nvidia False instead of staying
usable, via supports_explicit_visible_device_matching plus the physical /
explicit-match branches, and _select_visible_rows now matches rows the way
llama does (index or UUID, gpu- prefix optional) and skips unmatched tokens
rather than keeping all rows. Adds the Linux /proc/driver/nvidia/gpus
fallback and has_physical_nvidia. Adds parse_macos_version.

runtime_libs.py: the Linux on-disk scan now requires the exact libcudart /
libcublas SONAME (libcudart.so.13), not a libcudart.so.13* glob, so a bare
versioned file without the SONAME symlink no longer counts as loadable.
Hardens the ldconfig parse against an empty left-hand side.

selection.py: fix the Blackwell/torch reordering so it keys on the covering
runtime lines (falls through to the torch preference when the covering lines
were filtered out), matching linux_cuda_choice_from_release. Corrects the
compatible_runtime_lines_for_driver docstring: the bundles do not ship the
CUDA runtime, so the driver version is only an upper bound and the caller
must intersect with the on-disk scan.

install_whisper_prebuilt.py: enforce a macOS artifact's min_os (new
HostInfo.macos_version) so a bundle that cannot load on the host OS version
is dropped. Keep resolver stdout to only the JSON line by leaving logs on
stderr in --resolve-prebuilt mode, and map an unexpected probe failure to
prebuilt_available False instead of a traceback.

Tests: new host-probe suite for the visible-device logic, exact-SONAME
runtime-scan cases, macOS min_os filtering, resolver stdout-only-JSON,
exit-code mapping, and the repo key.

* studio: fix whisper prebuilt selection + launch parity gaps from review

A parallel review surfaced integration defects where the whisper path could
select or launch a bundle that cannot run on a concrete host. Each is fixed to
match install_llama_prebuilt.py.

macOS min_os: the manifest labels macOS requirements as macos-<version>
(e.g. macos-14.0), which the version parser could not read, so the guard was a
no-op and a macOS-13 host would install the macos-14 Metal bundle. Strip the
platform prefix before parsing.

ROCm gfx detection: _detect_rocm_gfx returned the first gfx token and ignored
HIP_VISIBLE_DEVICES / ROCR_VISIBLE_DEVICES / CUDA_VISIBLE_DEVICES. Since exact
ROCm matching treats that token as the active GPU, a mixed APU + dGPU host
(gfx1151 + gfx1100) with HIP_VISIBLE_DEVICES=1 installed the wrong archive. Route
through a shared pick_rocm_gfx_target (lifted from llama) that parses per-GPU
sections and honors the visibility vars (empty / -1 -> no AMD GPU).

--rocm-gfx override: recording the arch without setting has_rocm left the host on
its CUDA/CPU path so the ROCm bundle was never picked. --rocm-gfx now implies
has_rocm and clears NVIDIA state, like llama's _apply_host_overrides.

CUDA launch env: a CUDA bundle ships the ggml CUDA backend but not
libcudart/libcublas, and the sidecar launch env exposed only the bundle dir, so
on a host whose CUDA runtime lives only in the PyTorch wheels the selection would
gate cuda usable but the server could not load it. Add the CUDA-from-PyTorch
runtime dirs to the child loader path for CUDA bundles (bundle dir still first),
mirroring binary_env.

Also normalize a manifest artifact's supported_sms defensively (parity with
llama's parser) and document that blackwell_min_toolkit_for_caps is retained for
the Phase B llama Windows path.

Not changed (verified parity, not defects): Linux/Windows min_os is enforced
nowhere in llama (macOS only); the resolver is optimistic about the checksum
index and the install path verifies.

* studio: tighten prebuilt-core code comments

* studio: lift shared prebuilt installer core out of the whisper installer

* studio: reuse the llama.cpp prebuilt installer machinery for whisper

* studio: unify llama and whisper prebuilt installers on a shared descriptor core

* studio: consolidate prebuilt installer tests into the shared core suite

Grow tests/studio/install/test_prebuilt_core.py from 62 to 164 tests so every
component-agnostic behavior runs against both descriptors: the full seven
profile CUDA release matrix (multi-GPU, on-disk runtime gating, shuffle
stability, missing SM metadata, dotted SM normalization, no-driver fallback
policy), the ROCm gfx family matrix, macOS min_os gating and its helper,
backend resolution incl. cpu-fallback precedence and Intel-mac auto detect,
checksum-index non-object and plain-lookup cases, the tar symlink/hardlink
extraction guards moved from the llama suite, and the compute-cap, visible
device, runtime-line and Blackwell helper value tables moved verbatim from
the llama characterization suites.

Delete only tests whose exact behavior the master now asserts for the same
component: 40 pure-alias helper cases in test_selection_logic.py (replaced by
value-identical master tables plus an alias-identity pin), 6 extraction moves
and the master-absorbed zip-symlink case in the llama logic suite, 3 routing
twins in test_rocm_support.py already pinned byte-for-byte in
test_selection_logic.py, the 2 Blackwell helper tables in the backend resolve
suite, 28 whisper logic tests and 10 whisper checksum tests re-asserted by
the master whisper parameterization. Wrapper wiring pins, the llama release
plan dialect, fingerprints and every llama-only behavior stay untouched.

* studio: dedupe sidecar and update helpers into the backend prebuilt package

* studio: chain whisper.cpp prebuilt updates onto the llama.cpp update flow

* studio: consume paired slim whisper prebuilts via the llama ggml runtime

* studio: serve every whisper backend from slim prebuilts

* studio: drop the whisper fat per-accelerator selection chain

unslothai/whisper.cpp releases are slim-only from v1.9.1-unsloth.2: one
ggml-less bundle per os/arch, paired to the llama.cpp prebuilt that provides
every ggml backend. Delete the whisper-side fat CUDA/ROCm/metal/vulkan
selection glue; keep slim selection + pairing, link_ggml_runtime, and one
legacy shape, the published fat CPU bundle of an explicitly pinned pre-slim
release. Exit 2 now reads as prebuilt unavailable (whisper never source
builds); setup already treats it that way.

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

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

* Wire libomp runtime DLL alongside ggml in slim whisper installs

llama's clang-built windows-arm64 ggml-base.dll imports
libomp140.aarch64.dll, shipped in the llama bundle but not a system DLL.
Without it next to whisper-server.exe the loader fails with
STATUS_DLL_NOT_FOUND before main. MSVC x64 links vcomp140.dll from
System32 and Linux ggml uses system libgomp.so.1, so only windows-arm64
was affected. The empty-runtime guard still requires a real ggml
library; libomp alone is not a pairing.

* studio: drop whisper-side fat-selection support structure

Slim whisper bundles are selected per os/arch only; all accelerator
capability comes from the installed llama.cpp prebuilt, whose installer
already did the coverage-aware selection. Remove the machinery that only
existed to pick among fat per-accelerator whisper bundles:

- prebuilt_core: delete the generic CUDA/ROCm coverage selection
  (select_cuda_artifact, select_rocm_artifact, ArtifactView adapters,
  detected_cuda_runtime_lines, the exact-SONAME linux probe) that no
  shipped component routes through; llama keeps its own selection chain
  and whisper shadows select_artifact with the slim-only version.
  select_artifact is now a plain os/arch/backend first-match.
- install_whisper_prebuilt: drop the HostInfo CUDA fields
  (compute_caps, driver_cuda_version, torch_runtime_line) and the torch
  runtime probe that populated them; nothing reachable reads them, and
  the resolver payload sources runtime_line from the artifact.
- whisper_cpp_update: delete the standalone start_update job worker;
  whisper applies only run as the chained phase of the combined
  llama+whisper update. The status payload keeps its job field (idle).
- routes/whisper: drop the progress logger that could never fire.
- tests: remove tests of the deleted paths and tests duplicating the
  descriptor-parameterized core suite or the llama freshness suite.

Contracts unchanged: resolver JSON keys, exit codes, marker fields,
pairing logs, and the pinned pre-slim fat CPU escape hatch.

* Address review feedback on the whisper prebuilt update and install paths

- Pin the chained whisper phase to the release the freshness check
  offered, so the download-host latest pointer cannot reinstall an
  older build in a loop
- Wire the whisper prebuilt install into setup.ps1 (Windows setup
  previously skipped it entirely)
- Treat a non-executable server or missing wired ggml libraries as a
  broken install instead of reporting already matches
- Keep whisper sidecar reloads out of the job-level reload flag and
  resync chat state after a partial chained update that unloaded llama
- Repoint home and profile vars for the whisper-server subprocess at a
  managed scratch dir and drop credential-store pointers
- Clear the prebuilt marker before the opt-in source build overwrite
- Write the prebuilt marker with explicit utf-8 encoding

* Tighten comments in the whisper prebuilt consumer

* Harden the Windows whisper setup phase and the chained update edges

- setup.ps1: honor WHISPER_SERVER_PATH / UNSLOTH_WHISPER_CPP_PATH /
  UNSLOTH_SKIP_WHISPER_INSTALL, run the custom-home ownership guard
  before the atomic install, and forward the release-tag pin and ROCm
  hints like setup.sh
- sidecar: a cpu-selected install launches whisper-server with --no-gpu
  (slim wiring links every llama backend, so the flag is what keeps a
  deliberate CPU choice off the GPU)
- chained update: leave whisper unpinned on macOS (the llama phase can
  walk back there, and a newest-tag pin could be an impossible pairing
  on every retry) and treat installer exit 2 as kept-existing-runtime
  instead of failing the combined job
- job.to_tag now comes only from the llama phase, so a whisper-only
  round cannot report a llama update that never ran

* Fix slim whisper runtime follow-ups

* Address remaining whisper update reviews

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

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

* Address remaining prebuilt update reviews

* Fix remaining chained update reviews

* Fix remaining whisper runtime review edges

* [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>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
2026-07-23 01:39:03 -07:00
Leo Borcherding
430ada617a
installer: fix false "no GPU detected" on AMD hosts (dead KFD check) + clearer ROCm-less warning (#7314)
* installer: fix Linux AMD GPU detection + actionable ROCm-less warning

The rocminfo/amd-smi-less fallback in _has_amd_rocm_gpu keyed on a
/gpu_id/ line inside each KFD node's properties file, but gpu_id is a
separate sibling sysfs file and never appears in properties. The guard
never matched, so the fallback missed every AMD host without ROCm
tooling (e.g. a fresh CachyOS/Arch box) and reported 'no GPU detected'
despite vendor_id 4098 being present in the KFD topology.

Detect via vendor_id == 4098 directly: the KFD CPU node reports
vendor_id 0, so any 4098 node is an AMD GPU, while NVIDIA's KFD nodes
report 4318 and stay excluded.

Also rework the 'ROCm version could not be determined' warning into an
actionable message (install the ROCm/HIP SDK; Arch/CachyOS:
rocm-hip-sdk) so ROCm-less users know the concrete next step instead of
silently landing on CPU-only PyTorch.

* tests: replace the FNR==1 KFD invariant with the per-line vendor_id check

The FNR==1 reset guarded the old paired gpu_id+vendor_id awk against
cross-node state leakage. The new detection is a single atomic
vendor_id==4098 line condition, so there is no per-node state to reset;
assert the new invariant instead (single-line vendor match, and no
/gpu_id/ pattern, which never matched inside properties).

tests/studio/install/test_rocm_support.py: 344 passed, 2 skipped.

* installer: mirror the KFD vendor_id fix in setup.sh + honest CPU-fallback summary

Codex P2 follow-ups:
- studio/setup.sh carried the same dead gpu_id-inside-properties awk, so a
  host install.sh now routes to ROCm still failed setup's independent AMD
  re-probe and got a CPU llama.cpp. Use the same per-line vendor_id 4098
  check.
- When the AMD GPU is detected but the torch index stays CPU, the summary
  printed the old false diagnosis (gpu none / "No GPU detected"). Gate both
  on _has_amd_rocm_gpu and say what actually happened: AMD GPU present, no
  usable ROCm, CPU fallback.
- Structure test asserting setup.sh's KFD awk stays in sync with install.sh.

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

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

* Keep KFD-only AMD hosts on the CPU fallback (Codex P2s)

The KFD-topology fix makes _has_amd_rocm_gpu / _setup_amd_detected true on hosts that expose an AMD GPU to the kernel but ship no rocminfo/amd-smi. Detection alone does not mean ROCm is usable or that the gfx arch is known, and two downstream paths wrongly assumed it did:

- studio/setup.sh forwarded --has-rocm with no gfx, so install_llama_prebuilt found no per-gfx bundle and dropped to a HIP source build (slow, or a hard failure without build deps) instead of the CPU prebuilt these hosts used to get. Now --has-rocm is forwarded for a gfx-unknown host only when hipcc is present; otherwise it keeps the CPU prebuilt.

- install.sh get_torch_index_url selected a generic rocmX.Y index whenever the ROCm version was readable, but the Strix reroute only learns gfx from rocminfo/amd-smi, so a Strix KFD-only host landed on the broken _grouped_mm wheels. Now, when neither rocminfo nor amd-smi is present (gfx unknowable), it stays on CPU with a hint to install them.

Detection and the improved diagnostics are unchanged; only the routing for gfx-unknown KFD-only hosts is made safe. Adds tests for both gates.

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

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

* Harden KFD-only fallback: probe gfx, accept versioned hipcc (Codex P2s)

Follow-up to the previous commit's two guards:

- install.sh: the KFD-only torch guard tested only 'command -v rocminfo/amd-smi', so a host where those binaries exist but do not enumerate the GPU (gfx unreadable) slipped through and, with hipconfig/rocm-core present, still got a generic rocm index -- breaking Strix. Now it actually reads the gfx (rocminfo, then amd-smi list / static --asic, the same probe the reroute uses) and falls back to CPU whenever the arch is unreadable, not just when the binaries are absent.

- studio/setup.sh: the hipcc gate missed a HIP toolchain installed only under a versioned prefix (/opt/rocm-*/bin/hipcc), which the source build at setup.sh:1663 does support, so such hosts were dropped to the CPU prebuilt unnecessarily. The gate now also accepts /opt/rocm-*/bin/hipcc.

Tests updated to assert the gfx-read (not binary-presence) gate and the versioned hipcc path; full test_rocm_support.py green (347 passed). Verified the gfx probe by execution: rocminfo-with-no-gfx now routes to CPU, amd-smi fallback still resolves gfx.

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

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

* Honor UNSLOTH_ROCM_GFX_ARCH before the CPU fallback for PR #7314

Seed both the gfx-unknown guard in get_torch_index_url and the Strix reroute
from UNSLOTH_ROCM_GFX_ARCH before probing rocminfo/amd-smi, so a host that
names its arch reaches the correct rocm index instead of being forced to CPU
(or to the generic wheels) when the runtime probes can't enumerate the GPU.

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

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

* Probe gfx with visibility masks cleared for PR #7314 (Codex P2)

rocminfo/amd-smi honor ROCR/HIP_VISIBLE_DEVICES, so a container that masks the
GPU (e.g. ROCR_VISIBLE_DEVICES=-1) would make the gfx probe read nothing and
force CPU torch, even though the KFD-based AMD detection is env-independent and
hipconfig can still supply the ROCm version. Clear the visibility masks for the
rocminfo/amd-smi arch probe only (the Strix reroute keeps them for per-GPU index
selection), so a masked/container host keeps its ROCm route.

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

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

* Re-probe gfx unmasked in the Strix reroute when a mask hides all agents for PR #7314 (Codex P2)

* Remove leftover conflict marker from the test merge

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

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

* Report an explicit CPU pin instead of a ROCm misdiagnosis for PR #7314 (Codex P3)

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

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

* Trigger the reroute re-probe on a set-but-empty visibility mask for PR #7314 (subagent review)

* Guard the ROCm version chain against set -e when no source exists for PR #7314 (simulation find)

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

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

* Preserve the inferred-gfx reroute for KFD-only hosts (Codex P2)

The gfx-unknown CPU guard in get_torch_index_url fired before the
runtime-less reroute could run: with the KFD topology fix,
_has_amd_rocm_gpu is true on KFD-only hosts, so the reroute's
'! _has_amd_rocm_gpu' gate never let _infer_linux_amd_gfx_arch route
them to AMD per-arch wheels, regressing inferable boxes (PCI/cpuinfo/
lspci) from arch-specific PyTorch to CPU-only.

- Factor the override->rocminfo->amd-smi gfx probe (masks cleared)
  into _probe_amd_gfx_arch, shared by the guard and the reroute gate
  so the two can't disagree on what 'readable' means.
- Reroute gate now also fires when the GPU is detected but the probe
  is empty (KFD-only). Deliberate CPU fallbacks (old/unreadable ROCm
  version) all had a readable gfx and stay excluded.
- The guard defers to the reroute (no false 'installing CPU-only
  PyTorch' promise) only when inference yields a supported family;
  otherwise the actionable CPU warning is unchanged.

Executed tests: KFD-only host reroutes to repo.amd.com per-arch wheels
and exports UNSLOTH_ROCM_GFX_ARCH for setup.sh; readable-gfx CPU
fallback stays un-rerouted; undetected-GPU reroute unchanged; the
guard's three inference outcomes covered. Suite: 375 passed, bash -n
clean on both scripts.

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

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

* Fix two false diagnostics on the KFD-only paths (Codex P3s)

1. get_torch_index_url: with UNSLOTH_ROCM_GFX_ARCH set on a KFD-only
   host that has no ROCm version sources, the no-version endpoint
   printed 'falling back to CPU-only PyTorch' even though the reroute
   (gated on the override) then installs the per-arch wheels. When the
   override maps to a wheel family, defer with an accurate message;
   an unmappable override keeps the CPU warning since the reroute
   can't route it either.

2. Runtime-less reroute: the KFD-only branch reached the warning
   'ROCm runtime not visible (/dev/kfd, rocminfo, amd-smi)' although
   /dev/kfd is exactly what detected the GPU. The diagnostic now
   distinguishes KFD-visible/tooling-blind hosts from truly
   runtime-invisible ones.

Executed tests: supported override defers without the false CPU
warning, unsupported override and readable-gfx no-version hosts keep
it; KFD-only reroute emits the KFD wording, undetected-GPU reroute
keeps the original. Version sources are shimmed so the tests hold on
dev boxes with a real hipconfig. Suite: 376 passed, bash -n clean.

* [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-23 00:42:03 -07:00
Daniel Han
2c492c8d9b
Recognize Radeon 8065S (Gorgon Halo / Ryzen AI Max 400) as gfx1151 (#7290)
* Recognize Radeon 8065S (Gorgon Halo / Ryzen AI Max 400) as gfx1151

* Classify Radeon 8065S (Gorgon Halo) as unified memory in ROCm OOM guard

* [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-21 18:07:41 -07:00
Naitik Pal
cf912cbd88
feat(studio): add UNSLOTH_LLAMA_CPP_BACKEND env var to force CPU fallback #7213 (#7228)
* test(studio): add e2e test for cpu-fallback overriding vulkan

* feat(studio): add UNSLOTH_LLAMA_CPP_BACKEND env var

* feat(studio): add UNSLOTH_LLAMA_CPP_BACKEND env var

* Preserve UNSLOTH_LLAMA_CPP_BACKEND=cpu across llama.cpp updates for PR #7228

The in-app updater rebuilt the installer command without --cpu-fallback and
only re-asserted Vulkan, so accepting a llama.cpp update after forcing CPU on
an Intel iGPU host re-ran host detection and routed back to the crashing Vulkan
bundle (#7213). Record install_kind in the prebuilt marker and re-assert
--cpu-fallback on update when the installed bundle is CPU.

Also make setup.sh's UNSLOTH_LLAMA_CPP_BACKEND check case-insensitive to match
setup.ps1, and add tests for the updater CPU preservation and the setup.sh flag
plumbing.

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

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

* Trim and validate UNSLOTH_LLAMA_CPP_BACKEND, warn on unknown values for PR #7228

Trim surrounding whitespace and lowercase the value in both setup.sh and
setup.ps1, so values like ' cpu ' or 'CPU' still force the CPU-only prebuilt.
An unrecognized value (e.g. 'gpu') now prints a warning instead of silently
falling back to auto. Extend test_setup_llama_cpp_backend.py to cover both
scripts, including trimmed, empty and unknown values.

* Preserve arm64 CPU installs on update and honor CPU override in Windows prune for PR #7228

The update-path CPU preservation only matched install_kind ending in -cpu, so
arm64 CPU bundles (linux-arm64, windows-arm64) were re-routed to a GPU or source
build on update. Match the full set of CPU-only kinds instead.

Persisting install_kind also activated the previously inert Windows
mismatch-prune in setup.ps1: on a GPU host with UNSLOTH_LLAMA_CPP_BACKEND=cpu it
saw the windows-cpu marker as mismatched and deleted it every rerun. Normalize
the override once and make CPU expected so a deliberate CPU install is kept.
Extend the tests to cover both.

* Document legacy llama.cpp markers keep heal-to-GPU on update for PR #7228

Legacy prebuilt markers written before install_kind was persisted intentionally
do not force --cpu-fallback on update: the in-app updater lets them re-resolve
(heal to a GPU bundle) per the existing behavior from #6097, and only markers
that explicitly record a CPU install_kind are pinned to CPU. Add a comment and a
regression case documenting the boundary.

* Tighten llama.cpp CPU-fallback comments for PR #7228

* Fix Windows install-prune to keep valid Intel/fallback bundles for PR #7228

Persisting install_kind activated the setup.ps1 mismatch-prune, whose
expectedKinds was incomplete: the non-NVIDIA/non-AMD branch omitted
windows-vulkan (the Intel auto-route) and the GPU branches omitted the
windows-cpu/windows-arm64 fallback the installer uses when a GPU prebuilt is
missing. That made every setup rerun delete and re-download a valid Intel Vulkan
(or CPU-fallback) install. List all kinds the installer can produce per host so
only a bundle the host cannot run is pruned. Cover the full matrix in tests.

* Persist force_cpu marker flag so only forced CPU installs re-assert on update for PR #7228

* Add --force-cpu for deliberate CPU installs and warn on macOS for PR #7228

* Record force_cpu when reusing a matching CPU bundle for PR #7228

* Accept force_cpu keyword in installer test validator fakes for PR #7228

---------

Co-authored-by: danielhanchen <unslothai@gmail.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-07-20 00:33:56 -07:00
Michael Han
6d8c18cd1a
Replace standalone Studio wording with Unsloth (#7221)
* Replace standalone Studio wording with Unsloth

Replace the single word Studio with Unsloth wherever it is used as
shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n
locales, workflow display names, comments and docstrings.

Kept unchanged: the full name Unsloth Studio, third party product
names (LM Studio, Visual Studio, Mac Studio), feature names
(Recipe Studio, Fine-tuning Studio and its translations), and all
identifiers such as env vars, commands, paths and filenames.

* Address review feedback on the Studio wording rename

Use "an" before Unsloth where the rename left the article as "a".
Restore the split brand where Unsloth and Studio render as two halves
of the full product name: the onboarding sidebar subtitle and the
IPv6 localhost warning. Scope two messages to the full name Unsloth
Studio where plain Unsloth was misleading: the AMD README bullet and
the CLI studio setup error.
2026-07-19 00:47:04 -07:00
Leo Borcherding
91a0df9514
Studio: make the Cloudflare tunnel opt-in (off by default) (#7046)
* Studio: make the Cloudflare tunnel opt-in (off by default)

A wildcard bind (`-H 0.0.0.0`) auto-started a public trycloudflare.com
tunnel, so exposing Studio on the LAN also published it to the public
internet. Flip the default so the tunnel is opt-in.

- `--cloudflare` is now tri-state (Optional[bool], default None = off),
  mirroring the existing --enable-tools/--disable-tools handling. Pass
  --cloudflare to expose a public HTTPS link for a wildcard bind; --secure
  still implies the tunnel.
- --secure + --no-cloudflare is still rejected as a contradiction.
- Update the parent-command guard, re-exec forwarding, startup-banner
  wording, the colab comment, README, and tests.

* Studio: update installer/setup launch hints for opt-in Cloudflare

The post-install launch hints only mentioned --secure for a public link.
Now that the tunnel is opt-in, clarify that -H 0.0.0.0 exposes the raw
port on the LAN (not a public URL), and surface --cloudflare as the
explicit opt-in for a public HTTPS link (--secure keeps the raw port
private). Applied to install.ps1, install.sh, and studio/setup.sh.

* Studio: address review - keep cloudflare tri-state + harden run re-exec

Two review points from the bots:

- Gemini: keep `cloudflare` as Optional[bool] in run_server instead of
  casting None -> False, so the startup banner can distinguish "OFF (default)"
  (unset) from "OFF (--no-cloudflare)" (explicit). `_cloudflare_flag` and the
  banner branch now carry the tri-state.
- Codex (P1): `unsloth studio run` re-execs the studio venv's console script,
  which can be an older build whose --cloudflare defaulted on; omitting the
  flag let it re-enable the tunnel. That path now forwards the default polarity
  explicitly (--no-cloudflare, or nothing under --secure since --secure implies
  the tunnel). The plain `unsloth studio` path runs the same-version in-tree
  run.py (resolved via _find_run_py), so it keeps forwarding only an explicit
  polarity and still shows the accurate "(default)" banner.

Tests updated for the tri-state banner labels, the None gate cases, and the
new re-exec forwarding.

* Studio: forward --no-cloudflare on plain re-exec too (mixed install)

Codex follow-up: _find_run_py falls back to STUDIO_HOME/.../studio/backend/
run.py when the package copy is absent, so the plain `unsloth studio` re-exec
can land on an older run.py whose --cloudflare defaults on. Forward the default
polarity explicitly there too (--no-cloudflare, or nothing under --secure),
matching the run subcommand. The common in-venv launch skips the re-exec and
still shows the tri-state "(default)" banner.

* Studio: fix launch hint - --cloudflare needs the wildcard bind

Codex P3: the launch hint listed --cloudflare next to the loopback
`unsloth studio -p 8888` command, but the tunnel only starts for wildcard
binds, so `--cloudflare` alone on 127.0.0.1 does nothing. Show
`-H 0.0.0.0 --cloudflare` in the hints (install.ps1, install.sh,
studio/setup.sh) and clarify the same in the README.

* Studio: cross-platform masked terminal password prompt helper

Per-keystroke '*' echo (POSIX termios cbreak / Windows msvcrt.getwch),
backspace editing, Ctrl-C abort, EOF handling, confirmation loop with
re-prompt on mismatch or policy failure. Pure should_prompt gate for the
--secure/--cloudflare exposure paths.

* Studio CLI: force a terminal password change before public tunnel exposure

When a launch will start the Cloudflare tunnel (--secure, or --cloudflare on
a non-api-only wildcard bind) and the admin account still has its seeded
bootstrap password, prompt for a new password in the terminal (masked with
'*', confirmed, re-prompting until valid) before any re-exec or server
exists. The change is committed in the parent so it never crosses argv or
the environment and older studio-venv children see it immediately. Without
a terminal, warn and fall back to the backend bootstrap shutdown timer.
Mirrors backend update_password semantics in one transaction: rehash,
rotate the JWT secret, clear must_change_password, revoke refresh tokens,
drop the desktop secret, then remove the stale credential files.

* Studio: terminal password gate before the public tunnel (backend backstop)

Never publish a trycloudflare URL while the seeded admin password is
active: run_server now runs a terminal password-change gate after the
tunnel decision and strictly before start_studio_tunnel. Interactive
refusal fails closed (shutdown + exit 1, mirroring the secure gate);
without a tty it warns and keeps the bootstrap deadline. Success applies
the same effects as the change-password route (update_password +
revoke_user_refresh_tokens) and drops the stale
app.state.bootstrap_password. MIN_PASSWORD_LENGTH centralised in
auth/storage.py and referenced by the HTTP schema. terminal_prompt.py
carries the pure gate helper (interactive loop stubbed; supplied by the
masked-input module). Also migrates the studio/setup.ps1 launch footer
that still showed the bare wildcard hint.

* README: reconcile remote-access section with opt-in Cloudflare tunnel

* Studio: harden the terminal password gate after review

- run.py: run the gate BEFORE the uvicorn socket binds. On a wildcard
  --cloudflare launch the served HTML injects the bootstrap credential
  for first login, so a pre-gate listener would hand the default
  password to anyone who reaches the raw port while the operator is
  still typing. The gate now also seeds the admin row itself (it can
  run before lifespan startup).
- Headless launches that nothing would protect now fail closed: the
  bootstrap deadline never arms for api-only serving and
  UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT=0 disables it, so warn-and-proceed
  would have promised a shutdown that never comes. Both the CLI and the
  backend refuse to publish in that case; the ordinary headless path
  still warns and relies on the 1h deadline, and no longer auto-fills
  the default credential into HTML served on a public URL.
- storage.update_password gains revoke_refresh_tokens to delete the
  user's refresh tokens in the SAME transaction as the password commit;
  the change-password route and the backend gate use it (a separable
  follow-up delete could fail after the commit and leave a stale
  refresh token able to mint access tokens under the rotated secret).
- clear_bootstrap_password is best-effort: a locked/undeletable file
  must not surface as a failed password change.
- CLI masked reader: disable ISIG like the backend so Ctrl-Z cannot
  suspend the process with the shared terminal stuck in no-echo mode;
  handle Ctrl-C/Ctrl-Z as characters; treat stream EOF mid-line as an
  abort instead of submitting a partial password. Both readers restore
  terminal attrs from a SIGTERM/SIGHUP handler since a finally block
  cannot run when a default-disposition signal terminates the process.
- Backend reader: decode byte-at-a-time through an incremental UTF-8
  decoder so multi-byte characters split across read boundaries are no
  longer dropped; isatty checks tolerate closed/None streams.

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

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

* Studio: persist bootstrap suppression through lifespan startup

The pre-bind password gate nulled app.state.bootstrap_password, but the
FastAPI lifespan runs after it and re-reads the bootstrap password into
app.state on both admin paths, so a headless public launch could still
serve the injected credential in HTML. Carry a persistent
suppress_bootstrap_injection flag that the lifespan honors instead.

Also drop the quoted Tuple annotation on _terminal_password_gate that
tripped the import-hoist lint (the typing import looked unused).

* Studio CLI: keep the pre-exec auth DB private (0700 dir, 0600 db)

On a fresh install the pre-exposure password gate creates auth/ and
auth.db through the CLI before the backend ever runs, and
sqlite3.connect leaves the DB 0644 under a 022 umask. Mirror backend
storage.get_connection's chmod so the committed password hash and JWT
secret are never world-readable, even if the launch aborts before the
backend applies its own modes.

* Tighten pre-exposure password gate comments

* Studio: delete seeded bootstrap password before headless public re-exec

The headless warn-and-proceed path returns with the default admin
password still active, then re-execs a child Studio process. An old
studio-venv child (mixed-version install) predates the pre-bind gate and
its injection-suppress flag, so its lifespan reads .bootstrap_password
and injects the seeded credential into the public HTML for up to the
bootstrap deadline. A CLI-flag handshake cannot fix this uniformly: the
studio run path uses ignore_unknown_options and an old in-venv child
runs in-process, so it would never reject the flag.

Delete the seeded .bootstrap_password file in the parent before re-exec
so a fresh child of any version reads None and never serves it. This
covers both re-exec paths and both child versions. must_change_password
stays set, so the login page still forces a change and the bootstrap
shutdown timer still arms; only the plaintext-on-disk copy is removed.
Recovery is via a terminal-attached run or reset-password. Backend gate
and CLI warnings updated to match.

* Studio: commit the seeded admin before headless public re-exec

The headless-warn path deletes the seeded .bootstrap_password so a
re-exec'd child cannot inject it, but _ensure_cli_default_admin's INSERT
was never committed and rolled back on conn.close(). On a fresh
STUDIO_HOME an old studio-venv child then found no admin, regenerated a
fresh bootstrap password + file, and injected THAT into the public page,
defeating the deletion.

Commit the seeded admin right after _ensure_cli_default_admin so any
re-exec'd child sees the existing account and does not regenerate.
Regression tests cover both re-exec paths on a fresh (unseeded) DB.

* Studio: fail closed when the bootstrap password file cannot be removed

On the headless public path, deleting .bootstrap_password is the
protection against an old re-exec'd child injecting the seeded
credential. If unlink fails (locked file, read-only auth dir) the file
is still on disk, so warning and proceeding would still leak it for the
bootstrap-timeout window. Abort with a clear error instead. Regression
test covers the unlink-failure fail-closed path.

* Studio: hold no-echo for the whole password line, not per keystroke

The POSIX masked reader set cbreak/no-echo inside _getch_posix and restored
the terminal to echo-on in a finally after every single keystroke, because
_read_password calls _getch once per character. Between one char returning and
the next call re-entering cbreak, ECHO was on, so a keystroke arriving in that
window echoed the password in cleartext.

Move the terminal mode into a _prompt_raw_mode context that _read_password
holds around the entire line (mirroring unsloth_cli/commands/_password_prompt.py,
which already did this), restoring once when the line completes or aborts.
_getch_posix now only reads, since the mode is held by the caller. The context
is a no-op when stdin is not a real terminal, keeping the _getch test seam.

Add a regression test asserting the raw-mode context wraps the read exactly
once and every keystroke is read while it is active.

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

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

* Studio: strip the seeded bootstrap password when the auth DB check fails

The pre-exposure gate returned early on two auth-DB inspection failures and
proceeded to re-exec without removing the seeded .bootstrap_password:

- _connect_auth_db() failure: a seeded credential from a prior run may still
  be on disk.
- the must_change_password read-back failure: worse, _ensure_cli_default_admin
  had already seeded the admin and the code committed it (writing
  .bootstrap_password) right before the failing SELECT.

In the mixed-version case (a new outer CLI re-execing an old studio-venv child
that predates the pre-bind gate), that child would read the file back and
inject the default admin credential into the public Cloudflare page. The
sibling headless branch already deletes the file for exactly this reason, so
these returns were an inconsistent gap.

Factor the delete-or-fail-closed logic into
_strip_seeded_bootstrap_password_or_exit and call it on both inspection
failures (and reuse it in the headless branch): strip the seeded file first
(version-independent protection), failing closed if the removal itself fails.
must_change_password stays set, so the login page still forces a change and the
bootstrap shutdown timer still arms.

Add tests for both new paths (connect failure and post-commit read-back
failure strip the file and proceed; a failed strip fails closed).

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

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

* Studio: fail closed when the seeded admin cannot be committed before exposure

The pre-exposure gate wrapped _ensure_cli_default_admin (the INSERT), its
conn.commit(), and the must_change_password read-back in one try, and the
except recovered by stripping .bootstrap_password and proceeding to re-exec on
the assumption the admin was already committed. That assumption only holds when
the failing statement is the SELECT. When the INSERT or the commit itself fails
(e.g. a write lock held past the busy timeout on a fresh install), no admin row
is committed: it rolls back on conn.close(), and a re-exec'd old studio-venv
child (no pre-bind gate) then finds no admin, regenerates a fresh bootstrap
password + file, and serves that default credential on the public Cloudflare
page. Stripping the file cannot stop a regeneration.

Split the seed+commit into its own try that fails closed (refuse the public
launch, best-effort removing any half-written seed file) since we cannot prove a
committed admin; keep the separate read-back failure on the strip-and-proceed
path, where the admin is committed so an old child finds it and will not
regenerate. Add a test for the seed-commit-failure path.

* Studio: decode the CLI masked password reader with errors="replace"

The CLI reader read keystrokes with text-mode sys.stdin.read(1), which raises
UnicodeDecodeError on a pasted non-UTF-8 password (e.g. Latin-1 bytes), or under
PYTHONUTF8 yields a lone surrogate that later crashes the pbkdf2 encode -- either
aborts the launch with a traceback. The backend mirror (terminal_prompt.py)
already reads raw bytes through an incremental decoder with errors="replace".
Mirror that here: read with os.read and an incremental decoder so invalid bytes
map to U+FFFD, iterating over each emitted char (one byte can complete a
replacement plus the next char).

* Studio: resolve the child launcher before the pre-exposure gate

The gate strips the seeded .bootstrap_password on a headless public launch, and
it ran before the re-exec launchability check (studio venv / run.py / console
script present). So a headless launch with an incomplete studio setup would seed
the admin, delete the bootstrap password, then abort because the child could not
be found, leaving the admin at must_change_password=1 with no password ever
shown or injectable: locked out until `unsloth studio reset-password`.

Resolve and validate the child launcher first, in both `studio` (studio_default)
and `studio run`, and only then run the gate, so an unlaunchable setup exits
before anything is stripped. Add a regression test that a missing venv exits
without removing the seeded file.

* Studio: fail closed when the auth DB cannot be opened before exposure

The connect-failure branch of the pre-exposure gate stripped .bootstrap_password
and proceeded, on the assumption a committed admin from a prior run made an old
child find it and not regenerate. But on a fresh public launch whose
_connect_auth_db() itself fails (transient lock during the schema/seed step, or
an unwritable home), no admin is committed, so a mixed-version re-exec child that
predates the backend gate can find no user, generate a fresh bootstrap password,
and serve it on the public Cloudflare page. Stripping a file we cannot vouch for
cannot stop a regeneration.

Make this branch fail closed like the seed/commit failure path: we only continue
past the DB inspection once a committed admin is confirmed. The existing file is
left untouched so a retry (after a transient lock clears) can still prompt.

Update the connect-failure test to assert fail-closed, and give the in-venv
--secure flag test a real STUDIO_HOME with an already-changed admin so the gate
is a no-op rather than relying on a DB-open failure.

* Studio: invalidate seeded bootstrap files before deleting auth.db on reset

reset-password deleted auth.db first, then best-effort unlinked the seeded
.bootstrap_password and desktop secret. unlink() only ignores
FileNotFoundError, so a locked or read-only file (Windows AV, read-only auth
dir) survived while auth.db was gone. The next server start then re-seeded
from that stale plaintext and re-validated the exact credential the reset was
meant to revoke.

Invalidate the credential files first, truncating any that cannot be
unlinked, then delete the DB, so a surviving file can never carry a reusable
secret. clear_bootstrap_password now truncates on unlink failure for the same
reason, and its warning says the contents were cleared rather than claiming
the stale password is already invalid.

* Studio: require a servable frontend before the pre-exposure gate can strip the seeded password

A headless public launch strips the seeded .bootstrap_password before the
re-exec'd child starts. If the child then cannot serve the login page (the only
in-band way to change the seeded password) the admin is locked out
(must_change_password=1, no file, no UI) until reset-password.

Add _require_servable_frontend_or_exit and call it before the gate on both
`unsloth studio` and `unsloth studio run` public launches: fail closed if a
non-api-only public launch has no built frontend dist, before anything is
stripped. A user-supplied --frontend is validated to contain index.html so a
bad path cannot silently bypass the check; an auto-resolved dist is trusted
(_find_frontend_dist already requires index.html) and forwarded to the child.

Model-load aborts on `studio run` remain a residual: the parent must strip for
mixed-version safety (an old studio-venv child has no pre-bind gate) and model
loadability cannot be proven before exec, so that path stays recoverable via
reset-password.

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

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

* Studio: harden reset-password ordering and validate the in-venv backend before the strip

Three follow-ups to the pre-exposure hardening:

reset-password now deletes auth.db FIRST and proves it is gone before touching
the seeded credential files. If the DB cannot be removed (a running Studio or
Windows holds it open, or a read-only auth dir) it aborts with the credential
files untouched, so a forgotten-password reset is not left half-done with the
recovery credentials deleted while an un-resettable must_change_password=1 DB
survives. After the DB is gone it invalidates the stale credential files
(unlink, else truncate) and fails closed if a file can be neither removed nor
truncated, since a surviving plaintext would be re-seeded by
generate_bootstrap_password() and re-validate the revoked password.

The in-venv (in-process) launch path had no analogue of the re-exec launcher
check: a headless public launch would seed the admin and strip the seeded
.bootstrap_password in the gate before _load_run_module() later failed on a
broken/partial venv, leaving must_change_password=1 with no password to log in.
Add _validate_inproc_backend_before_strip, called on the in-venv path (both
`unsloth studio` and `unsloth studio run`) before the gate on the headless
public path, so a broken backend fails cleanly before anything is stripped. It
is scoped to the headless path so an interactive prompt is not delayed behind a
full backend import.

* Studio: validate the frontend and tunnel before the strip on every public path

Five follow-ups closing the remaining pre-exposure-strip lockouts:

The in-venv (in-process) paths of both `unsloth studio` and `unsloth studio
run` validated the backend but not the frontend before the gate, so a headless
public launch with a missing/bad dist would strip the seeded .bootstrap_password
and then abort in run_server() during frontend setup, leaving
must_change_password=1 with no login page. Both now validate a servable frontend
before the strip (cheap check first, backend import after) and serve the
resolved dist in-process.

The `studio run` re-exec discarded the dist that satisfied the pre-strip check
and only forwarded a user-supplied --frontend. In a shadowed install where the
parent finds a built dist the child cannot, it stripped and exec'd without the
path, and the child aborted during frontend setup. It now forwards the resolved
dist, matching `unsloth studio`.

On a headless --secure launch the bind is loopback, so the Cloudflare tunnel is
the only public exposure. If cloudflared is provably unavailable (found nowhere
and undownloadable) the tunnel cannot start, so stripping the recovery
credential would just lock the user out with no public URL ever served. Add
_tunnel_binary_confirmed_unavailable and, on --secure only, refuse the launch
with the credential preserved rather than strip. Wildcard --cloudflare binds
0.0.0.0 publicly regardless of the tunnel, so it still strips; any uncertainty
(helper not loadable) also still strips, since a possible credential leak
outweighs a recoverable lockout.

clear_bootstrap_password no longer claims it cleared the file's contents when
both unlink and truncate failed; it now reports the stale password is still on
disk and asks the user to remove it manually.

* Studio: fix cloudflared probe path and skip the bootstrap strip for a self-suppressing child

Two follow-ups to the --secure pre-exposure hardening:

The cloudflared availability probe loaded cloudflare_tunnel by file path but not
its backend deps: ensure_cloudflared() -> _cache_path() lazily imports
utils.paths.storage_roots, which only resolves when studio/backend is on
sys.path. From the outer CLI it is not, so the probe saw ensure_cloudflared()
return None (cache unresolvable) and wrongly treated the tunnel as unavailable,
refusing --secure even when cloudflared was cached or downloadable. Add the
backend dir to sys.path for the probe (and remove it after) so the cache path
resolves as it will in the child.

A headless --secure launch stripped the seeded .bootstrap_password before the
child proved the tunnel could actually connect, so a cloudflared that is present
but cannot establish the tunnel (blocked connectivity, Cloudflare outage) left
must_change_password=1 with no recovery credential. But the strip is only needed
when the re-exec'd child is an OLD studio-venv backend with no pre-bind
suppression: this install's own run.py sets app.state.suppress_bootstrap_injection
before binding and never serves the seeded credential publicly. Add
_child_self_suppresses (true in-process, or when the re-exec target is this
install's own run.py by path identity) and skip the strip in that case, keeping
.bootstrap_password as a local recovery credential; the strip stays fully in
force for the studio-venv console-script path and any venv-fallback run.py, where
an old child is actually possible.

* Studio: reword the pre-exposure terminal password prompt

* Studio: warn when -H is overridden by --secure; align pre-exposure prompt wording

- --secure/--secure run: emit a Note (not an error) when -H is a non-loopback
  host, since --secure forces the loopback bind and would otherwise discard -H
  silently.
- Reword the pre-exposure terminal prompt to 'exposed on the public internet'
  in both the backend gate and the CLI mirror.
- Align the CLI success line with the backend ("Password updated for '<user>'.").
- Tests for the new -H warning (present when overridden, absent on loopback).

* Studio: add non-interactive --password to set the initial admin password

Headless hosts (CI, containers, systemd units) have no TTY, so the forced
first-exposure password change could not be completed unattended. Add a
non-interactive way to set the INITIAL admin password before the server binds:

- --password <value>, the UNSLOTH_STUDIO_PASSWORD env var, or --password -
  (read one line from stdin). Off by default; unset falls back to the normal
  interactive terminal prompt / browser setup.
- Applies on any launch (public --secure/--cloudflare or a headless -H 0.0.0.0
  bind), only when the account still has its seeded bootstrap password. An
  already-set password is a hard error, never an override; an invalid value
  (too short, or equal to the bootstrap) fails closed before bind.
- The CLI applies the change in the parent, never forwards --password to the
  re-exec child, and strips UNSLOTH_STUDIO_PASSWORD from the child env so the
  secret never crosses to the child. run.py does the same on the direct path and
  strips the env var so spawned subprocesses (cloudflared, llama-server, tools)
  cannot inherit it.

Mirrors resolve_supplied_password across the CLI and backend, documents the
option in the README (including the argv-visibility caveat), and covers all
flows (env/stdin/literal, fail-closed cases, no-forward, env-strip,
reset-password roundtrip) in the CLI, backend, and unit suites.

* Studio: truncate the stale bootstrap file when unlink fails on a CLI password change

The post-change cleanup in _cli_update_password only warned when
.bootstrap_password could not be unlinked but was still writable (locked file,
read-only auth dir), leaving the old plaintext on disk. If auth.db is later
reset or removed, generate_bootstrap_password() reads that file back and
re-validates the revoked bootstrap password. Truncate the file on unlink
failure so its stale plaintext cannot be re-seeded, mirroring the backend
clear_bootstrap_password(); the password change is already committed, so this
never rolls it back. The warning now states truthfully whether the contents
were cleared or the file must be removed manually.

* Studio: tighten comments

---------

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-15 06:13:25 -07:00
Daniel Han
c570180a32
Tighten Studio instruction-file cleanup boundaries (#7097)
Some checks failed
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
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Mac Studio API CI / Studio 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 Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Waiting to run
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
* Handle linked instruction files in Bash cleanup

* Limit instruction cleanup to managed dependencies

* Make Bash cleanup test portable

* Run junction cleanup regression on Windows

* Keep instruction cleanup CI focused
2026-07-13 01:46:23 -07:00
Daniel Han
9e77c1e663
Studio: remove AGENTS.md and CLAUDE.md from install artifacts (#7096)
* Studio: remove AGENTS.md from install artifacts

* Studio: prune CLAUDE.md from install artifacts

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

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

* Fix Studio instruction cleanup edge cases

* Trim Studio cleanup comments

* Make Studio cleanup safe on PowerShell 5.1

* Fix Studio cleanup ownership boundaries

* Simplify Windows link detection

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-13 00:15:04 -07:00
oobabooga
fcb1152c76
Studio: source CPU llama.cpp prebuilts from unslothai/llama.cpp (#6311)
* Studio: source CPU llama.cpp prebuilts from the unslothai fork

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

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

* Studio: reject unknown Linux CPU arches and keep ROCm-tooling hosts off the CPU prebuilt

* Studio: extend the resolve-prebuilt ROCm-tooling guard to Windows

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

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

* Studio: let ROCm-SDK-only CPU hosts take the fork CPU prebuilt

* Studio: accept windows-arm64 prebuilt kind and refresh stale fork-routing comments

* Studio: correct stale fork-routing comments and --resolve-prebuilt help

* Refresh stale ggml-org routing comments

---------

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-08 05:34:59 -07:00
Leo Borcherding
73e8245ee8
[Studio] Add --with-llama-cpp-dir installer flag to reuse a local llama.cpp (#6472)
* Add --with-llama-cpp-dir flag to install.ps1 and install.sh

Users can now pass --with-llama-cpp-dir /path/to/llama.cpp to the
installer to skip downloading or building llama.cpp and use a local
directory instead. A junction (Windows) or symlink (Linux/macOS) is
created at the canonical install location, bypassing both the prebuilt
download (Phase 3) and source build (Phase 4) steps in setup.ps1/setup.sh.

The path is passed via UNSLOTH_LOCAL_LLAMA_CPP_DIR env var which
setup.ps1 and setup.sh read directly.

Ported from the idea in unslothai/unsloth#4384, reimplemented against
current Studio architecture.

* test: add static wiring test for --with-llama-cpp-dir flag

Cross-checks install.sh, install.ps1, studio/setup.sh and studio/setup.ps1
so the flag's contract (parse -> UNSLOTH_LOCAL_LLAMA_CPP_DIR env var -> link
local dir, skip prebuilt download and source build) can't silently regress.
Wired into studio-backend-ci.yml alongside the other tests/sh installer tests.

* Address review feedback on --with-llama-cpp-dir flag

- setup.ps1: delete an existing junction/symlink via DirectoryInfo.Delete()
  instead of a recursive remove, which can traverse the link and wipe the
  user's real llama.cpp directory on PowerShell 5.1.
- setup.ps1: short-circuit the build chain when a local dir is linked so CMake
  never runs inside the user's checkout when it lacks a Windows-layout binary.
- install.sh / setup.sh: resolve paths with CDPATH= cd -P so a set CDPATH
  cannot corrupt the resolved path.
- install.sh: seed _WITH_LLAMA_CPP_DIR from UNSLOTH_LOCAL_LLAMA_CPP_DIR so an
  exported env var (piped-install style) is honored instead of being clobbered.
- setup.sh: create the root llama-quantize shim when linking a local source
  build so GGUF export's check_llama_cpp() still finds it.
- setup.sh / setup.ps1: drop a stale link before the custom-home ownership
  assert so re-runs with the flag stay idempotent.
- test: pin the new linked-dir build short-circuit.

* Harden --with-llama-cpp-dir against Codex/Gemini review findings

- install.sh: error when --with-llama-cpp-dir is the final arg with no path,
  matching the existing --package/--python post-loop guards (was a silent
  fallback to the normal prebuilt/source install).
- studio/setup.sh: canonicalize LLAMA_CPP_DIR before the self-link no-op
  compare. _RESOLVED_LOCAL is fully resolved while LLAMA_CPP_DIR was textual,
  so a symlinked $HOME made the guard miss and the rm -rf could wipe the
  user's real llama.cpp tree.
- studio/setup.sh: make the llama-quantize shim non-fatal; it writes through
  the link into the user's tree, which may be read-only (shared/CI cache),
  and under set -e a failed ln aborted an otherwise-good reuse.
- studio/setup.ps1: detect a broken junction via Get-Item -Force instead of
  Test-Path so a dangling link from a prior run is removed and mklink can
  relink to a new valid directory.
- studio/setup.ps1: use Copy-Item -LiteralPath so a source path containing
  [ ] isn't treated as a wildcard in the junction copy fallback.
- tests: update the wiring assertions for the LiteralPath copy and the
  canonicalized compare.

* Validate/reuse local llama.cpp tree and guard the in-use case

Addresses the second Codex pass on the --with-llama-cpp-dir flag:

- Validate the linked tree before disabling installs (setup.sh + setup.ps1):
  reusing a local dir skips BOTH the prebuilt download and the source build,
  so the dir must already contain a runnable llama-server (build/bin on
  Linux/macOS, build\bin\Release\llama-server.exe on Windows). Bail out with a
  clear message instead of linking an unbuilt/wrong-platform checkout and
  leaving Studio with no usable binary.
- Treat a canonical-path target as already linked when it holds a build
  (setup.sh + setup.ps1): point the flag at ~/.unsloth/llama.cpp itself and an
  existing build is reused (skip prebuilt + source) rather than clobbered by
  the staged prebuilt installer (which uses os.replace()/replace). An empty
  canonical dir still falls through to the normal in-place install.
- Abort when an in-use llama.cpp can't be removed on Windows (setup.ps1):
  Remove-Item -ErrorAction SilentlyContinue can silently leave a locked tree
  in place; detect that and stop with the same active-process message + exit 3
  the prebuilt path uses, instead of junctioning over a half-present dir.

Left as follow-up (already tracked by the PR author as a non-blocker): the
in-app "Update llama.cpp" updater does not yet recognize a local-link install
as externally managed; that fix belongs in studio/backend/utils/llama_cpp_update.py.

* Accept all backend llama-server layouts in --with-llama-cpp-dir validation

The linked-tree validation only accepted build/bin[/Release]/llama-server, but
LlamaCppBackend._layout_candidates() resolves a root-level llama-server first,
then build/bin, then build/bin/Release on Windows. A `make` build or a flat
release extract (binary at the dir root) was therefore rejected with a hard
installer failure even though Studio would have run it.

Validate the same candidate set the backend uses in both setup scripts, and add
wiring-test assertions so the check can't silently narrow again.

* Treat --with-llama-cpp-dir local links as externally managed

A --with-llama-cpp-dir install junctions/symlinks the canonical llama.cpp dir to
the user's own checkout, but two backend paths still treated it as a Studio-owned
tree:

- The in-app updater (llama_cpp_update) offered and could apply an official
  prebuilt over the link, writing through it into the user's checkout (or
  failing) and silently dropping the link the flag created.
- Orphan cleanup (LlamaCppBackend._kill_orphaned_servers) resolved the linked
  root into its kill allowlist, so a llama-server the user launched from the same
  checkout was classified as ours and killed on startup.

Detect the canonical dir being a symlink/junction (reparse point) and treat the
install as unmanaged: get_update_status reports unsupported, start_update refuses
with reason "local_link", and the linked root is left out of the orphan
allowlist. Adds behavioral tests (link vs plain dir, updater refusal, and the
spared-vs-killed orphan control).

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

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

* Add behavioral shell test for --with-llama-cpp-dir linking

The existing tests/sh/test_with_llama_cpp_dir_flag.sh is a static grep of the
scripts. This adds a behavioral test that extracts the real link block from
studio/setup.sh (by content anchors, with a self-validating extraction) and runs
it against hermetic fake dirs, asserting the outcomes that matter:

- an external CMake build links and arms neither the prebuilt download nor the
  source build
- a flat / make tree (root-level llama-server, no build/bin) is accepted too
- an unbuilt tree is rejected with a non-zero exit and no link left behind
- relinking over a stale link preserves the target's contents (no data loss)
- pointing at the canonical path is a no-op reuse, not a self-referential link

Symlink-identity checks run only where real symlinks exist (skipped on Windows
git-bash copy-mode); the link/skip/no-data-loss checks run everywhere. Wired into
studio-backend-ci.yml next to the static test.

* Install psutil in backend CI so orphan-cleanup tests run

The new orphan-cleanup tests import psutil for the process scan, but the Backend
CI deps step installed studio.txt plus a fixed extras list that omits it, so the
two tests failed with ModuleNotFoundError. Add psutil to both backend pytest dep
steps (kept in shared shape), and guard the import with pytest.importorskip so a
minimal env without psutil skips these tests instead of erroring.

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-02 22:11:20 +01:00
Abdul Moiz
91f4ec7ba7
Studio: self-heal a pre-#6483-fix anyio>=4.14 stuck in existing installs (#6805)
Some checks are pending
Studio GGUF CI / Tool calling Tests (push) Waiting to run
Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio API CI / Studio 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 / Studio Updating Tests (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Waiting to run
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
* Studio: self-heal a pre-#6483-fix anyio>=4.14 stuck in existing installs

The <4.14 cap in constraints.txt/no-torch-runtime.txt only constrains new
anyio resolutions. An install made before that cap existed can already be
sitting on anyio 4.14+, and since it already satisfies mcp/fastmcp's
anyio>=4.5 floor, every later constrained install skips it as
already-satisfied -- so affected installs never recover and keep hitting
the cancel-scope RuntimeError on every request (#6797, a recurrence of
#6483). Force-reinstall anyio<4.14 whenever a stuck 4.14+ is detected.

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

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

* Studio: also repair anyio on the update fast path

setup.sh's _SKIP_PYTHON_DEPS and setup.ps1's $SkipPythonDeps skip
install_python_stack.py entirely once the installed package version already
matches PyPI latest, so an install stuck on anyio>=4.14 with an otherwise
up-to-date package never reaches the repair added in install_python_stack.py.
Probe anyio on that fast path too and fall through to the full dependency
pass when it's still >=4.14, mirroring the existing ROCm/CPU-torch override
right below it.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-02 15:49:40 +02:00
Daniel Han
54f25bf17e
Studio: UNSLOTH_NPM_REGISTRY opt-in for corporate npm mirrors (#6491) (#6663)
* Studio: UNSLOTH_NPM_REGISTRY opt-in for corporate npm mirrors (#6491)

studio/frontend/.npmrc pins registry=https://registry.npmjs.org/ as a
supply-chain lock. A project-level pin takes precedence over a user's
~/.npmrc, so behind a corporate firewall that blocks npmjs.org the
frontend bun/npm install hit npmjs.org directly and failed with 403.

Add an opt-in UNSLOTH_NPM_REGISTRY env var (off by default). When set it
is threaded as --registry into every registry-touching install in
setup.sh, setup.ps1 and build.sh (bun bootstrap, bun install + retry, npm
fallback, OXC validator runtime). --registry is the highest-precedence
override for both bun and npm and leaves min-release-age and save-exact
in force, so the default lock is unchanged for everyone else.

On an install failure that looks like a blocked registry, print guidance
pointing at UNSLOTH_NPM_REGISTRY and auto-suggest the mirror already set
in the user's npm config. Registries are never switched automatically.

Also correct the .npmrc comment: the pin does not block an ambient
NPM_CONFIG_REGISTRY env var (npm and bun honor that at higher precedence);
it only guards against a lower-precedence stale ~/.npmrc.

* Studio: make the registry hint reachable under set -e; clean temp log (#6491)

run_quiet_no_exit returns non-zero on failure, which under `set -euo
pipefail` exits the script at the call site before the exit code is
captured, so the new UNSLOTH_NPM_REGISTRY hint never printed on the npm
fallback and OXC validator paths. Guard both with `|| _rc=$?` (the same
idiom every other run_quiet_no_exit caller already uses) so the failure
branch runs, and remove the _FRONTEND_INSTALL_LOG temp file on the
early-exit path.

* Studio: detect the user's mirror outside the pinned frontend dir (#6491)

_suggest_npm_registry / Show-NpmRegistryHint run while the cwd is still
studio/frontend, whose .npmrc pins registry=https://registry.npmjs.org/.
So `npm config get registry` returned that pin instead of the user's
~/.npmrc mirror, and the "Detected a registry" branch was skipped for the
main corporate case (mirror set in ~/.npmrc). Run the lookup from a
directory with no project .npmrc (/ in bash, the temp dir in PowerShell)
so the user/global mirror is surfaced. The NPM_CONFIG_REGISTRY env check
is unchanged and still takes precedence.
2026-06-25 04:01:43 -07:00
Daniel Han
fad89aecf9
Clarify Studio --secure hint exposes a public Cloudflare tunnel (#6615)
The installer/setup launch hints described --secure as merely allowing
HTTPS. In practice --secure forces a loopback bind and opens a public
Cloudflare quick tunnel (https://*.trycloudflare.com) to Studio, which
serves Python/terminal tools by default, so the old wording understated
the exposure. Update the hint to say it is a public Cloudflare HTTPS link
and that anyone with the API key can run code, matching the runtime
secure-mode banner.
2026-06-24 03:48:32 -07:00
Lee Jackson
b458e1cf6d
Add HTTPS hint to Studio launch message (#6583) 2026-06-23 15:11:19 +02:00
Daniel Han
70926822db
studio/setup.sh: guard empty CUDA arch detection in the source build (#5854) (#6481)
* studio/setup.sh: guard empty CUDA arch detection in the source build

PR #5826 hardened setup.sh for fresh CUDA toolkits, but the source build
still set -DCMAKE_CUDA_ARCHITECTURES only when nvidia-smi reported a
compute capability. When that query returns nothing the build proceeded
with no explicit arch list, so llama.cpp built PTX only. On a driver older
than the toolkit that binary fails at runtime with "the provided PTX was
compiled with an unsupported toolchain" - the build succeeds, so neither
the build-time check nor the CPU fallback caught it (issue #5854).

Resolve the arch list before committing to a CUDA build. A new pure helper
_resolve_cuda_archs parses and de-duplicates the nvidia-smi compute_cap
output and honors an explicit UNSLOTH_LLAMA_CUDA_ARCHS override. When the
result is empty, build CPU llama.cpp instead of a PTX-only binary, with a
clear message pointing at the override - so the user still ends up with a
working llama-server. The override also lets advanced users force a native
build on hosts where nvidia-smi cannot report compute_cap.

No behavior change when an arch is detected: -DGGML_CUDA=ON plus the arch,
CUDA flags and NVCC_PREPEND_FLAGS are assembled exactly as before.

Adds tests/sh/test_resolve_cuda_archs.sh (single/multi/dedup/empty/garbage/
whitespace/override cases), wired into tests/run_all.sh and the
studio-backend-ci.yml shell-test loop.

* studio/setup.sh: resolve nvidia-smi via /usr/bin fallback for arch detection

Addresses review feedback on the empty-CUDA-arch guard: _setup_has_usable_nvidia_gpu
classifies a host as NVIDIA-usable using nvidia-smi on PATH OR /usr/bin/nvidia-smi,
but the new arch detection probed only `command -v nvidia-smi`. On a GPU host where
nvidia-smi is off PATH (reachable only at /usr/bin), arch detection returned empty
and the new empty-arch branch dropped the build to CPU, losing CUDA. Mirror the same
PATH-then-/usr/bin resolution so those hosts still get a native CUDA build.

Also scope _resolve_cuda_archs locals with `local` (no behavior change; it already
runs under command substitution).

* tests: update compute_cap-probe assertion for $_smi_bin resolution

The nvidia-smi /usr/bin fallback parameterized the binary in the compute_cap
probe (_setup_run_smi "$_smi_bin" ...), so the literal-string assertion in
test_compute_cap_probe_timeout_wrapped no longer matched. Assert the probe is
preceded by _setup_run_smi (timeout-wrapped) instead, scanning all occurrences
so the comment mention is ignored. Same intent, binary-agnostic.

* tests: ruff-format the compute_cap probe assertion (pre-commit)

Collapse the backslash-continued assert onto one line and normalize slice
spacing so the ruff-format pre-commit hook (0.6.9) is satisfied. Formatting
only; no behavior change.

* Tighten code comments (no logic change)

* studio(windows): build CPU when CUDA arch is undetectable (#5854)

The Windows source build added -DGGML_CUDA=ON unconditionally but only set
-DCMAKE_CUDA_ARCHITECTURES when $CudaArch was detected. With no detectable
compute capability that produced a PTX-only binary, the same hole the Linux
fix closed. Build CPU llama.cpp in that case, and honor UNSLOTH_LLAMA_CUDA_ARCHS
to force a CUDA build, matching setup.sh. Detected-arch builds are unchanged.

* test: anchor NVCC_PREPEND_FLAGS scope check on the final CPU branch

The undetectable-arch CPU fallback adds an earlier -DGGML_CUDA=OFF, so the
ordering check now anchors on -DGGML_CUDA=ON and the last -DGGML_CUDA=OFF
instead of the first.

---------

Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
2026-06-23 01:26:43 -07:00
Daniel Han
9f39cc2c39
Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm (#6533)
* Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm

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

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

* Studio: address Node isolation review (no-Node probe crash, PATH refresh, OXC provisioning, venv python, runtime node resolver)

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

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

* Fix/adjust Node isolation for PR #6533

* Studio Node: don't cache a negative node resolution; accept Node metadata in setup.sh ownership guard

- node_runtime: memoize only a version-adequate executable so a Node installed
  by a separate-process 'studio update' is picked up without a backend restart.
- setup.sh: _studio_owned_adoptable also accepts UNSLOTH_NODE_PREBUILT_INFO.json,
  matching the setup.ps1 Node ownership guard (custom-home parity).

* Studio setup.ps1: skip OXC npm install gracefully when npm is absent

Mirror setup.sh's `command -v npm` guard so a pip-installed Studio with no
system Node skips the OXC runtime install (validator degrades at runtime) instead
of exit 1 aborting the whole setup. Tighten test_node_probe_guard.ps1's probe
regex so it only matches the two system-version probes, not this new npm guard.

* Wire test_node_probe_guard.ps1 into Windows CI for PR #6533

* Harden isolated Node install and probes for PR #6533

- install_node_prebuilt.py: keep an existing, still-usable isolated Node
  when nodejs.org's dist index is unreachable instead of aborting the
  update on a transient outage (existing_install_usable + tolerant fetch).
- install_node_prebuilt.py: pin NPM_CONFIG_PREFIX/npm_config_prefix and
  drop NODE_PATH in _run_node so any npm -g stays inside the isolated
  prefix; Windows npm otherwise writes to %APPDATA%\npm.
- install_node_prebuilt.py: resolve tar hard-link targets against the
  archive root (symlink targets stay link-parent relative).
- setup.ps1: wrap the system node/npm probes in try/catch so a present
  but broken shim degrades to the bundled Node instead of aborting setup.
- setup.ps1: run the isolated Node install with the handed-off/venv Python
  (ReusedSetupPython); the main resolver runs later and bare python may be
  a Store stub this early.
- setup.sh: log when the OXC validator runtime is skipped for missing npm,
  matching setup.ps1.
- node_runtime.py: move the version-floor comment onto _version_meets_floor.
- Tests for the offline-reuse and broken-shim paths.

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

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

* Trim verbose comments across the Studio Node installer for PR #6533

Comments-only pass: collapse the multi-line section banners to single lines,
drop comments that restate obvious code, and tighten the remaining docstrings
and "why" notes without losing intent. No code changes (verified with an AST
comment-only check on the Python files and a non-comment-diff scan on setup.sh
and setup.ps1). Net 109 fewer lines; the install, decision, and probe-guard
suites stay green.

* Harden Node install from review: validated Python, version floor, legacy home, lock race

For PR #6533, addressing the latest review pass:

- setup.ps1: run the isolated Node install with the validated reused/venv Python.
  An incompatible reused interpreter (old venv, conda, stale UNSLOTH_SETUP_PYTHON)
  is no longer used; fall back to the resolved python instead.
- setup.ps1: a STUDIO_HOME/UNSLOTH_STUDIO_HOME override equal to the legacy default
  now uses the legacy sibling node dir (~/.unsloth/node), matching the runtime
  resolver and setup.sh, so OXC can find the Node it installed.
- install_node_prebuilt.py: reject an explicit --node-version below the floor
  (^20.19 || >=22.12 || >=23) instead of installing a Node the build cannot use.
- install_node_prebuilt.py: atomically rename a stale install lock before unlinking
  so two concurrent runs without filelock cannot both acquire it.

Tests added for the version floor (parametrized + explicit-below-floor rejection).
Full install suite: 937 passed, 1 skipped; setup.ps1 parses; decision tests green.

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

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

* Address latest review: armv7l + later-fetch offline reuse for PR #6533

- install_node_prebuilt.py: reject 32-bit ARM (armv7l) up front. Node 24 LTS
  ships no linux-armv7l build, so the old path failed late with a confusing
  "no sha256"; it now fails fast with a clear unsupported-architecture error.
- install_node_prebuilt.py: extend the offline-reuse fallback to the SHASUMS and
  archive fetches. If index.json resolves a newer Node but a later download fails
  and a usable isolated Node is already on disk, keep it instead of aborting a
  non-force update.

Tests added: armv7l/armhf are unsupported; a SHASUMS failure keeps an existing
usable Node and re-raises when none is present. Full install suite: 941 passed.

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

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

* Add UNSLOTH_STUDIO_HOME node-dir tests (install side + resolver) for PR #6533

* Add regression tests pinning the reuse path read-only and isolating installer writes

Lock in the two invariants behind the isolated-Node design: reusing a good
system Node never mutates the user's Node/npm, and the installer's own npm
calls only ever write inside its install_dir.

- tests/studio/install/test_install_node_prebuilt_logic.py: assert _run_node
  redirects NPM_CONFIG_PREFIX/npm_config_prefix into install_dir and drops an
  inherited NODE_PATH; assert _ensure_npm_floor scopes the npm self-upgrade to
  install_dir (never -g against the system) and is a no-op once npm meets the floor.
- tests/sh/test_system_node_readonly.sh (new, wired into studio-backend-ci.yml):
  the setup.sh NODE_SOURCE=system arm runs no global install and sets no
  NPM_CONFIG_PREFIX, with a positive control that the bundled arm does.
- tests/studio/test_node_decision.ps1: symmetric structural guard that the prefix
  pin and the only global install (bun) live in the bundled branch, not the system arm.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-21 21:17:29 -07:00
oobabooga
7ce8dc73ac
Studio: label Apple Silicon as Metal/unified memory instead of CPU-only in installers (#6470)
Some checks failed
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Studio GGUF CI / Tool calling Tests (push) Waiting to run
Studio GGUF CI / JSON, images (push) Waiting to run
Studio load-orchestrator CI / test (push) Waiting to run
Mac Studio API CI / Studio 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 / Studio Updating Tests (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
* Studio: label Apple Silicon as Metal/unified memory instead of CPU-only in installers

* Studio: drop redundant aarch64 check from the macOS GPU label detection
2026-06-19 07:16:16 -07:00
Daniel Han
375350e0b6
Make the Studio installers (sh + ps1) resilient to transient uv download failures (#6281)
* Make Studio installer resilient to transient uv download failures

Updating an existing Studio install via install.sh could hard-fail and roll
back when a wheel download (torch, unsloth) hit a transient connection reset:

  x Failed to download unsloth==2026.6.6
  error decoding response body -> error reading a body from connection
  -> connection reset
  restoring previous environment after failed install...

Root cause: that error chain is a mid-stream HTTP/2 body read failure. uv did
not retry this class until 0.8.16 (astral-sh/uv#15675, h2 was shadowing the
underlying IO error), but the installer pinned UV_MIN_VERSION=0.7.22, so a stale
uv got zero retries and a single blip aborted the whole update under set -e.

Fix (installer only, backwards compatible, no change on success):
- Raise UV_MIN_VERSION to 0.8.16 so stale uv is upgraded to a version that
  retries HTTP/2 streaming body errors.
- Export UV_HTTP_RETRIES=5 and UV_HTTP_TIMEOUT=180 (override-preserving :=).
- Add run_install_cmd_retry (retry-with-backoff around run_install_cmd) and use
  it for the network-heavy uv pip install steps (torch, unsloth, unsloth-zoo
  from git, ROCm torch repair, no-torch runtime deps). Local editable overlays
  and venv creation are left to fail fast.

run_install_cmd_retry preserves the final exit code on permanent failure, so the
existing set -e rollback trap still fires.

* Apply the same transient-download resilience to the Windows installer

install.ps1 is the native-Windows installer and had the identical issue as
install.sh: it pinned $UvMinVersion=0.7.22 (below uv 0.8.16, which is where uv
started retrying HTTP/2 streaming body errors), set no UV_HTTP_* defaults, and
ran each 'uv pip install' once via Invoke-InstallCommand, so a single connection
reset aborted the update and triggered the Exit-InstallFailure rollback.

install.ps1:
- Raise $UvMinVersion to 0.8.16.
- Default $env:UV_HTTP_RETRIES=5 and $env:UV_HTTP_TIMEOUT=180 (preserving overrides).
- Add Invoke-InstallCommandRetry and use it for the network-heavy uv pip install
  steps (torch, unsloth, unsloth-zoo from git, ROCm torch, no-torch runtime deps).
  Local editable overlays and venv creation stay single-shot.

install.sh:
- Align UNSLOTH_INSTALL_RETRIES sanitization with the PowerShell version: a
  non-positive-integer value now falls back to the default of 3 instead of
  silently disabling retries (set =1 to disable). Keeps both installers identical.

* Adopt pre-marker Studio llama.cpp and sidecar dirs on update

After the uv retry fix, an update now reaches studio/setup.sh, whose
Studio-owned ownership guard rejects a llama.cpp or sidecar venv created by an
earlier install that predates the .unsloth-studio-owned marker:

  ERROR: .../llama.cpp already exists and is not marked as a Studio-owned
         llama.cpp install.

The marker and UNSLOTH_PREBUILT_INFO.json were introduced in the same commit,
so a directory from before that point carries neither signal and a legitimate
self-update fails for anyone who installed earlier (reported on issue #6274).

Fold a one-time adoption into _assert_studio_owned_or_absent (setup.sh) and
Assert-StudioOwnedOrAbsent (setup.ps1): when a custom-home directory lacks the
marker, backfill it and proceed only when there is positive evidence it belongs
to an established Studio home -- the directory carries UNSLOTH_PREBUILT_INFO.json,
or STUDIO_HOME already holds Studio's CLI shim or studio.conf from a prior run.
Both installers write the shim and studio.conf only after invoking setup, so a
fresh install into a dirty custom home (the case the guard protects) does not
have them yet and is still rejected. The venv marker is excluded because install
writes it before setup and so cannot tell a prior install from a fresh one.

* Review fixes: restrict llama.cpp adoption to dir-local evidence; restore install.sh +x

Addresses the PR review on the marker-migration change.

P1 - the adoption helper keyed on root-level Studio sentinels ($STUDIO_HOME/bin
/unsloth, share/studio.conf), so once a home was recognized every unmarked child
passed to the guard became adoptable, and an unrelated directory at a
Studio-managed path could be silently marked and overwritten. Base adoption on
evidence inside the directory instead:
  - UNSLOTH_PREBUILT_INFO.json, written by the prebuilt llama.cpp installer (the
    default path, in place well before the marker), or
  - a top-level llama-quantize symlink, written by source builds (a plain
    llama.cpp checkout keeps the binary under build/bin, not a root symlink).
A foreign llama.cpp now stays rejected even inside an established Studio home,
and sidecar venvs (no such fingerprint) stay subject to the strict guard; their
marker has been written since the guard was introduced, so a real custom install
already carries it.

P2 - restore the executable bit on install.sh; a stray mode change to 100644
would break ./install.sh --local on Unix.

On Windows the prebuilt metadata is the signal; source builds are git checkouts
indistinguishable from a user clone, so they are left to the strict guard.

* Bound UNSLOTH_INSTALL_RETRIES / _DELAY before numeric use

An oversized all-digit override (e.g. a fat-fingered
"99999999999999999999") passed the digit-only validation and then reached the
numeric comparison: POSIX `[ -ge ]` errored with "Illegal number" mid-loop and
could spin instead of falling back, and PowerShell's `[int]` cast threw an
Int32 overflow under $ErrorActionPreference = "Stop" before any install ran.

Sanitize with a length guard + range check (sh) and [int]::TryParse with bounds
(ps1), so out-of-range or oversized values fall back to the default. Bounds:
1..100 retries, 0..3600s base delay.

* Studio installers: scope llama.cpp adoption to prebuilt metadata; reject leading-zero retry delay

setup.sh: drop the top-level llama-quantize symlink as an ownership-adoption signal, leaving UNSLOTH_PREBUILT_INFO.json as the sole fingerprint. The shared ownership guard runs immediately before a destructive replace / rm -rf, and a bare root llama-quantize symlink is user-creatable (a user can keep their own llama.cpp build with such a convenience symlink at a custom UNSLOTH_STUDIO_HOME), so the old check could adopt and then delete a user directory. This matches the Windows installer, which already keeps markerless source builds strict. Pre-marker prebuilt installs still adopt via the metadata file, so the original update fix is preserved.

install.sh: reject leading-zero values for UNSLOTH_INSTALL_RETRY_DELAY. A value like 08 or 09 passed the range check but then hit the backoff doubling $((_ricr_delay * 2)), where a non-octal leading zero is a fatal arithmetic error mid-retry. The 0?* pattern routes such values to the default; bare 0 stays valid.

* Tighten the comments added in this PR

* Condense the comments in this PR
2026-06-16 03:48:01 -07:00
Daniel Han
42eb241b50
Studio: resolve studio home before the llama-only setup split (#6289)
UNSLOTH_STUDIO_LLAMA_ONLY=1, the llama.cpp-only fast path used by 'unsloth studio update', skipped the base-install block where STUDIO_HOME, _STUDIO_HOME_IS_CUSTOM, _STUDIO_OWNED_MARKER and _assert_studio_owned_or_absent are defined. setup.sh runs under 'set -euo pipefail', so the llama.cpp section aborted with '_STUDIO_HOME_IS_CUSTOM: unbound variable' before installing anything.

Hoist the studio-home and ownership resolution above the llama-only guard so both paths share it. Full installs are unchanged: the values are computed earlier but identically.
2026-06-13 04:14:53 -07:00
oobabooga
5300c047b6
Installer: drop the lemonade ROCm fallback now the fork ships identical per-gfx prebuilts (#6225)
---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-06-12 11:53:26 -03:00
Daniel Han
90cb9499e8
Studio: serve DiffusionGemma with live in-place denoising and honest stats (#6250)
* Studio: serve DiffusionGemma GGUFs with the on-device visual decoder

* Studio: render the DiffusionGemma denoising canvas live in chat with honest stats

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

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

* Studio: harden DiffusionGemma runner resolution (Windows .exe, build/bin lookup, clear stale audio flag, safe PYTHONPATH, Linux-only pdeathsig)

* [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-06-12 05:48:06 -07:00
Daniel Han
2db9fad4b5
Installer: GPU detection follow-ups after #6174 (poisoned venv repair, llama.cpp routing, probe bounds) (#6183)
* Installer: harden GPU detection follow-ups after #6174

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

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

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

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

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

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

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

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

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

Move the policy into the helpers so every consumer agrees:

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

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

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

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

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

* Retrigger CI after PyPI 503 outage during the previous run

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-11 05:06:02 -07:00