Keep the regenerable caches inside the Studio root (#10597)

* Keep the regenerable caches inside the Studio root

Unsloth's own data already lives under one root, but the caches it creates
along the way do not. Torch inductor, Triton, matplotlib, numba and the CUDA
JIT cache all land in the home directory, and DataDesigner builds
~/.data-designer off Path.home() at import time. This pins the regenerable,
process-scoped ones in every mode.

Part of #8865, which asks for an install contained in a single directory. This
half needs no new flags and helps default installs too.

Added to the defaults dict in _setup_cache_env, all under the existing
cache_root(): TORCHINDUCTOR_CACHE_DIR (was /tmp/torchinductor_<user>),
TRITON_HOME and TRITON_CACHE_DIR (~/.triton), TORCH_EXTENSIONS_DIR
(~/.cache/torch_extensions), CUDA_CACHE_PATH (~/.nv/ComputeCache), MPLCONFIGDIR
(~/.config/matplotlib and ~/.cache/matplotlib), NUMBA_CACHE_DIR (~/.cache/numba),
and DATA_DESIGNER_HOME with DATA_DESIGNER_MANAGED_ASSETS_PATH (~/.data-designer).

DATA_DESIGNER_HOME is read at import time by data_designer's constants module,
so it has to be set before the Data Recipes worker imports the package. The
workers are spawned, so they copy this environment and need no change.

diffusion_compile_cache.py also stops hardcoding
~/.cache/unsloth/diffusion_compile_cache. It resolves through cache_root() per
call now, and still reads a bundle from the old path when one is there, so
nobody pays a cold compile for the move alone.

Fixes ~/unsloth_compiled_cache. unsloth train, inference and export import the
backend in-process and never seeded the cache environment, so they inherited
unsloth-zoo's bare relative UNSLOTH_COMPILE_LOCATION and resolved it against
whatever directory the shell was in. Running from the home directory left an
~/unsloth_compiled_cache that cache_cleanup then refused to remove, because
nothing marked it as ours. Seeding from ensure_studio_backend_path() covers
every command that reaches the backend, without making `unsloth --help` pay for
importing it.

What deliberately does not move: the Hugging Face hub cache stays at
~/.cache/huggingface. It was taken out of the Unsloth tree on purpose in #4591
so that models downloaded before Unsloth was installed, or shared with LM
Studio, Ollama and plain huggingface_hub, keep being found instead of
downloaded a second time. HF_HOME never moves in any mode either: it owns the
token path, and credentials should not follow a cache onto a removable volume.

* Tighten the comments this branch adds

* Correct the runtime-root docstrings and note the matplotlib version gap

* Install the managed runtimes at the master root and contain the modules cache

* Point the whisper.cpp builder at the master root and harden the legacy cache probe

* Normalize the master root the same way in both installer scripts

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

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

* Assert ownership of the runtime children a master root moves

* Remove the master root's runtime children on uninstall

* Keep a shared staging directory and stop locked master-root runtimes

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

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

* Remove only a file from an install-lock path in a master root

prebuilt_core.install_lock creates these locks with os.open(O_CREAT | O_EXCL) and writes a pid,
so an install lock is always a regular file. The master-root cleanup reached them through
_remove_path, which is rm -rf: in a user-chosen root a directory that happens to be named
.node.install.lock was deleted whole, with none of the owner-marker proof the runtime children
beside it require.

The names are fixed and nothing else routes here, so the shape test costs nothing. A symlink is
unlinked rather than followed, since -f is true for a link to a file and rm takes the link.

The test grew a case for each shape and now names every helper it extracts: an extraction that
stops matching went command-not-found inside a subshell the harness swallows, which reads as
"nothing was removed" and quietly passes the keep-assertions.

* Lift install.ps1's new master-root helper into the preflight harness

Two Windows checks in this file were failing on the branch.

Get-ManagedLlamaCppDir now calls Get-MasterRootOverride, which the harness's list of functions
to copy into the child did not name. A helper that is not lifted does not announce itself: the
child dies on the first call, every Write-Host after it is lost, and the two assertions read as
though the preflight had resolved the wrong directory. So the list is now checked against the
commands the lifted bodies actually call, and a missing one is reported by name.

The ownership guard's flag moved into a parameter so the runtime children beside studio\ under a
master root are covered even when the Studio home is the legacy one. The shape assertion still
names the old variable; it now names the new one, plus the line that defaults it back to the
script-level flag, which is what made the old assertion worth making.

* Do not pin the compiler caches into a root that contains a space

Four fixes, all reproduced against origin/main before and after.

1. torch.compile stopped working when the Studio root contains a space.

   torch/_inductor/cpp_builder.py joins its arguments with spaces, interpolates the output path
   unquoted, and reparses the result with shlex.split, so a cache directory containing a space
   splits mid-path:

       g++: fatal error: input file .../my is the same as output file

   Nothing here can fix that quoting, so this stops creating the situation: the five variables a
   C++ or CUDA toolchain receives on a command line are left unset when the path has whitespace,
   and Inductor goes back to its own whitespace-free temporary directory, which is what shipped
   before these were pinned. Everything else still moves. An explicit value from the caller is
   still honoured; only a default we invented is ours to withhold.

   Measured on origin/main and on this branch, same fixture: main compiles, the branch before
   this commit raises CppCompileError, the branch after it compiles. Not a rare shape --
   "C:\Users\First Last" is an ordinary Windows account name, so this is the DEFAULT root for a
   large share of installs.

2. A missing master root ended the whole uninstall under sh.

   uninstall.sh runs with set -e and a bare assignment takes the exit status of its command
   substitution, so the canonicalizing cd failed the run whenever the root was already gone: a
   second uninstall, or a portable install on a disconnected drive. Ordinary bash hides this by
   clearing errexit inside command substitution; dash, sh and bash --posix do not, and the
   advertised one-liner pipes into sh on Debian, Ubuntu and WSL. setup.sh already guarded the
   same call.

3. setup.ps1's Node ownership guard did not cover a master root.

   The guard and the owner marker both hung off $NodeOverride, which only the
   UNSLOTH_STUDIO_HOME branch sets. The master-root branch sets $NodeParent and leaves it null,
   so <master>\node reached the whole-directory os.replace() in install_node_prebuilt.py with no
   ownership evidence, and the tree the run then created stayed unmarked, which makes the
   uninstaller keep it as somebody else's. setup.sh had already moved both sites.

4. uninstall.ps1 resolved a relative master root differently from setup.

   [IO.Path]::GetFullPath anchors at [Environment]::CurrentDirectory, which PowerShell does not
   keep in step with its own location. After a Set-Location a relative UNSLOTH_HOME named one
   install to setup and another here, and the owner marker does not help: it spares trees that
   are not Unsloth's, and the wrongly resolved path is another Unsloth install. Resolved through
   the provider path now, as Get-CanonicalDir does.

Also: the PowerShell uninstaller gets uninstall.sh's install-lock rule, its stale-lock glob
keeps the leading dot the rename actually produces, and both uninstallers apply the rule to the
default root as well as a master one.

tests/sh/test_with_llama_cpp_dir_link_behavior.sh had been failing on this branch: it seeds the
variables the lifted block reads and never learned the runtime ownership flag, so the block died
on an unbound variable under set -u and printed nothing, which read as seven wrong answers
instead of one missing seed. Seeded, and it now says which it is.

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

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

* Stub git and cmake for the extracted whisper block, so its tests do not need a toolchain

Four of this branch's own tests failed on a runner without cmake:

    AssertionError: ERROR: cmake is required

They lift the root selection out of scripts/build_whisper_cpp.sh as shipped text, and that slice
carries the builder's `command -v` preflight along with it, so the block exits 1 before choosing
any directory and the tests fail for a reason unrelated to what they assert. They passed here
because cmake is at /usr/bin/cmake and the fixture hardcoded PATH=/usr/bin:/bin.

Stubbed rather than cut out of the slice: excising the preflight would mean the tests no longer
run the shipped text, which is the only reason to lift it instead of restating it.

Verified by running the extracted block directly under a PATH with no cmake, which reports the
preflight error and names no directory, and under the same PATH with the stubs prepended, which
resolves <root>/whisper.cpp.

* Fix six review findings about the master root

All six reproduce; each has a test that fails without the fix.

1. UNSLOTH_HOME=$HOME/.unsloth named the root a default install already uses, and the runtime
   paths did not move, but the ownership flag was set from the variable being present rather
   than from where the runtimes land. A legacy source-built ~/.unsloth/llama.cpp carries neither
   the owner marker nor prebuilt metadata, so the assertion rejected an update that used to
   reuse that build. Customness now comes from comparing the resolved root against the legacy
   one, canonicalised the same way so a symlinked $HOME does not compare unequal to itself.

2. setup.ps1's Node ownership guard and marker hung off $NodeOverride, which only the
   UNSLOTH_STUDIO_HOME branch sets, so <master>\node reached install_node_prebuilt.py's
   whole-directory os.replace() with no ownership evidence and the tree it created stayed
   unmarked. Both sites now consult the runtime ownership state, as llama.cpp and whisper.cpp
   already did.

3. _assert_studio_owned_or_absent tested -d, so a dangling symlink and a regular file both read
   as "nothing is here" and the caller rm -rf'd the path or replaced it. A dangling link is the
   ordinary case, its target volume merely unmounted, and following it later installs onto
   somebody's other disk. Every existing entry is now occupied unless it carries ownership, and
   the refusal says which shape it found.

4. The uninstaller's master-root child gate tested -e, which is false for a dangling symlink, so
   an unmarked one fell through to _remove_path, which treats -L as present and unlinks it.

5. A whitespace-only UNSLOTH_STUDIO_HOME or STUDIO_HOME is unset to every resolver, but a bare
   truthiness test called it present and suppressed the master-root branch. The path was then
   discarded, so an uninstall carrying that environment removed the master root's runtime
   siblings and left <UNSLOTH_HOME>/studio installed. Both uninstallers trim first.

6. setup.ps1 persists TORCHINDUCTOR_CACHE_DIR to the USER environment, so every later Studio
   process inherits it and _setup_cache_env's fill-if-unset default never applied on Windows:
   the containment this branch is for did not happen there, and with long paths off the cache
   sat at C:\tc even in portable mode. The persisted value now names what the resolver would
   have chosen. Long paths off still keeps the short drive-root directory for MAX_PATH headroom,
   and a path containing a space is refused for the same reason storage_roots refuses one.

Two more, from the same round:

The uninstaller could not find a master root that was named for a single command:
`UNSLOTH_HOME=/mnt/portable unsloth studio update` installs the runtimes there and leaves
nothing behind, so a later uninstall removed the Studio tree and stranded them. setup.sh now
records the root in the Studio tree and the uninstaller reads it. The deny list and the marker
gate still apply to whatever the note names, so it cannot license a removal the environment
could not.

install.sh and install.ps1 do not read UNSLOTH_HOME yet, so a machine with it merely exported
had the CLI resolve <master>/studio while the installers put everything at the legacy root, and
`unsloth studio` reported that Studio was not set up. The master root now wins only when it has
an install; otherwise the one that exists does. When the installers learn the flag this fallback
stops being reachable.

test_setup_staged_root.sh matched the old spelling of the branch that chooses the compiler cache
rather than what it does. It now checks that a staged run takes the stage root and is asked
first, which is what that assertion was for.

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

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

* Record the Windows master root, and clear the cache path uninstall leaves behind

setup.sh writes a .unsloth-master-root note in the Studio tree and uninstall.sh
reads it, so an uninstall run without UNSLOTH_HOME still finds the runtimes that
`UNSLOTH_HOME=... unsloth studio update` installed under a root named for one
command. setup.ps1 never wrote that note and uninstall.ps1's _MasterRoot consulted
only the current environment, so on Windows the same run removed <master>\studio
and stranded node, llama.cpp and whisper.cpp beside it. Both halves are here now,
matching the POSIX rules: only a master root is noted, never a staging one, the
note is staged then renamed, and the first line alone is read.

setup.ps1 also persists TORCHINDUCTOR_CACHE_DIR to the User environment, and this
branch made that path install-specific. The uninstaller deleted the tree and left
the variable, so every later PyTorch process on the account, including ones with
nothing to do with Unsloth, compiled into the removed directory and rebuilt part
of it. It is cleared when it names a root this run owned. A directory the user
chose is theirs, and the shared C:\\tc fallback is not install-specific and is not
deleted here either.

* Two ways the diffusion migration raised instead of missing

Both are pre-existing weaknesses this branch made reachable, so it should carry
the fix.

json.loads returns [] for "[]" and None for "null", and manifest.get() on either
raises AttributeError out of a function whose entire contract is that a bad cache
entry is a miss. The manifest being validated now comes from the pre-relocation
root, written by an older build on a disk this run has never checked.

Path.exists() raises rather than answering False when a parent denies traversal.
The write root moved, so it can land on a directory the process does not own, and
the unguarded probe left begin() before the legacy fallback could run: exactly the
fallback the relocation depends on for a warm start after an upgrade.

* Keep a matplotlibrc written under the managed root

The legacy probe re-runs on every launch, so on its own it hands a matplotlibrc
written under the managed MPLCONFIGDIR to a ~/.config/matplotlib created later by
some other tool. Measured across four launches: figure.dpi 177 from the managed
config, 222 once a legacy rc appeared, 177 again when it was removed, with the
managed rc present throughout. A style that changes on a later launch and changes
back is worse than either choice made once.

_data_designer_defaults already applies exactly this rule to its own home, and the
pre-existing rule is unchanged: an empty managed directory still defers, since
_setup_cache_env creates it on the first launch and existence alone would drop a
real user matplotlibrc.

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

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

* Stop the workspace guard pinning text the master-root fix moved

Two stale assertions, both caught by a full local run rather than by CI.

The setup.sh half sliced a fixed 600 characters from _assert_studio_owned_or_absent.
Adding the dangling-symlink check pushed the second assertion past the window, so
the test failed while the guard it is about was intact. The PowerShell half beside
it already slices to the end of the function and says why; the sh half now does too.

The other assertion named the exact line `[ -z "$_MASTER_ROOT" ] || _RUNTIME_ROOT_IS_CUSTOM=true`,
which is no longer the rule: UNSLOTH_HOME can name ~/.unsloth, the root a default
install already uses, and treating that as custom made an update reject a legacy
source-built llama.cpp carrying no owner marker. tests/test_setup_master_root.py
runs the shipped derivation for that case, so this one holds the two halves being
present rather than their wording.

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

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

* Four more ways a generated path could be published or taken wrongly

A blank inherited toolchain variable is now dropped rather than left blank when
the generated default is refused for whitespace. Inductor distinguishes an absent
TORCHINDUCTOR_CACHE_DIR from a present one, so "   " became a relative compiler
path and was then split by the very unquoted command construction the refusal
exists to avoid. "Blank counts as unset" now holds on a spaced root too.

A generated toolchain path that could not be made is withheld instead of exported.
The placement error is swallowed on purpose so startup survives, which is what let
a regular file at <studio>/cache/torchinductor, or a parent with a restrictive
ACL, stay published: torch treats the value as authoritative and fails every
compile, where an unset variable would have used its own temporary cache. Only
these keys. UNSLOTH_COMPILE_LOCATION unset falls back to a bare relative name
resolving against the CWD, which is the bug that pin exists to fix.

setup.ps1's Node guard asked -PathType Container, so a regular file or a symlink
named node under a user-selected master root was invisible to it and then
displaced by _swap_into_place. Anything at the path counts as occupied now, and a
non-directory is refused outright since ownership evidence lives inside one.

Neither uninstaller will take a Studio root that is also the master root. The
resolver accepts that flat layout, and a Studio root is removed whole once it
carries the ownership marker, so anything else kept in that directory went with
the install. Every other master-root child is individually marker-gated for
exactly that reason; this was the one hole in the rule. Kept rather than pruned:
data left behind is recoverable and printed, a deleted file is not.

* A link at managed-assets is use, not the empty layout we create

_setup_cache_env only ever makes a plain directory there, so a symlink or a
Windows junction is the user sending their assets to another disk. entry.is_dir()
follows it, and an empty target therefore read as the untouched layout Unsloth
creates: with a standalone ~/.data-designer also present, DATA_DESIGNER_HOME was
dropped and the redirect went with it.

os.path.isjunction is 3.12, and the Studio venv can be 3.11, so its absence means
there are no junctions to worry about rather than an error.

* Four ways the master-root cleanup could miss its target or take someone else's

The master root is resolved once, before anything is deleted. _master_root can
read its answer from a note INSIDE a Studio tree, and the custom-root loop removes
that tree, so asking again afterwards returned nothing and stranded the marked
llama.cpp, Node and whisper.cpp siblings: the exact failure the note was added to
prevent, reintroduced by asking too late. Windows already computed the value early
for the stop pass and then re-probed; it reuses it now.

The note probe trims the Studio overrides, as _custom_studio_roots and setup.sh
both do. UNSLOTH_STUDIO_HOME="  /mnt/studio  " puts the note at /mnt/studio/share,
and appending to the padded value looked somewhere that does not exist, so the
note was never found while the loop still removed /mnt/studio.

The stale-lock sweep matches the installer's own shape rather than any dotted
name. prebuilt_core.py leaves <component>.install.lock.stale.<pid>; the glob also
matched a user's .backup.install.lock.stale.copy, and in a root they chose that
file is theirs. Both sweeps, both scripts.

And _nothing_at looks at the link before following it. os.scandir follows a
symlink or junction at stylelib, so one pointing at an empty directory, or a
dangling one whose volume is not mounted, read as an empty directory and let
MPLCONFIGDIR move away from a redirect the user created. The Data Designer probe
already checked the link itself.

* Refuse a non-directory at a managed runtime path on Windows

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

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

* Keep the CLI, the backend and both setup halves on one master root

Four defects found by simulating the branch rather than reading it.

The CLI keeps an installed legacy root when UNSLOTH_HOME names a directory with
no install in it, because install.sh and install.ps1 do not read the variable
yet. That fallback is not custom, and _ensure_studio_env_exported returned early
on non-custom, so nothing was exported at all while storage_roots.studio_root()
went on honouring UNSLOTH_HOME with no install check. The CLI ran the legacy venv
and the backend under it wrote studio.db, auth and the pid file to
<master>/studio. The root is now exported without being marked custom, so the
ownership guards are untouched.

setup.ps1 derived the runtime ownership flag from whether UNSLOTH_HOME was set,
where setup.sh derives it from where the runtimes land. UNSLOTH_HOME pointed at
the legacy root moves nothing, and calling it custom there demanded an owner
marker from a markerless source-built .unsloth\\llama.cpp that predates the
marker, so Assert-StudioOwnedOrAbsent exited an update the shell half adopts.

uninstall.ps1 removed the master root and its .staging only when empty, but
asked with Get-ChildItem -ErrorAction SilentlyContinue and then called
_RemovePath, which is recursive: a swallowed enumeration error read as an empty
directory, and a file arriving between the check and the delete went with it.
Directory.Delete(path, false) is one call that refuses a non-empty directory,
which is what uninstall.sh's rmdir does and what this file's own TEMP prune
already does.

The bash and PowerShell harnesses in test_pr10648_offline_and_status.py stand in
for the setup section that derives these flags, and the guards inside the block
they extract now read the runtime flag, so both harnesses have to define it.
Nine tests there failed under set -u with 'unbound variable'; the file passes 102
on the merge base and 102 here.

* Hold the preflight lift check to install.ps1's own helpers

The check resolved every command the lifted bodies call, so main's new
Get-SecuritySoftwareNote, which reaches Get-CimInstance and Get-MpPreference,
read as two unlifted helpers on a Linux runner and as nothing on Windows.
Ask install.ps1 whether it defines the name instead: a helper the lift leaves
behind is still caught, on either platform.

* Stage the master-root note under an unpredictable name

The note setup writes so the uninstaller can find a root that only one
command named was staged at "<share>/.unsloth-master-root.$$" and written
with a shell redirection. Anyone who can write share/ could precreate that
name as a symlink and have setup truncate whatever it points at, which is
the shape that matters here: a master root is normally a volume, and a
volume is the thing two accounts share. setup.sh states the rule for the uv
cache probe 2400 lines earlier, "mktemp (O_EXCL) not $$"; the note follows
it now, and setup.ps1 opens its staging file CreateNew under a random name
rather than writing through WriteAllText. The rename already replaced a link
at the final path rather than following it, so only the staging name was open.

tests/test_setup_master_root.py plants the link from the same shell that then
runs the shipped block, so the guessed pid matches the way an attacker's
would. The PowerShell half is held structurally: the Linux runners have no
Windows filesystem to plant on.

* Treat a whitespace-only Studio home as unset when exporting it

_resolve_studio_home strips before deciding, and so does storage_roots, so
"   " means unset to both. The export guard read the raw value, where "   "
is truthy, and left it in place. With a master root exported and an install
at the legacy root, that split the two: this process ran the legacy venv
while the backend under it resolved <UNSLOTH_HOME>/studio and wrote
studio.db, auth and the pid file there. Blank was already covered; whitespace
was the shape that got through. uninstall.ps1 had the same bug and this
branch already fixed it there.

UNSLOTH_LLAMA_CPP_PATH gets the same treatment: llama_cpp.py's discovery
takes a whitespace value as a directory to scan, so leaving it costs the
master root's build and hands the scan a path that cannot exist.

* Migrate the legacy diffusion bundle through a temp file

Every other write in this module goes to a temp file in the destination
directory and is renamed into place, because a save runs on a daemon thread
that interpreter exit can kill anywhere. The migration out of the legacy root
used shutil.copyfile straight onto the live names, so an interrupted copy
published a partial manifest and a second backend migrating the same key
interleaved its writes into one destination file. A torn manifest reads as a
miss, which costs exactly the cold compile the migration exists to avoid.

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

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

* Point the matplotlib containment tests at each platform's config dir

The helper hardcoded _get_config_or_cache_dir's Linux branch, so on a mac
runner these nine tests wrote the rc into ~/.config/matplotlib, which macOS
matplotlib never reads. The guard correctly found nothing to strand, pinned
MPLCONFIGDIR, and the assertions failed for a reason that was in the tests
rather than in the code they cover; test_matplotlib_reads_the_config_the_pin
_would_have_hidden proved it by watching real matplotlib fall back to its
built-in matplotlibrc. The derivation is held against real matplotlib on each
spoofed platform by the two tests above it, so it cannot drift into agreeing
with storage_roots.py alone.

* Give the master-root probes an environment Python can start in

_resolve and _discover build the child's environment from scratch, and the
list left out what the interpreter itself needs: Windows python exits 1
before running a line when SYSTEMROOT is absent. check = True then raised
CalledProcessError with the child's stderr discarded, so all nine tests
reported as though the resolver had answered wrongly on every Windows
runner. The environment now carries SYSTEMROOT and its neighbours, and a
non-zero exit asserts with the child's stderr in the message, since a dead
interpreter passing for a wrong answer is the failure that hid this.

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

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

* Write the llama-server fixture under the name this platform looks for

_find_llama_server_binary appends .exe on Windows, so the fixture's POSIX-only
name meant discovery correctly found nothing and the three discovery tests
failed on a Windows runner for a reason that was in the fixture.

* Skip the setup.sh blocks on Windows rather than run them under Git Bash

Sixteen tests here execute blocks of the POSIX installer and were guarded by
"is bash on PATH". Git Bash puts it there on a Windows runner, so they ran
against Windows path semantics and UTF-16 output and failed sixteen times
about a script that platform never uses: `assert '\x00' == 'ALLOWED'` is the
shape of it. setup.ps1 is held separately in this file and under tests/studio,
so the guard now asks for bash on a POSIX filesystem and the Windows half of a
cross-platform run reports only what it really covers.

* Probe the cache dirs for writability, keep linked lock paths, harden the Node guard

Three defects found in review.

storage_roots._usable_dir accepted any existing directory. The mkdir above it
passes exist_ok = False and swallows every OSError, so a directory that exists
but cannot be written to arrived here having proven nothing, and the value was
published anyway. torch treats these variables as authoritative: cache_dir()
and triton_cache_dir() fall back to their own temporary directory only when the
variable is UNSET, then call os.makedirs(exist_ok = True), which succeeds on an
existing read-only directory and leaves every later compile write to fail
(torch/_inductor/runtime/cache_dir_utils.py). Probe with a real create via
tempfile.mkstemp and unlink it, so an unwritable directory is withheld and torch
keeps its own fallback.

scripts/uninstall.sh removed a symlink sitting at an install-lock path, so a lock
name planted as a link to something outside the install took that link with it.
uninstall.ps1 already excludes reparse points at the same names. Keep a link, and
keep any other non-file, reporting both.

studio/setup.ps1 probed the Node directory with Test-Path -PathType Container.
Under Windows PowerShell 5.1 that answers for the link TARGET, so a dangling
`node` link read as absent and install_node_prebuilt.py then installed straight
through it. Probe with Get-Item -Force, as Assert-StudioOwnedOrAbsent already
does, and treat a reparse point as not-a-directory however it resolves.

Tests: a read-only managed cache is withheld while NUMBA_CACHE_DIR stays pinned,
the write probe leaves nothing behind, a linked and a dangling-linked lock are
both kept, and the two Node-guard structure tests now hold the stronger
invariant. The sh suite's assert_link uses -L so a dangling link does not read
as gone.

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

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

* Recover the master root from the note setup already writes

UNSLOTH_HOME is documented as settable for a single command, `UNSLOTH_HOME=/mnt/portable unsloth
studio update`, and that command installs node, llama.cpp and whisper.cpp as SIBLINGS of studio/
under the root it names. Nothing persisted the variable, so once the command's environment went
away unsloth_home() returned None and all three resolvers looked somewhere else. Measured against
a real master-root tree:

  later launch, bare                       node -> ~/.unsloth/node                 (absent)
  later launch, UNSLOTH_STUDIO_HOME set    node -> <master>/studio/node            (absent)
  what setup installed                     node -> <master>/node

The second row is the one that matters: even with the studio root known correctly, the runtimes
resolved to its studio/ CHILD while the installer had put them beside it. whisper.cpp and the
llama.cpp path pinned into every worker follow the same call.

setup.sh and setup.ps1 already record the root at share/.unsloth-master-root, for the
uninstallers. Read it here too, so the process agrees with the install on disk. One fallback in
unsloth_home() covers all three resolvers, since each of them keys on it.

Two guards keep a note from doing harm. The recorded root must exist, and its studio/ child must
be the very directory the note was read from, so a note left behind by a tree that has since
moved, or copied into an unrelated install, is ignored rather than adopted for caches and
runtimes both. The candidate studio roots are derived without calling studio_root(), which asks
unsloth_home() where the studio tree is and would recurse.

Tests: the recorded root outlives the command that set it, a moved root and a copied note are
both ignored, an empty note is not a root, and a default install still reads none. Each one fails
with the fallback removed or a guard weakened.

* Read the master-root note from this install's Studio root only

Two installs on one box, one legacy and one named by UNSLOTH_STUDIO_HOME. Every note reader
walked a list and took the first readable note, with the legacy tree first, so a note belonging
to the OTHER install won while the rest of the run stayed on the named one.

In storage_roots that split the process against itself: studio_root() returned the named tree
while unsloth_home() returned the other install's master root, sending node, whisper.cpp,
llama.cpp, the portable caches and orphan cleanup into a tree Studio was not running from. In
both uninstallers it was worse than wrong, because a master root licenses removals: the run
deleted the named Studio and then followed the other install's root and took its marker-owned
runtime children with it.

All three now consult exactly one Studio root, the one studio_root() itself would answer with
the master root out of the picture: UNSLOTH_STUDIO_HOME, then STUDIO_HOME, then the legacy tree,
and no falling through from one to the next. An explicit override names that exact directory, so
its absence of a note is an answer, not a reason to keep looking.

Also moves this file's three pwsh call sites onto run_pwsh, which tests/studio/
test_pwsh_calls_use_the_shared_runner.py requires: a direct spawn shares one startup cache with
every other xdist worker and dies with `Stack overflow.` at a few percent per process.

Tests: an unrelated install's note is not adopted and the named root's own note still is, in
Python, in sh and in PowerShell, each running the shipped function. Restoring the legacy-first
order fails all three.

* Require a master-root note to describe the tree it was found in

A Studio tree copied from master root A to B carries a note still naming A. Every reader took
that at face value, and in the uninstallers it licenses removals: A's llama.cpp, node,
whisper.cpp and sd.cpp carry exactly the owner markers B's would, so the marker gates cannot
tell them apart and uninstalling the copy deleted the ORIGINAL install's runtimes.

All three readers now require the Studio directory a note was read from to lie INSIDE the root
it names. Containment rather than an exact <root>/studio match, including in storage_roots,
which had the stricter rule: the flat layout, UNSLOTH_HOME and UNSLOTH_STUDIO_HOME naming one
directory, is supported and an exact match refuses it. A Studio root deliberately placed outside
its master root is not recovered from a note, which storage_roots already calls not
self-contained and warns about on every resolve. An explicit UNSLOTH_HOME skips the check, being
the user speaking rather than a file found on disk.

Both uninstallers also read the default-mode studio.conf when no override names a Studio root.
A master-root install puts Studio at <master>/studio, so with a bare environment that conf is
the only thing naming the directory the note lives in, and the legacy path could never satisfy
containment. Read inline rather than through _custom_studio_roots / _CustomStudioRoots, which
call back into this function.

unsloth_cli exports the recovered root rather than only reading it. setup runs as a subprocess,
so an update with the root in a note but not the environment refreshed the runtimes at
<master>/studio/ while the backend, which does read the note, kept launching the stale ones at
<master>/. The CLI keeps its own aligned copy of the reader, as it does for studio_root();
tests/test_unsloth_home_root_agreement.py is what holds the two together.

Tests: a copied note is refused in Python, in sh and in PowerShell, a flat recorded root is
still honoured, and the CLI both recovers a real note and refuses a carried-in one. Four sh
fixtures described a master root with no Studio tree under it, which cannot occur; they now
describe the real layout. Every case fails with its guard removed.

* Give the CLI the same note validation as the backend

Relaxing the note check to containment in storage_roots and both uninstallers left the CLI's
copy on the stricter <master>/studio match. UNSLOTH_HOME=/root with
UNSLOTH_STUDIO_HOME=/root/custom/studio is a real pairing: setup records /root, the Studio
directory is inside it, and it is not the default child. The backend accepts that note and the
CLI declined it, so a later `unsloth studio update` passed setup no master root at all and
refreshed the runtimes under the Studio directory while the backend went on loading the ones at
/root. That is the same split this recovery exists to close, reopened one level up.

Test: the CLI and the backend agree on a nested Studio root, in
tests/test_unsloth_home_root_agreement.py. It fails with the strict rule restored.

* Only record a master root a reader will honour

Two shapes of the note were being written and then refused by every reader, so
the feature recorded nothing while the file on disk said otherwise. Both were
found by running the shipped setup.sh block against a fixture and handing the
result to the real resolver, not by reading.

Containment. setup.sh writes the note at $STUDIO_HOME/share, and STUDIO_HOME is
derived only from UNSLOTH_STUDIO_HOME/STUDIO_HOME, never from UNSLOTH_HOME.
Every reader requires the Studio directory to lie inside the root the note
names. install.sh does not read UNSLOTH_HOME yet, so the documented case,
UNSLOTH_HOME=/mnt/portable unsloth studio update on an ordinary install, leaves
Studio at ~/.unsloth/studio and puts llama.cpp, node and whisper.cpp at
/mnt/portable, which is exactly the shape all four readers reject. The note is
now skipped there and the stranding is reported with the two ways out, instead
of being recorded in a file nobody reads.

The legacy default root. UNSLOTH_HOME=$HOME/.unsloth names the directory the
install is already in. Both uninstallers refuse that value outright, but
storage_roots honoured it, and honouring it made portable_mode() true on every
later bare launch: HF_HUB_CACHE moved off ~/.cache/huggingface, which is the one
cache this branch promises not to move, and every model already downloaded went
missing. One command that named the default root converted the install
permanently, with nothing set at the time. It is no longer recorded, and a note
an earlier build left carrying it is cleared on the next run, since the writer
only runs when UNSLOTH_HOME is set again and the user who hit this set it once.

Also in storage_roots:

- A note read that fails for a reason other than absence is no longer cached.
  _recorded_master_root memoises per studio path and the backend is long-lived,
  so an EACCES on share/ or an EIO off a mount that came back pinned that
  process to the legacy runtime paths for its whole lifetime, silently, with the
  trees it should have found beside it. Nothing in production calls
  forget_recorded_master_root. A genuine miss is still cached.

- _usable_dir guards os.close like it already guards the unlink. Every other
  failure there is a False; an OSError escaping took _setup_cache_env, and the
  backend start that called it, down with it.

- The UNSLOTH_PORTABLE warning no longer says 0/false/off/no will leave portable
  mode off. An off value is not a veto: a master root is what makes an install
  portable, as the comment three lines below already said.

Tests: five new cases over the gate, the legacy-root sweep, the cached-note
rule and the close guard, plus the setup.ps1 halves held structurally. 205 in
test_setup_cache_env_containment and the cache suites, 356 across the master
root, root agreement, workspace guard and system32 guard suites, 6710 in
tests/studio/install, and all five tests/sh scripts pass; tests/studio/install
matches the merge base exactly.

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

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

* Prove a studio.db is absent before skipping the read, on Windows too

The skip added here reads os.stat(studio_db_path()) and treats FileNotFoundError
as "no database, nothing to read". On POSIX that is sound: a parent component
that is a file raises NotADirectoryError and one that cannot be traversed raises
PermissionError, each its own type. Windows collapses all of them into
ERROR_PATH_NOT_FOUND, which Python surfaces as FileNotFoundError, so there the
exception means "absent, OR something above it is not a directory, OR I could
not look" and the guard took the first reading. The result was the cache home
chosen in Settings being discarded on Windows and nowhere else.

This is the branch's own new test failing, not a theory:
test_uninspectable_studio_db_keeps_the_stored_cache_home[not_a_directory] is
green on Linux and macOS and red on windows-latest. Measured as a differential
on owned staging runners, base against head, same sixteen files, same three
operating systems: ubuntu-latest has no real failure on either arm, macos-15 has
the same five on both, and windows-latest has 82 on the base and 83 on the head.
The one extra is that test. Nothing else the branch touches moved on any of the
three.

_absence_is_real answers the question the skip actually needs: the nearest
ancestor that can be stat'ed decides. A directory means the tree is real and the
file genuinely is not in it. Anything else means a component is a file or a
reparse point, so the path could never have existed and the caller has learned
nothing. An ancestor that cannot be inspected at all is not proof either way.
Running out of ancestors means nothing on the path exists, which is the machine
that has never opened Studio, and the skip has to hold there or the read builds
the 250 KB database it was added to avoid. Checking only the immediate parent is
not enough: account_path puts studio.db several levels under the Studio home, so
the non-directory is usually further up.

Held by a test that reproduces the Windows error shape rather than the platform,
since the Linux runners cannot raise it, plus its opposite, an absent Studio
home that must still skip. The first fails on the code as it was and passes
after, so it measures the fix rather than restating it.

207 pass in the cache suites, 365 with 1 skipped across the master root, root
agreement, workspace guard and system32 guard suites, 6754 with 32 skipped in
tests/studio/install, 2 in the CLI export suite.

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

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

* Drop the module-level errno import the probe does not need

errno is used only inside _WINDOWS_SHAPED_GUARD_PROBE, which is a string run in
a subprocess and imports it itself. The hoisted import was unused at module
level and the source lint blocks on exactly that.

* Decline a recorded legacy default root in the reader, not only the writer

Found by enumerating the resolver's input space rather than reading it: 512
cells over UNSLOTH_STUDIO_HOME x STUDIO_HOME x UNSLOTH_HOME x UNSLOTH_PORTABLE x
the recorded note, base against head, one subprocess per cell. In 12 of them the
Hugging Face hub cache still moved off ~/.cache/huggingface with nothing set in
the process at all.

The write side was already gated: setup.sh and setup.ps1 refuse to record
$HOME/.unsloth and sweep an old note away. But that only fires when setup runs
again, and the user who hit this set UNSLOTH_HOME once, for one command. Until
the reader declines it too the repair never reaches them, and both uninstallers
were meanwhile refusing the same value outright, so the four readers the branch
claims to keep aligned were not.

_is_legacy_default_root declines it in storage_roots, and the CLI copy gets the
identical rule, which is what test_unsloth_home_root_agreement.py holds. Recorded
roots only: unsloth_home() returns an explicit UNSLOTH_HOME before it ever reads
the note, and narrowing that would change what the variable means rather than
what a stale file is allowed to claim.

Re-run over the same 512 cells: root resolution 480 identical and 32 changed,
which is the new UNSLOTH_HOME feature, with 0 violations; HF_HOME moves in 0
cells; of the 192 cells with no UNSLOTH_HOME and no UNSLOTH_PORTABLE, the caches
move in 30 and all 30 carry a real recorded master root. Legacy-root notes: 12
before, 0 now. Foreign notes: 0 throughout.

Also closes a gap in this file's own tests. Reverting the production hunks and
keeping the tests left 25 of 113 containment tests green, because each asserts
only that some variable is ABSENT, which is equally true when no pinning code
exists: assertions, not negative controls. Three new cases pair each decline with
the pins that must still happen in the same run, so the family fails when the
resolver does nothing. All five new tests fail with the production hunks reverted
and pass here.

119 pass in the containment suite, 147 across it plus the root agreement and
managed tools suites.

* Refuse a multi-line master-root note in both uninstallers

The comment beside each read said "One line, first only: a note that grew a
second line is not one we wrote", and neither did that. uninstall.sh took
`head -n 1` and carried on; uninstall.ps1 took `Get-Content -TotalCount 1`. The
second line was never looked for.

That is the four readers disagreeing in the one place it costs something.
storage_roots.py and unsloth_cli/commands/studio.py both read_text().strip() the
WHOLE file, so a two-line note is not a directory to them and they decline it.
The two uninstallers accepted line 1 and went on to authorise removing
<master>/llama.cpp, node, whisper.cpp and stable-diffusion.cpp for a master root
no runtime reader had agreed to. Measured on the fixture, all four readers, one
note:

  storage_roots.unsloth_home()    -> None
  CLI _recorded_master_root()     -> None
  uninstall.sh _master_root()     -> /<master>      <- authorises the delete
  uninstall.ps1 _MasterRoot       -> same shape

Both now refuse when a second non-blank line is present, which is what the
comments already promised. A trailing newline on an ordinary note is not a
second line, and the one-line note keeps working -- both are asserted, so the
refusal cannot pass by reading nothing.

Found by the release-safety fan-out over the install and uninstall paths, then
reproduced against the shipped helpers rather than taken on report.

tests/sh/test_uninstall_master_root.sh grows three cases and runs the real
extracted block: 45 pass here, and the two-line case fails against uninstall.sh
exactly as submitted in this PR. The PowerShell half is held structurally, as
the rest of that file's Windows coverage is.

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

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

* Do not rewrite the master-root note when it already says this

Move-Item -Force is not an atomic replace. PowerShell's filesystem provider
deletes the destination and then moves the source, so an interruption between
the two leaves NO note at all, while the complete staging file sits beside it
unreferenced. A backend that looks next reads "no master root" and, since the
miss is cached, keeps that answer for its whole lifetime.

The window cannot be removed under Windows PowerShell 5.1, which has no atomic
replace primitive this code can reach. What can be removed is re-entering it on
every single update to write bytes that are already there, which is every run
after the first. The note is compared first and the write skipped when it
matches, and the staging file is now cleared in a finally rather than left
behind when the move throws.

Two tests, and the second one took two attempts to make honest: the first
version compared LastWriteTime across two runs a millisecond apart, which can
land on one filesystem tick, and it passed against the unfixed writer. It
back-dates the note instead, so any rewrite is unmistakable, and then changes
the value to prove the writer still writes when it should. Both fail against
setup.ps1 exactly as submitted in this PR and pass here.

Found by the release-safety fan-out over idempotency and interrupted runs, then
reproduced against the shipped block under pwsh rather than taken on report.

43 pass in test_setup_master_root, 176 across it plus the containment, root
agreement and managed tools suites, and 45 in tests/sh/test_uninstall_master_root.sh.

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

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

* Do not read a Windows path error as proof the user's config is absent

_nothing_at's docstring is explicit that Path.exists and is_dir must not be used
because they report ENOTDIR and ELOOP as absence and the redirect would then hide
the user's files. It then took FileNotFoundError at face value, which is the same
reading restricted to one platform: POSIX distinguishes "not there" from "a
parent component is a file" (NotADirectoryError) and "I may not traverse it"
(PermissionError), while Windows collapses all three into ERROR_PATH_NOT_FOUND.

So with a file where ~/.matplotlib belongs, POSIX declined the pin and Windows
took it, moving MPLCONFIGDIR for the whole process and hiding whatever was
underneath. Same defect and same cause as the studio.db guard fixed in
37f23c576, in a second probe, and _nothing_above is the same rule: the nearest
ancestor that can be stat'ed decides, a directory means real absence, anything
else means the path could never have existed, an uninspectable ancestor proves
nothing, and no ancestors at all is the default install and is real absence.

Found by the windows-latest staging leg, on a negative control written for the
POSIX shape and added earlier in this review. base arm 82 real failures,
head 83, the one extra being that test. It is the only new failure on any of the
three operating systems.

Held by a test that reproduces the error shape rather than the platform, since
the Linux runners cannot raise it, with both opposite cases asserted so the fix
cannot pass by declining everything: a genuinely empty present directory still
reads as empty, and a path with nothing along it is still absence. It fails
against storage_roots.py exactly as submitted in this PR.

584 pass with 1 skipped across the eleven suites this branch touches.

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

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

* Trim the comment prose around the master-root resolvers

* Give the cache-pin probes one table each, and the PowerShell probes one helper

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

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

* Refuse a master-root note that names the legacy tree, and stop Node discovery seeding caches

Three fixes found while auditing what happens to an existing install.

A note left at <studio>/share/.unsloth-master-root that names the legacy
tree itself passed every containment check, so a bare environment resolved
a master root and the uninstaller then treated a second marked install
under it as a root to remove. Both uninstallers and both setup scripts now
decline a note whose directory is the legacy Studio dir.

A staged update derived the runtime ownership flag from the master root,
so a staging pass could mark runtimes custom when they were still legacy.
The flag is now assigned only when no stage root is in play, and follows
where the runtimes actually land.

Node tool discovery imported the Studio backend path purely to find the
bundled tools and built the whole compiler cache tree as a side effect.
The import now takes a seed_cache_env flag and the discovery path opts out.

Each fix has a regression test checked against the unfixed code.

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

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

---------

Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-09-20 06:20:41 -07:00 • committed by GitHub
parent e259903f83
commit 922e62297e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 7058 additions and 148 deletions

View file

@ -3966,13 +3966,32 @@ exit 1
(Get-CanonicalDir -Path (Join-Path $env:USERPROFILE ".unsloth\studio")))
}
# Explicit staging root, shared default cache, or the custom Unsloth home's tree.
# The master root storage_roots.unsloth_home() reads. llama.cpp, node and whisper.cpp sit
# BESIDE studio\ under it, so deriving them from $StudioHome would put them one level below
# where every runtime resolver looks.
function Get-MasterRootOverride {
if ([string]::IsNullOrWhiteSpace($env:UNSLOTH_HOME)) { return $null }
$value = $env:UNSLOTH_HOME.Trim()
if ($value -eq "~") {
$value = $env:USERPROFILE
} elseif ($value -like "~/*" -or $value -like "~\*") {
$value = (Join-Path $env:USERPROFILE $value.Substring(1).TrimStart('/', '\'))
}
return (Get-CanonicalDir -Path $value)
}
# Explicit staging root, the master root, the shared default cache, or the custom Unsloth
# home's tree.
function Get-ManagedLlamaCppDir {
param([AllowNull()][string]$StagingRoot = $null)
if ($StagingRoot) {
return (Join-Path $StagingRoot "llama.cpp")
}
$masterRoot = Get-MasterRootOverride
if ($masterRoot) {
return (Join-Path $masterRoot "llama.cpp")
}
if (-not (Test-StudioHomeIsCustom)) {
return (Join-Path $env:USERPROFILE ".unsloth\llama.cpp")
}
@ -3988,8 +4007,9 @@ exit 1
$dir = Get-ManagedLlamaCppDir -StagingRoot $StagingRoot
if ((Get-LlamaCppInstallReadState -Path $dir) -ne "Denied") { return $null }
Write-StudioLine ""
# A denied custom home cannot be claimed as an Unsloth-managed cache.
$homeIsCustom = Test-StudioHomeIsCustom
# A denied custom home cannot be claimed as an Unsloth-managed cache. Computed rather
# than read off $RuntimeRootIsCustom: this runs beside the line that defines it.
$homeIsCustom = (Test-StudioHomeIsCustom) -or [bool](Get-MasterRootOverride)
# Preserve user-supplied wording when either override names this tree, or
# names a build inside it: moving or deleting this folder takes that build
# with it, and the later --with-llama-cpp-dir check then aborts on a path

View file

@ -2,6 +2,7 @@
# Build whisper.cpp's whisper-server for Unsloth's GGUF dictation engine.
# Installs into the managed Unsloth home so the backend's binary discovery
# (core/inference/stt_ggml_sidecar.py::find_whisper_server_binary) picks it up:
# <UNSLOTH_HOME>/whisper.cpp/build/bin/whisper-server (master root)
# <UNSLOTH_STUDIO_HOME>/whisper.cpp/build/bin/whisper-server (custom home)
# ~/.unsloth/whisper.cpp/build/bin/whisper-server (default)
# Usage:
@ -16,7 +17,25 @@ set -eu
WHISPER_CPP_SOURCE="${WHISPER_CPP_SOURCE:-https://github.com/ggml-org/whisper.cpp}"
WHISPER_CPP_TAG="${WHISPER_CPP_TAG:-v1.9.1}"
STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-${UNSLOTH_HOME:-}}}"
# Stripped and tilde-expanded before it can outrank anything, as setup.sh and the Python
# resolvers do: ${VAR:-} only treats the EMPTY string as unset, so a whitespace-only value
# would win here and name a relative " /whisper.cpp" the backend never looks in.
_root_value() {
_rv=$(printf '%s' "${1:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
case "$_rv" in
"~") _rv="$HOME" ;;
"~/"*) _rv="$HOME/${_rv#'~/'}" ;;
esac
printf '%s' "$_rv"
}
# UNSLOTH_HOME first: whisper.cpp is a SIBLING of studio/ under the master root, and the CLI
# exports UNSLOTH_STUDIO_HOME=<root>/studio beside it, so taking that one would install a level
# below where stt_ggml_sidecar._managed_whisper_cpp_dir() looks.
_STUDIO_HOME_ALIAS="${STUDIO_HOME:-}" # read before the name below is reassigned
STUDIO_HOME="$(_root_value "${UNSLOTH_HOME:-}")"
[ -n "$STUDIO_HOME" ] || STUDIO_HOME="$(_root_value "${UNSLOTH_STUDIO_HOME:-}")"
[ -n "$STUDIO_HOME" ] || STUDIO_HOME="$(_root_value "$_STUDIO_HOME_ALIAS")"
CUSTOM_STUDIO_HOME=false
if [ -n "$STUDIO_HOME" ]; then
CUSTOM_STUDIO_HOME=true

View file

@ -124,6 +124,48 @@ Environment:
}
}
# rmdir, as uninstall.sh uses for the same two paths: empty or not at all, never recursed
# into. Not "list it, then _RemovePath": -ErrorAction SilentlyContinue makes an enumeration
# failure look like an empty directory, so a master root whose ACL denies listing was deleted
# recursively with the user's files in it, and a separate check and delete let a file
# arriving in between be taken by the -Recurse. Directory.Delete($path, $false) is one call.
function _RemoveDirIfEmpty {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return }
try {
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
[System.IO.Directory]::Delete($Path, $false)
_Substep "removed: $Path" "Green"
} catch {
# Non-empty, unreadable or held open. All three mean "not ours to take".
_Substep "keeping non-empty directory: $Path" "Yellow"
}
}
# The exact shape prebuilt_core.py leaves: a component lock name, ".stale.", and the pid it
# moved aside. The leading dot alone also matched ".backup.install.lock.stale.copy", which in
# a user-chosen master root is theirs. uninstall.sh applies the same shape.
$script:StaleLockPattern = '^\.(llama\.cpp|node|whisper\.cpp|sd\.cpp)\.install\.lock\.stale\.[0-9]+$'
# An install lock, and only an install lock. prebuilt_core.install_lock creates these with
# O_CREAT | O_EXCL, so a lock is always a regular file, while _RemovePath deletes recursively
# and in a user-chosen master root would take a whole tree carrying one of these fixed names
# with none of the owner-marker proof its neighbours require. Same rule as uninstall.sh.
function _RemoveLockFile {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return }
if (-not (Test-Path -LiteralPath $Path)) { return }
# A reparse point at a lock name is not a lock either, and following one would delete
# whatever it points at.
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if ($null -ne $item -and -not $item.PSIsContainer -and
-not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
_RemovePath $Path
} else {
_Substep "keeping non-file at an install-lock path: $Path" "Yellow"
}
}
# LOCALAPPDATA / APPDATA are dropped in service and CI contexts, so fall back to the known
# folder; $null only if that fails too, which callers treat as incomplete cleanup.
function _AppDataRoot {
@ -567,6 +609,122 @@ Environment:
# Discover non-default Unsloth roots from env vars + studio.conf files. Mirrors install.ps1's
# precedence: UNSLOTH_STUDIO_HOME wins and STUDIO_HOME is ignored when both are set, or
# uninstalling install A would also delete install B from a stale STUDIO_HOME.
# The master root UNSLOTH_HOME names, or $null. studio\ is its child and llama.cpp, node and
# whisper.cpp are its other children, so removing the Studio root alone strands them. Trimmed
# and tilde-expanded like storage_roots.unsloth_home() and studio\setup.ps1.
function _MasterRoot {
$noteStudio = $null
$raw = $env:UNSLOTH_HOME
if ([string]::IsNullOrWhiteSpace($raw)) {
# The note setup.ps1 leaves in the Studio tree, when this run has no UNSLOTH_HOME of
# its own: a one-command `$env:UNSLOTH_HOME = 'D:\portable'` leaves nothing in a
# later environment, so without it an uninstall stranded the runtimes. The deny list
# and the marker gates still apply, so a stale note licenses nothing.
#
# Only the Studio root this run is about, in studio_root()'s precedence and with no
# falling through: with two installs on one box, a legacy note otherwise won here
# while the removal worked on the named tree, so the run deleted one Studio and the
# OTHER install's runtime children. Mirrors _master_root in uninstall.sh.
$noteRoots = @()
foreach ($override in @($env:UNSLOTH_STUDIO_HOME, $env:STUDIO_HOME)) {
if (-not [string]::IsNullOrWhiteSpace($override)) {
$noteRoots = @(_ExpandTilde $override.Trim())
break
}
}
if (-not $noteRoots) {
# No override names one, so the root install.ps1 recorded in the default-mode
# studio.conf, then the legacy tree. A master-root install puts Studio at
# <master>\studio, and that conf is how this script finds it with a bare
# environment. Read directly rather than through _CustomStudioRoots, which calls
# back into this function.
if ($env:LOCALAPPDATA) {
$confRoot = _RootFromConf (Join-Path $env:LOCALAPPDATA "Unsloth Studio\studio.conf")
if ($confRoot) { $noteRoots += $confRoot }
}
if ($env:USERPROFILE) {
$noteRoots += (Join-Path $env:USERPROFILE ".unsloth\studio")
}
}
foreach ($noteRoot in $noteRoots) {
$notePath = Join-Path $noteRoot "share\.unsloth-master-root"
if (-not (Test-Path -LiteralPath $notePath -PathType Leaf)) { continue }
try {
# One line, and REFUSED when there is a second: storage_roots.py and the CLI
# strip the WHOLE file, so a two-line note is not a directory to them and
# they decline it, while accepting line 1 here let a note no runtime reader
# honours authorise removing the master root's runtime children. Two lines
# are read so the second can be seen; a trailing newline is not one.
# -Encoding UTF8: 5.1 decodes BOM-less input with the ANSI code page, so a
# master root holding non-ASCII came back mangled and its runtime siblings
# were never found.
$noteLines = @(Get-Content -LiteralPath $notePath -TotalCount 2 -Encoding UTF8 -ErrorAction Stop)
} catch { continue }
if ($noteLines.Count -gt 1 -and -not [string]::IsNullOrWhiteSpace($noteLines[1])) { continue }
$line = if ($noteLines.Count -ge 1) { $noteLines[0] } else { $null }
if (-not [string]::IsNullOrWhiteSpace($line)) {
$raw = $line
$noteStudio = $noteRoot
break
}
}
}
if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
$expanded = _ExpandTilde $raw.Trim()
$norm = $null
# The provider path first, exactly as setup.ps1's Get-CanonicalDir resolves it.
# [IO.Path]::GetFullPath anchors a RELATIVE root at [Environment]::CurrentDirectory,
# which PowerShell does not keep in step with its own location, so after a Set-Location
# a relative UNSLOTH_HOME named one install to setup and another here. Nothing downstream
# notices: the marker spares trees that are not Unsloth's, not another Unsloth install.
try {
$norm = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($expanded)
} catch { $norm = $null }
try { $norm = [System.IO.Path]::GetFullPath($(if ($norm) { $norm } else { $expanded })).TrimEnd('\','/') }
catch { return $null }
if (-not $norm) { return $null }
# A note must describe the tree it was found in: copy a Studio tree from master root A to
# B and uninstall B, and the copied note still names A, whose runtimes carry the same
# owner markers -- so the gates below would delete the ORIGINAL install's. Containment,
# not an exact <root>\studio match, since the flat layout is supported. An explicit
# UNSLOTH_HOME skips the check: that is the user speaking, not a file on disk. Mirrors
# uninstall.sh and storage_roots._recorded_master_root().
if ($noteStudio) {
$here = $null
try {
$here = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($noteStudio)
} catch { $here = $null }
try { $here = [System.IO.Path]::GetFullPath($(if ($here) { $here } else { $noteStudio })).TrimEnd('\','/') }
catch { $here = $null }
if (-not $here) { return $null }
# A note found in the ordinary %USERPROFILE%\.unsloth\studio install is declined
# outright, whatever it records, which is the rule storage_roots'
# _is_legacy_studio_tree and the CLI already apply. Containment alone is not enough:
# the profile directory CONTAINS the legacy tree, so a note naming it passed,
# _CustomStudioRoots then derived "<profile>\studio", and uninstalling the legacy
# install accepted a SECOND marked install there as a root to remove. The deny list
# below refuses the profile as a MASTER root, not the Studio candidate derived from
# it. Mirrors uninstall.sh.
if ($env:USERPROFILE) {
$legacyStudio = $null
try {
$legacyStudio = [System.IO.Path]::GetFullPath(
(Join-Path $env:USERPROFILE ".unsloth\studio")).TrimEnd('\','/')
} catch { $legacyStudio = $null }
if ($legacyStudio -and ($here -ieq $legacyStudio)) { return $null }
}
if (-not ($here -ieq $norm -or $here.StartsWith($norm + [System.IO.Path]::DirectorySeparatorChar,
[System.StringComparison]::OrdinalIgnoreCase))) {
return $null
}
}
# The default root is left to the blocks that own it, which remove it unconditionally.
if ($env:USERPROFILE -and ($norm -ieq (Join-Path $env:USERPROFILE ".unsloth").TrimEnd('\','/'))) {
return $null
}
return $norm
}
function _CustomStudioRoots {
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$defaultRoot = $null
@ -586,10 +744,18 @@ Environment:
}
$envRoot = $null
if ($env:UNSLOTH_STUDIO_HOME) {
$envRoot = $env:UNSLOTH_STUDIO_HOME
} elseif ($env:STUDIO_HOME) {
$envRoot = $env:STUDIO_HOME
# IsNullOrWhiteSpace, not truthiness: storage_roots.studio_root() trims these, so a
# whitespace-only override is unset to every resolver, while a bare truthy test called it
# present and suppressed the master-root branch below, leaving <UNSLOTH_HOME>\studio.
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) {
$envRoot = $env:UNSLOTH_STUDIO_HOME.Trim()
} elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) {
$envRoot = $env:STUDIO_HOME.Trim()
} else {
# Last, as in storage_roots.studio_root(): UNSLOTH_HOME names the tree, and the two
# above name this exact directory, so either of them wins outright.
$master = _MasterRoot
if ($master) { $envRoot = (Join-Path $master "studio") }
}
if ($envRoot) {
$expandedEnv = _ExpandTilde $envRoot
@ -918,7 +1084,23 @@ Environment:
}
# Also stop anything holding a handle on the exact paths we delete (llama-server,
# the CLI shim, an mp-fork python with a venv DLL) so the dir delete isn't refused.
$stopRoots = @($ownedRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode, $defaultWhisperCpp) + @($defaultSdCppToStop | Where-Object { $_ }) + @($customSdCppToStop)
# Resolved here, before the stop pass: a loaded llama-server.exe under the master root keeps
# its own tree locked, and the removal below would exhaust its retries and leave it installed.
$masterRootToStop = _MasterRoot
$masterChildrenToStop = @()
if ($masterRootToStop -and -not (_IsUnsafeRoot $masterRootToStop)) {
foreach ($childName in @("llama.cpp", "node", "whisper.cpp", "stable-diffusion.cpp")) {
$childPath = Join-Path $masterRootToStop $childName
# Only the trees this uninstall is going to delete, so an unmarked neighbour's
# process is never killed.
if (Test-Path -LiteralPath (Join-Path $childPath ".unsloth-studio-owned") -PathType Leaf) {
$masterChildrenToStop += $childPath
}
}
}
# $ownedRoots, not $knownRoots: see the note where the two lists are built. The master
# children are already marker-gated above, so they carry their own proof of ownership.
$stopRoots = @($ownedRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode, $defaultWhisperCpp) + @($defaultSdCppToStop | Where-Object { $_ }) + @($customSdCppToStop) + @($masterChildrenToStop)
# The reparse expansion turns one path into generic subdirectories of wherever it points
# (node, bin, unsloth_studio), so it is gated for the same reason.
_StopProcessesLockingRoots -Roots ($stopRoots + @(_ManagedPathsUnderReparseTargets $ownedRoots))
@ -937,6 +1119,18 @@ Environment:
_Substep "refusing to remove non-Unsloth path: $r" "Yellow"
continue
}
# The flat layout, both variables naming one directory: _RemoveRootRecordingDb takes a
# marked Studio root WHOLE, so without this the user-chosen master root went with the
# install, which is the hole in the rule every other child below is marker-gated for.
# Kept rather than pruned, since data left behind is recoverable. Mirrors uninstall.sh.
$flatMaster = $masterRootToStop
if ($flatMaster -and ($r.TrimEnd('\', '/') -ieq $flatMaster.TrimEnd('\', '/'))) {
_Substep "keeping $r`: UNSLOTH_HOME and the Studio root name the same directory," "Yellow"
_Substep "so removing it would take whatever else you keep there. Delete it by hand" "Yellow"
_Substep "once you have checked what is in it." "Yellow"
$script:RemoveFailed = $true
continue
}
_RemoveRootRecordingDb $r
# Native diffusion now installs UNDER the custom root, so the removal above already took
# it. Older builds put it BESIDE the root at <parent>\stable-diffusion.cpp, which removing
@ -971,6 +1165,45 @@ Environment:
# Unsloth's %TEMP% before the sweep ever looked at its owner.pid.
$preservedTemp = @(_RemoveStudioPrivateTempTrees -Paths $privateTempDirs -PrimaryPath $primaryPrivateTemp)
if ($defaultDataDir) { _RemoveDataDirKeepingWslIcon $defaultDataDir -Preserve $preservedTemp }
# The master root's own children, marker-gated rather than removed outright like the
# ~/.unsloth ones below: <master> is a directory the user chose. The locks and .staging are
# ours by name (prebuilt_core.py) and carry no marker. Mirrors scripts/uninstall.sh.
# $masterRootToStop, not a fresh _MasterRoot: that resolver can read its answer from a note
# inside a Studio tree the loop above has already removed, after which a second call returns
# nothing and the runtime siblings are stranded.
$masterRoot = $masterRootToStop
if ($masterRoot -and (_IsUnsafeRoot $masterRoot)) {
_Substep "refusing to remove unsafe path: $masterRoot" "Yellow"
$masterRoot = $null
}
if ($masterRoot) {
foreach ($childName in @("llama.cpp", "node", "whisper.cpp", "stable-diffusion.cpp")) {
$childPath = Join-Path $masterRoot $childName
if ((Test-Path -LiteralPath $childPath) -and
-not (Test-Path -LiteralPath (Join-Path $childPath ".unsloth-studio-owned") -PathType Leaf)) {
_Substep "keeping $childName without Unsloth owner marker: $childPath" "Yellow"
} else {
_RemovePath $childPath
}
}
foreach ($lockName in @(".llama.cpp.install.lock", ".node.install.lock",
".whisper.cpp.install.lock", ".sd.cpp.install.lock")) {
_RemoveLockFile (Join-Path $masterRoot $lockName)
}
# The prebuilt installers SHARE <root>\.staging and prune it only when empty, so
# anything left in it here is not ours: remove the directory only, never its contents.
_RemoveDirIfEmpty (Join-Path $masterRoot ".staging")
if (Test-Path -LiteralPath $masterRoot) {
# $script:StaleLockPattern, not a glob: the shape has to be the installer's own,
# name and numeric pid both, or a user's file in their own root is taken.
foreach ($stale in @(Get-ChildItem -LiteralPath $masterRoot -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match $script:StaleLockPattern })) {
_RemoveLockFile $stale.FullName
}
}
# Only when nothing of the user's is left.
_RemoveDirIfEmpty $masterRoot
}
# Shared llama.cpp build + cache, siblings of studio under ~/.unsloth in default mode.
if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp }
# "stable-diffusion.cpp" is exactly what a git clone of leejet/stable-diffusion.cpp produces
@ -992,15 +1225,16 @@ Environment:
# (prebuilt_core.py), and a stray lock keeps ~/.unsloth from being pruned below.
if ($defaultUnslothHome) {
foreach ($lockName in @(".llama.cpp.install.lock", ".node.install.lock", ".whisper.cpp.install.lock")) {
_RemovePath (Join-Path $defaultUnslothHome $lockName)
_RemoveLockFile (Join-Path $defaultUnslothHome $lockName)
}
# Taking over an abandoned lock renames it to .stale.<pid> before unlinking; a crash
# between the two strands the rename, so sweep any leftovers. -Force sees dotted names.
if (Test-Path -LiteralPath $defaultUnslothHome) {
# -like, not -Filter: the Win32 filter is unreliable for names with several dots.
# -match, not -Filter: the Win32 filter is unreliable for names with several dots,
# and only the installer's exact shape is ours to remove.
foreach ($stale in @(Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "*.install.lock.stale.*" })) {
_RemovePath $stale.FullName
Where-Object { $_.Name -match $script:StaleLockPattern })) {
_RemoveLockFile $stale.FullName
}
}
}
@ -1105,6 +1339,27 @@ Environment:
} catch {
_Substep "could not update user PATH: $($_.Exception.Message)" "Yellow"
}
# Clear the persisted Inductor cache path when it still names a tree this run deleted:
# setup.ps1 writes TORCHINDUCTOR_CACHE_DIR to the USER environment, so every later PyTorch
# process on this account inherits it and compiles into the removed directory. Only a value
# inside a root this run owned; a user's own directory and the shared C:\tc fallback stay.
try {
$persistedCache = [Environment]::GetEnvironmentVariable('TORCHINDUCTOR_CACHE_DIR', 'User')
if (-not [string]::IsNullOrWhiteSpace($persistedCache)) {
$expandedCache = [Environment]::ExpandEnvironmentVariables($persistedCache).TrimEnd('\', '/')
foreach ($r in $ownedRoots) {
if (-not $r) { continue }
$rNorm = $r.TrimEnd('\', '/')
if ($expandedCache -ieq $rNorm -or $expandedCache -ilike "$rNorm\*") {
[Environment]::SetEnvironmentVariable('TORCHINDUCTOR_CACHE_DIR', [NullString]::Value, 'User')
_Substep "cleared TORCHINDUCTOR_CACHE_DIR: $persistedCache" "Green"
break
}
}
}
} catch {
_Substep "could not clear TORCHINDUCTOR_CACHE_DIR: $($_.Exception.Message)" "Yellow"
}
# Remove HKCU\Software\Unsloth (PathBackup lives here; install.ps1 owns it).
try {
Remove-Item -LiteralPath 'HKCU:\Software\Unsloth' -Recurse -Force -ErrorAction SilentlyContinue

View file

@ -341,6 +341,22 @@ _remove_path() {
fi
}
# An install lock, and only an install lock. prebuilt_core.install_lock creates these with
# O_CREAT | O_EXCL, so a lock is always a regular file: a directory or a link at that name is
# the user's, and in a master root they chose _remove_path would rm -rf it. -L before -f, which
# is true for a link to a file. uninstall.ps1's _RemoveLockFile excludes reparse points for the
# same reason, and the two halves of one rule have to agree.
_remove_lock_file() {
_rlf="$1"
if [ -L "$_rlf" ]; then
echo " keeping link at an install-lock path: $_rlf" >&2
elif [ -f "$_rlf" ]; then
_remove_path "$_rlf"
elif [ -e "$_rlf" ]; then
echo " keeping non-file at an install-lock path: $_rlf" >&2
fi
}
# $1 override, $2 default. A relative override is invalid per XDG and dropped by dirs (which
# Tauri resolves through), so honouring one would spare the real data and rm -rf under our cwd.
_xdg_dir() {
@ -462,6 +478,112 @@ _custom_studio_data_dirs() {
# 3. Env-mode studio.conf at $<root>/share/studio.conf (discovered via 1)
# install.sh writes UNSLOTH_EXE='<root>/unsloth_studio/bin/unsloth', so the install root is three
# dirnames up. Each discovered non-default root is printed on its own line, de-duplicated.
# The master root UNSLOTH_HOME names, or empty. studio/ is its child and llama.cpp, node and
# whisper.cpp are its other children, so removing the Studio root alone strands them. Stripped
# and tilde-expanded like storage_roots.unsloth_home() and studio/setup.sh, or a padded value
# would name a directory neither install nor uninstall agrees on.
_master_root() {
_mr_from_note=
_mr=$(printf '%s' "${UNSLOTH_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
# The note setup.sh leaves in the Studio tree, when this run has no UNSLOTH_HOME of its own:
# `UNSLOTH_HOME=/mnt/portable unsloth studio update` leaves nothing in the environment, so
# without it an uninstall stranded the runtimes. The deny list and the marker gate still
# apply, so a stale note licenses nothing the environment could not.
#
# Only the Studio root this run is about, in studio_root()'s precedence and with no falling
# through: with two installs on one box, a legacy note otherwise won here while the removal
# worked on the named tree, so the run deleted one Studio and the OTHER install's runtimes.
if [ -z "$_mr" ]; then
# Trimmed as setup.sh trimmed them when it chose where to write the note: appending to
# a padded UNSLOTH_STUDIO_HOME looked somewhere that does not exist, so the note was
# never found while the loop below still removed the tree.
_mr_ush=$(printf '%s' "${UNSLOTH_STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
_mr_sh=$(printf '%s' "${STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_mr_ush" ]; then
_mr_roots="$_mr_ush"
elif [ -n "$_mr_sh" ]; then
_mr_roots="$_mr_sh"
else
# No override, so the root install.sh recorded in the default-mode studio.conf, then
# the legacy tree: with a bare environment that conf is the only way to find a
# <master>/studio at all. Read here, not through _custom_studio_roots, which calls
# back into this function. UNSLOTH_EXE='<root>/unsloth_studio/bin/unsloth', so the
# root is three dirnames up; apostrophes arrive escaped as '\''.
_mr_roots=""
_mr_dconf="$HOME/.local/share/unsloth/studio.conf"
if [ -f "$_mr_dconf" ]; then
_mr_exe=$(sed -n "s/^UNSLOTH_EXE='\(.*\)'\$/\1/p" "$_mr_dconf" | head -n1)
_mr_exe=$(printf '%s' "$_mr_exe" | sed "s/'\\\\''/'/g")
[ -n "$_mr_exe" ] && _mr_roots=$(dirname "$(dirname "$(dirname "$_mr_exe")")")
fi
_mr_roots="$_mr_roots
$HOME/.unsloth/studio"
fi
_mr_saved_ifs=$IFS
IFS='
'
for _mr_studio in $_mr_roots; do
IFS=$_mr_saved_ifs
[ -n "$_mr_studio" ] || continue
_mr_conf="${_mr_studio}/share/.unsloth-master-root"
[ -f "$_mr_conf" ] || continue
# One line, and REFUSED when there is a second. storage_roots.py and the CLI both
# strip the WHOLE file, so a two-line note is not a directory to them and they
# decline it, while taking line 1 here authorised removing <master>/llama.cpp, node,
# whisper.cpp and stable-diffusion.cpp: a note no runtime reader honours must not
# license a delete. A trailing newline is not a second line.
if [ -n "$(sed -n '2,$p' "$_mr_conf" 2>/dev/null | tr -d '[:space:]')" ]; then
continue
fi
_mr=$(head -n 1 "$_mr_conf" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \
|| _mr=""
if [ -n "$_mr" ]; then
_mr_from_note=1
break
fi
done
IFS=$_mr_saved_ifs
fi
[ -n "$_mr" ] || return 0
# shellcheck disable=SC2088
case "$_mr" in
"~") _mr="$HOME" ;;
"~/"*) _mr="$HOME/${_mr#'~/'}" ;;
esac
# shellcheck disable=SC1007
# || _mr_canon="": a bare assignment takes the substitution's exit status under set -e, and
# a root already gone or on a disconnected drive is the ordinary case here, which killed the
# whole run. bash hides this by clearing errexit inside command substitution; dash and POSIX
# `sh`, which the advertised `| sh` one-liner uses, do not.
_mr_canon=$(CDPATH= cd -P -- "$_mr" 2>/dev/null && pwd -P) || _mr_canon=""
[ -n "$_mr_canon" ] && _mr="$_mr_canon"
# A note must describe the tree it was found in: copy a Studio tree from master root A to B
# and uninstall B, and the copied note still names A, whose runtimes carry the same owner
# markers -- so the gates below would delete the ORIGINAL install's. Containment, not an
# exact <root>/studio match, since the flat layout is supported. An explicit UNSLOTH_HOME
# skips all of this: that is the user speaking, not a file on disk.
if [ "${_mr_from_note:-}" = 1 ]; then
_mr_here=$(CDPATH= cd -P -- "$_mr_studio" 2>/dev/null && pwd -P) || _mr_here=""
# A note found in the ordinary ~/.unsloth/studio install is declined outright, whatever
# it records, which is the rule storage_roots._is_legacy_studio_tree and the CLI already
# apply. Containment alone is not enough here: $HOME contains the legacy tree, so a note
# naming it passed, _custom_studio_roots then emitted "$HOME/studio", and uninstalling
# the legacy install accepted a SECOND marked install there as a root to remove, taking
# its database and outputs with it. The deny list does not catch that -- it refuses
# $HOME as a master root, not the separate Studio candidate derived from it.
_mr_legacy_studio=$(CDPATH= cd -P -- "$HOME/.unsloth/studio" 2>/dev/null && pwd -P) \
|| _mr_legacy_studio="$HOME/.unsloth/studio"
[ "$_mr_here" != "$_mr_legacy_studio" ] || return 0
case "$_mr_here" in
"$_mr") : ;;
"$_mr"/*) : ;;
*) return 0 ;;
esac
fi
case "$_mr" in "$HOME/.unsloth"|/|"") return 0 ;; esac
printf '%s\n' "$_mr"
}
_custom_studio_roots() {
# $1 = "lexical": skip the canonicalization (see the legacy sd.cpp sibling below). Reset on
# every call, so a plain call is never affected by a preceding lexical one.
@ -500,12 +622,22 @@ _custom_studio_roots() {
}
# Mirror install.sh's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME is ignored when both
# are set, or uninstalling install A could also delete install B from a leftover STUDIO_HOME.
if [ -n "${UNSLOTH_STUDIO_HOME:-}" ]; then
_emit "$UNSLOTH_STUDIO_HOME"
_from_conf "$UNSLOTH_STUDIO_HOME/share/studio.conf"
elif [ -n "${STUDIO_HOME:-}" ]; then
_emit "$STUDIO_HOME"
_from_conf "$STUDIO_HOME/share/studio.conf"
# Trimmed, as storage_roots.studio_root() trims them: a whitespace-only override is unset to
# every resolver, but a bare -n test called it present and suppressed the master-root branch
# below, leaving <UNSLOTH_HOME>/studio installed.
_ush=$(printf '%s' "${UNSLOTH_STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
_sh=$(printf '%s' "${STUDIO_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_ush" ]; then
_emit "$_ush"
_from_conf "$_ush/share/studio.conf"
elif [ -n "$_sh" ]; then
_emit "$_sh"
_from_conf "$_sh/share/studio.conf"
elif [ -n "$(_master_root)" ]; then
# Last, as in storage_roots.studio_root(): UNSLOTH_HOME names the tree, and the two
# above name this exact directory, so either of them wins outright.
_emit "$(_master_root)/studio"
_from_conf "$(_master_root)/studio/share/studio.conf"
fi
# Default-mode conf.
_from_conf "$HOME/.local/share/unsloth/studio.conf"
@ -593,6 +725,9 @@ _unsloth_uninstall_main() {
_pkill_studio
echo "Removing data and install directories..."
# Resolved ONCE, before anything is deleted: _master_root can read its answer from a note
# inside a Studio tree the loop below removes, after which the runtime siblings are stranded.
_MASTER_ROOT_SAVED="$(_master_root)"
_custom_studio_roots | while IFS= read -r _custom_root; do
[ -n "$_custom_root" ] || continue
if _is_unsafe_root "$_custom_root"; then
@ -607,6 +742,23 @@ _unsloth_uninstall_main() {
echo " refusing to remove non-Unsloth path: $_custom_root" >&2
continue
fi
# The flat layout, both variables naming one directory: _remove_root_recording_db takes
# a marked Studio root WHOLE, so without this the user-chosen master root went with the
# install, which is the hole in the rule that every other child below is marker-gated
# for. Kept rather than pruned, since data left behind is recoverable and printed.
# Canonicalised on both sides, or a symlinked path compares unequal to itself.
_crf_canon=$(CDPATH= cd -P -- "$_custom_root" 2>/dev/null && pwd -P) || _crf_canon=""
[ -n "$_crf_canon" ] || _crf_canon="$_custom_root"
_crf_master="$_MASTER_ROOT_SAVED"
if [ -n "$_crf_master" ] && [ "$_crf_canon" = "$_crf_master" ]; then
echo " keeping $_custom_root: UNSLOTH_HOME and the Studio root name the same" >&2
echo " directory, so removing it would take whatever else you keep there." >&2
echo " Delete it by hand once you have checked what is in it." >&2
_set_marker "$_REMOVE_FAILED_FLAG"
unset _crf_canon _crf_master
continue
fi
unset _crf_canon _crf_master
_remove_root_recording_db "$_custom_root"
# Native diffusion now installs UNDER the custom root, so the removal above already took
# it. Older builds put it BESIDE the root at <parent>/stable-diffusion.cpp, which removing
@ -645,6 +797,52 @@ _unsloth_uninstall_main() {
_remove_path "$_lex_sd_cpp"
fi
done
# The master root's own children, marker-gated and deny-listed rather than removed outright
# like the ~/.unsloth ones below: <master> is a directory the user chose. The locks and
# .staging are ours by name (prebuilt_core.py) and carry no marker.
_mr_root="$_MASTER_ROOT_SAVED"
if [ -n "$_mr_root" ]; then
if _is_unsafe_root "$_mr_root"; then
echo " refusing to remove unsafe path: $_mr_root" >&2
else
for _mr_child in llama.cpp node whisper.cpp stable-diffusion.cpp; do
_mr_path="$_mr_root/$_mr_child"
if _is_unsafe_root "$_mr_path"; then
echo " refusing to remove unsafe path: $_mr_path" >&2
# -L as well as -e: -e is false for a dangling symlink, so one named llama.cpp
# with its target volume unmounted fell through to _remove_path, which treats
# -L as present and unlinks it. In a root the user chose, that link is theirs.
elif { [ -e "$_mr_path" ] || [ -L "$_mr_path" ]; } \
&& [ ! -f "$_mr_path/.unsloth-studio-owned" ]; then
echo " keeping $_mr_child without Unsloth owner marker: $_mr_path" >&2
else
_remove_path "$_mr_path"
fi
done
for _mr_lock in .llama.cpp.install.lock .node.install.lock \
.whisper.cpp.install.lock .sd.cpp.install.lock; do
_remove_lock_file "$_mr_root/$_mr_lock"
done
# The prebuilt installers SHARE <root>/.staging and prune it only when empty, so
# anything left in it here is not ours. rmdir, not _remove_path: in a user-chosen
# root a recursive delete would take files an install was content to leave.
rmdir "$_mr_root/.staging" 2>/dev/null || true
# The exact shape prebuilt_core.py leaves: a component lock name, ".stale.", and the
# pid. A bare .*.install.lock.stale.* also matched .backup.install.lock.stale.copy.
for _mr_lock in .llama.cpp.install.lock .node.install.lock \
.whisper.cpp.install.lock .sd.cpp.install.lock; do
for _mr_stale in "$_mr_root/$_mr_lock".stale.*; do
case "${_mr_stale##*.stale.}" in
''|*[!0-9]*) continue ;;
esac
{ [ -e "$_mr_stale" ] || [ -L "$_mr_stale" ]; } && _remove_lock_file "$_mr_stale"
done
done
# Only when nothing of the user's is left; rmdir refuses a non-empty directory.
rmdir "$_mr_root" 2>/dev/null || true
fi
fi
# end master-root children
# Same gate as a custom root: an ungated run takes a hand-made ~/.unsloth/studio, and then
# ~/.unsloth via the empty-dir prune below.
# -e OR -L: -e follows a link and misses a dangling one, which _remove_path would still
@ -685,14 +883,23 @@ _unsloth_uninstall_main() {
_remove_path "$HOME/.unsloth/whisper.cpp"
# Prebuilt install locks: every prebuilt serializes on <parent>/.<name>.install.lock
# (prebuilt_core.py), and a stray lock keeps ~/.unsloth from being pruned below.
_remove_path "$HOME/.unsloth/.llama.cpp.install.lock"
_remove_path "$HOME/.unsloth/.node.install.lock"
_remove_path "$HOME/.unsloth/.whisper.cpp.install.lock"
_remove_lock_file "$HOME/.unsloth/.llama.cpp.install.lock"
_remove_lock_file "$HOME/.unsloth/.node.install.lock"
_remove_lock_file "$HOME/.unsloth/.whisper.cpp.install.lock"
# Taking over an abandoned lock renames it to .stale.<pid> before unlinking
# (install_node_prebuilt.py); a crash between the two strands the rename, and a stranded one
# blocks the rmdir below. Unmatched globs stay literal, hence the existence test.
for _stale in "$HOME"/.unsloth/.*.install.lock.stale.*; do
[ -e "$_stale" ] && _remove_path "$_stale"
# Same shape restriction as the master-root sweep above: the help text promises that
# anything else kept under ~/.unsloth is left in place, and .backup.install.lock.stale.copy
# is dotted too.
for _lock in .llama.cpp.install.lock .node.install.lock \
.whisper.cpp.install.lock .sd.cpp.install.lock; do
for _stale in "$HOME/.unsloth/$_lock".stale.*; do
case "${_stale##*.stale.}" in
''|*[!0-9]*) continue ;;
esac
{ [ -e "$_stale" ] || [ -L "$_stale" ]; } && _remove_lock_file "$_stale"
done
done
# ROCm-on-WSL helper artifacts (librocdxg clone, smoke-test venv); removing them frees the rmdir.
_remove_path "$HOME/.unsloth/librocdxg"

View file

@ -57,6 +57,7 @@ import dataclasses
import hashlib
import json
import os
import shutil
import tempfile
import threading
import time
@ -75,7 +76,7 @@ _ENV_DIR = "UNSLOTH_DIFFUSION_COMPILE_CACHE_DIR"
_ENV_SAVE = "UNSLOTH_DIFFUSION_COMPILE_CACHE_SAVE"
_ENV_SYNC = "UNSLOTH_DIFFUSION_COMPILE_CACHE_SYNC"
_DEFAULT_ROOT = Path.home() / ".cache" / "unsloth" / "diffusion_compile_cache"
_LEGACY_ROOT = Path.home() / ".cache" / "unsloth" / "diffusion_compile_cache"
_MANIFEST_NAME = "manifest.json"
# The pre-content-addressing bundle name. Still read (a manifest without a "bundle" key names it, which is every
@ -133,14 +134,61 @@ def _save_enabled(mode: str) -> bool:
return (os.environ.get(_ENV_SAVE) or "").strip().lower() not in ("0", "off", "false", "no")
def _portable_mode() -> bool:
try:
from utils.paths.storage_roots import portable_mode
except ImportError:
return False
try:
return bool(portable_mode())
except Exception: # noqa: BLE001 - never fail a cache lookup over this
return False
def _default_root() -> Path:
"""Resolved per call, not a module constant: this module is imported before startup sets
UNSLOTH_STUDIO_HOME, which storage_roots reads."""
try:
from utils.paths.storage_roots import cache_root as studio_cache_root
except ImportError:
return _LEGACY_ROOT
return studio_cache_root() / "diffusion_compile_cache"
def sync_saves() -> bool:
"""Whether ``save_async`` must write inline instead of handing off to the worker."""
return (os.environ.get(_ENV_SYNC) or "").strip().lower() in ("1", "on", "true", "yes")
def cache_root() -> Path:
"""The root every bundle, manifest and inductor artifact is WRITTEN under."""
root = os.environ.get(_ENV_DIR)
return Path(root) if root else _DEFAULT_ROOT
if root:
return Path(root)
return _default_root()
def legacy_cache_root() -> Optional[Path]:
"""The pre-relocation root, when it is still worth READING old bundles from.
Read-only on purpose: returning it as the write root would pin an upgraded install to the home
directory forever. Skipped under an explicit dir override (it names one exact directory) and
in portable mode (the host's home is not part of the install).
"""
if os.environ.get(_ENV_DIR) or _portable_mode():
return None
# Equal when storage_roots is unavailable and the default IS the legacy root.
if _LEGACY_ROOT == _default_root():
return None
# This root is the HOST's home, not the install, so it is the one that may be on a mount the
# new cache does not depend on. Path.exists raises for EACCES and EIO before 3.14, and an
# optional migration source we cannot inspect is a miss, never a failed generation.
try:
if not _LEGACY_ROOT.exists():
return None
except OSError:
return None
return _LEGACY_ROOT
def _triton_version() -> Optional[str]:
@ -339,17 +387,70 @@ def begin(
_collect_superseded(cdir, logger)
# Try an exact-match load. A miss/mismatch is normal and non-fatal.
if published is not None and ctx.bundle.exists():
# Guarded like _load_from_legacy's probe: Path.exists() raises rather than returning False when a parent denies
# traversal, and this branch moves the write root to a directory the process may not own. Unguarded, that
# exception left begin() before the legacy fallback below could run.
try:
pair_present = published is not None and ctx.bundle.exists()
except OSError as exc:
_warn(logger, f"compile-cache: cannot check the bundle at {ctx.dir}: {exc}")
pair_present = False
if pair_present:
ctx.hit = _try_load(ctx, logger)
if ctx.hit and mode != "on":
# Loaded artifacts == on-disk artifacts, so nothing to save. A new static-compile shape re-dirties via
# register_shape; mode "on" keeps saving.
ctx.saved = True
else:
if not ctx.hit:
# An install that predates the relocation may still hold this key under the old root.
ctx.hit = _load_from_legacy(ctx, logger)
if not ctx.hit:
_info(logger, f"compile-cache: no bundle for key {key} (will compile locally)")
elif mode != "on":
# Loaded artifacts == on-disk artifacts, so nothing to save. A new static-compile shape
# re-dirties via register_shape; mode "on" keeps saving.
ctx.saved = True
return ctx
def _load_from_legacy(ctx: CacheContext, logger: Any) -> bool:
"""Load the same key's bundle from the pre-relocation root, then migrate it.
The key already covers every portability dimension, so a legacy bundle under it is the same
artifact this run would have written. The copy stops the read fallback becoming permanent.
Best-effort, and skipped when saving is off, since that mode promises a read-only cache."""
root = legacy_cache_root()
if root is None:
return False
ldir = root / ctx.key
manifest_path = ldir / _MANIFEST_NAME
# The manifest is the commit point here too, so it decides which legacy bundle is live; a
# pre-content-addressing one names cache.bin.
published = _read_manifest(manifest_path)
if published is None:
return False
bundle = _manifest_bundle(ldir, published)
try:
if not bundle.exists():
return False
except OSError:
# Same reason as legacy_cache_root: a pair we cannot even stat is a miss.
return False
if not _try_load(ctx, logger, bundle = bundle, manifest_path = manifest_path):
return False
if _save_enabled(ctx.mode):
try:
ctx.dir.mkdir(parents = True, exist_ok = True)
# Under the name the copied manifest names, which is not ctx.bundle unless the legacy
# pair predates content addressing. Published like begin() does, so the context goes
# on naming the live bundle in the write root.
migrated = _manifest_bundle(ctx.dir, published)
_atomic_copy(bundle, migrated)
# Bundle first: a manifest without one reads as a miss, not an unservable hit.
_atomic_copy(manifest_path, ctx.manifest_path)
ctx.bundle = migrated
_info(logger, f"compile-cache: migrated legacy bundle for key {ctx.key}")
except OSError as exc:
_warn(logger, f"compile-cache: could not migrate legacy bundle: {exc}")
return True
def register_shape(ctx: Optional[CacheContext], shape: Any, *, static: bool) -> None:
"""Record a generation's (width, height, batch) against the bundle coverage.
@ -372,20 +473,37 @@ def register_shape(ctx: Optional[CacheContext], shape: Any, *, static: bool) ->
pass
def _try_load(ctx: CacheContext, logger: Any) -> bool:
def _try_load(
ctx: CacheContext,
logger: Any,
*,
bundle: Optional[Path] = None,
manifest_path: Optional[Path] = None,
) -> bool:
"""Validate and load a bundle pair, defaulting to the context's own (write-root) one."""
bundle = bundle if bundle is not None else ctx.bundle
manifest_path = manifest_path if manifest_path is not None else ctx.manifest_path
try:
manifest = json.loads(ctx.manifest_path.read_text(encoding = "utf-8"))
manifest = json.loads(manifest_path.read_text(encoding = "utf-8"))
except Exception as exc: # noqa: BLE001
_warn(logger, f"compile-cache: unreadable manifest: {exc}")
return False
# A manifest that decoded but is not an object. json.loads happily returns [] or null, and
# the .get() below would then raise AttributeError out of a function whose whole contract is
# that a bad cache entry is a miss. The legacy root makes this reachable: the bundle being
# validated was written by an older build, on a disk this run has never checked.
if not isinstance(manifest, dict):
_warn(logger, "compile-cache: manifest is not an object; ignoring")
return False
# Exact-match guard (defence in depth: torch also validates internally on load).
if manifest.get("env") != ctx.env_fp or manifest.get("model") != ctx.model_fp:
_warn(logger, "compile-cache: fingerprint mismatch; falling back to local compile")
return False
try:
data = ctx.bundle.read_bytes()
data = bundle.read_bytes()
except Exception as exc: # noqa: BLE001
_warn(logger, f"compile-cache: cannot read bundle: {exc}")
return False
@ -443,6 +561,30 @@ def _atomic_write(path: Path, data: bytes) -> None:
pass
def _atomic_copy(src: Path, dst: Path) -> None:
"""``shutil.copyfile`` into a temp file in *dst*'s directory, then ``os.replace``.
Same rule and same reason as _atomic_write: a plain copyfile onto the live name is visible
while it is still partial, and the migration below runs on the same interruptible path as a
save. A torn manifest costs only a miss, but a miss here means the cold compile the migration
exists to avoid, and two backends migrating one key would otherwise interleave their writes
into a single destination file.
"""
tmp: Optional[str] = None
try:
fd, tmp = tempfile.mkstemp(dir = str(dst.parent), prefix = f".{dst.name}.", suffix = _TEMP_SUFFIX)
os.close(fd)
shutil.copyfile(src, tmp)
os.replace(tmp, dst)
tmp = None
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def _collect_superseded(cdir: Path, logger: Any) -> list[str]:
"""Delete bundles no manifest names any more. Returns the names removed. Never raises.

View file

@ -8478,8 +8478,8 @@ class LlamaCppBackend:
@staticmethod
def _resolved_studio_root_and_is_legacy() -> "tuple[Optional[Path], bool]":
"""Resolve the Unsloth install root and classify it as the legacy
~/.unsloth/studio root vs. a custom (env/venv-inferred) root.
"""Resolve the directory llama.cpp hangs off and classify it as the
legacy ~/.unsloth/studio root vs. a custom (env/venv-inferred) root.
Returns (resolved_root, is_legacy). On any import/resolution failure the
root is treated as legacy and resolved_root is None -- callers must read
@ -8488,8 +8488,16 @@ class LlamaCppBackend:
(cleanup) so the two never disagree on which root is legacy.
"""
try:
from utils.paths.storage_roots import studio_root as _sr # noqa: WPS433
from utils.paths.storage_roots import ( # noqa: WPS433
studio_root as _sr,
unsloth_home as _uh,
)
# llama.cpp is a sibling of studio/ under the master root, the path run.py and
# main.py export, so a portable install is never the legacy layout.
master = _uh()
if master is not None:
return master, False
resolved = _sr()
legacy_studio = Path.home() / ".unsloth" / "studio"
try:

View file

@ -120,15 +120,20 @@ def resolve_ggml_model_id(model: Optional[str]) -> str:
def _managed_whisper_cpp_dir() -> Path:
"""`<STUDIO_HOME>/whisper.cpp` in custom mode, else `~/.unsloth/whisper.cpp`.
"""`<UNSLOTH_HOME>/whisper.cpp` when set, else `<STUDIO_HOME>/whisper.cpp` in custom mode,
else `~/.unsloth/whisper.cpp`.
Mirrors `managed_node_dir` / `_find_llama_server_binary` so managed runtimes
share one parent directory.
"""
legacy = Path.home() / ".unsloth" / "whisper.cpp"
try:
from utils.paths.storage_roots import studio_root
from utils.paths.storage_roots import studio_root, unsloth_home
# setup.sh installs whisper.cpp at <master root>, beside studio/. No env var bridges it.
master = unsloth_home()
if master is not None:
return master / "whisper.cpp"
resolved = studio_root()
legacy_studio = Path.home() / ".unsloth" / "studio"
try:

View file

@ -230,10 +230,18 @@ try:
_STUDIO_ROOT_RESOLVED = _studio_root().resolve()
except (OSError, ValueError):
_STUDIO_ROOT_RESOLVED = _studio_root()
if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
from utils.paths.storage_roots import unsloth_home as _unsloth_home
_MASTER_ROOT = _unsloth_home()
# A master root pointed at the legacy path still owns runtimes beside it, so the equality alone
# would skip the export and leave unsloth_zoo on ~/.unsloth/llama.cpp.
if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT or _MASTER_ROOT is not None:
if not os.environ.get("UNSLOTH_STUDIO_HOME"):
os.environ["UNSLOTH_STUDIO_HOME"] = str(_STUDIO_ROOT_RESOLVED)
_MANAGED_LLAMA_CPP_PATH = _STUDIO_ROOT_RESOLVED / "llama.cpp"
# Same derivation as run.py: the runtimes sit at the master root, beside studio/, and marking
# the wrong directory managed would make the bundled path look like an immutable override.
_MANAGED_ROOT = _MASTER_ROOT or _STUDIO_ROOT_RESOLVED
_MANAGED_LLAMA_CPP_PATH = _MANAGED_ROOT / "llama.cpp"
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_MANAGED_LLAMA_CPP_PATH)
# A CLI/desktop launcher may already have exported Unsloth's own install path.

View file

@ -1241,10 +1241,18 @@ try:
_STUDIO_ROOT_RESOLVED = _studio_root().resolve()
except (OSError, ValueError):
_STUDIO_ROOT_RESOLVED = _studio_root()
if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
from utils.paths.storage_roots import unsloth_home as _unsloth_home
_MASTER_ROOT = _unsloth_home()
# A master root pointed at the legacy path still owns runtimes beside it, so the equality alone
# would skip the export and leave unsloth_zoo on ~/.unsloth/llama.cpp.
if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT or _MASTER_ROOT is not None:
if not os.environ.get("UNSLOTH_STUDIO_HOME"):
os.environ["UNSLOTH_STUDIO_HOME"] = str(_STUDIO_ROOT_RESOLVED)
_MANAGED_LLAMA_CPP_PATH = _STUDIO_ROOT_RESOLVED / "llama.cpp"
# The runtimes sit at the master root, beside studio/; deriving from the Studio root would
# pin a path one level too deep for every worker.
_MANAGED_ROOT = _MASTER_ROOT or _STUDIO_ROOT_RESOLVED
_MANAGED_LLAMA_CPP_PATH = _MANAGED_ROOT / "llama.cpp"
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_MANAGED_LLAMA_CPP_PATH)
# The CLI and generated launchers can export this path before run.py starts.

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import hashlib
import json
import types
from pathlib import Path
import pytest
@ -342,6 +343,227 @@ def test_restore_inductor_dir(monkeypatch, tmp_path, fake_megacache):
assert os.environ["TORCHINDUCTOR_CACHE_DIR"] == "/tmp/prior-inductor" # restored
# -------------------------------------------------------------------------- legacy root
def _seed_legacy_bundle(monkeypatch, tmp_path, fake_megacache) -> tuple:
"""Write a bundle under a fake pre-relocation root, then hand back an upgraded install:
``(legacy_root, studio_home, legacy_bundle)``, with the environment already pointing at a
non-portable install whose new default root is empty. The bundle is content-addressed, so its
name comes from the seeding save rather than from a constant."""
legacy = tmp_path / "legacy" / "diffusion_compile_cache"
studio_home = tmp_path / "studio"
monkeypatch.setenv(cc._ENV_MODE, "auto")
monkeypatch.delenv(cc._ENV_SAVE, raising = False)
monkeypatch.setenv(cc._ENV_DIR, str(legacy))
seeded = cc.begin(transformer = _transformer(), **_BEGIN_KW)
assert cc.save(seeded) is True
monkeypatch.delenv(cc._ENV_DIR, raising = False)
monkeypatch.delenv("UNSLOTH_HOME", raising = False)
monkeypatch.delenv("UNSLOTH_PORTABLE", raising = False)
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(studio_home))
monkeypatch.setattr(cc, "_LEGACY_ROOT", legacy)
return legacy, studio_home, seeded.bundle
def test_legacy_bundle_is_read_but_the_new_root_takes_the_writes(
monkeypatch, tmp_path, fake_megacache
):
# An upgraded install stays warm without the home directory becoming the write root:
# begin() points TORCHINDUCTOR_CACHE_DIR at ctx.dir and save() writes every later bundle.
legacy, studio_home, legacy_bundle = _seed_legacy_bundle(monkeypatch, tmp_path, fake_megacache)
import os
ctx = cc.begin(transformer = _transformer(), **_BEGIN_KW)
assert ctx.hit is True # warm: the legacy bundle still counts
assert str(ctx.dir).startswith(str(studio_home))
assert str(os.environ["TORCHINDUCTOR_CACHE_DIR"]).startswith(str(studio_home))
assert legacy not in ctx.dir.parents
# The read fallback migrates: the pair now lives in the write root, byte-identical.
assert ctx.bundle.read_bytes() == legacy_bundle.read_bytes()
assert ctx.bundle.parent == ctx.dir
assert ctx.manifest_path.exists()
def test_an_interrupted_migration_leaves_no_partial_pair(monkeypatch, tmp_path, fake_megacache):
"""The migration copies through a temp file and renames, as every other write here does.
A plain copyfile onto the live name is visible while it is still partial. The manifest is the
commit point, so a torn one is read as a miss and costs the cold compile the migration exists
to avoid; a second backend migrating the same key would interleave its writes into the same
destination. Interruption is injected at the manifest copy, the later of the two.
"""
legacy, studio_home, legacy_bundle = _seed_legacy_bundle(monkeypatch, tmp_path, fake_megacache)
import shutil as _shutil
real_copyfile = _shutil.copyfile
calls = {"n": 0}
def exploding_copyfile(src, dst, *args, **kwargs):
calls["n"] += 1
if calls["n"] == 2:
# Half a manifest on disk, then death, exactly as interpreter exit kills the thread.
Path(dst).write_bytes(Path(src).read_bytes()[:3])
raise OSError("interrupted")
return real_copyfile(src, dst, *args, **kwargs)
monkeypatch.setattr(cc.shutil, "copyfile", exploding_copyfile)
ctx = cc.begin(transformer = _transformer(), **_BEGIN_KW)
# The read still served this run from the legacy pair.
assert ctx.hit is True
# Nothing partial is published under a name a later run reads: the temp file the copy died
# inside is not the live manifest.
if ctx.manifest_path.exists():
assert ctx.manifest_path.read_bytes() == (legacy / ctx.key / cc._MANIFEST_NAME).read_bytes()
leftovers = [q.name for q in ctx.dir.iterdir() if q.name.endswith(cc._TEMP_SUFFIX)]
assert leftovers == [], leftovers
def test_migrated_bundle_serves_the_next_run_without_the_legacy_root(
monkeypatch, tmp_path, fake_megacache
):
legacy, _, legacy_bundle = _seed_legacy_bundle(monkeypatch, tmp_path, fake_megacache)
assert cc.begin(transformer = _transformer(), **_BEGIN_KW).hit is True
import shutil
shutil.rmtree(legacy) # the old cache gets cleaned up: the warm start must survive
ctx = cc.begin(transformer = _transformer(), **_BEGIN_KW)
assert ctx.hit is True
def test_load_only_mode_reads_legacy_without_writing_to_it(monkeypatch, tmp_path, fake_megacache):
legacy, _, legacy_bundle = _seed_legacy_bundle(monkeypatch, tmp_path, fake_megacache)
monkeypatch.setenv(cc._ENV_SAVE, "0")
ctx = cc.begin(transformer = _transformer(), **_BEGIN_KW)
assert ctx.hit is True
assert not (
ctx.dir / legacy_bundle.name
).exists() # a read-only cache stays read-only, both roots
assert legacy_bundle.exists()
def test_key_absent_from_legacy_never_writes_into_it(monkeypatch, tmp_path, fake_megacache):
# The old fallback swapped the WHOLE root, so even an unheld key wrote into the home dir.
legacy, studio_home, legacy_bundle = _seed_legacy_bundle(monkeypatch, tmp_path, fake_megacache)
before = {p.name for p in legacy.iterdir()}
other = dict(_BEGIN_KW, family = "qwen-image")
ctx = cc.begin(transformer = _transformer(("QwenImageTransformerBlock",)), **other)
assert ctx.hit is False
assert cc.save(ctx) is True
assert str(ctx.bundle).startswith(str(studio_home))
assert {p.name for p in legacy.iterdir()} == before
def test_portable_mode_never_falls_back_to_the_home_directory(monkeypatch, tmp_path):
# begin() points TORCHINDUCTOR_CACHE_DIR inside this root, so a fallback here would write
# GBs into the host machine's home directory.
monkeypatch.delenv(cc._ENV_DIR, raising = False)
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
monkeypatch.setenv("UNSLOTH_HOME", str(tmp_path / "portable"))
legacy = tmp_path / "legacy" / "diffusion_compile_cache"
legacy.mkdir(parents = True)
monkeypatch.setattr(cc, "_LEGACY_ROOT", legacy)
root = cc.cache_root()
assert root != legacy
assert str(root).startswith(str(tmp_path / "portable"))
assert cc.legacy_cache_root() is None # not even read: not part of the install
def test_explicit_dir_override_ignores_the_legacy_root(monkeypatch, tmp_path):
monkeypatch.setenv(cc._ENV_DIR, str(tmp_path / "chosen"))
legacy = tmp_path / "legacy" / "diffusion_compile_cache"
legacy.mkdir(parents = True)
monkeypatch.setattr(cc, "_LEGACY_ROOT", legacy)
assert cc.cache_root() == tmp_path / "chosen"
assert cc.legacy_cache_root() is None
def test_an_unreadable_legacy_root_is_a_miss_not_a_failure(monkeypatch, tmp_path):
# The legacy root is the HOST's home, so it can be on a mount the new cache does not need.
# Path.exists raises for EACCES and EIO before 3.14, and begin() does not catch around here.
monkeypatch.delenv(cc._ENV_DIR, raising = False)
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path / "studio"))
monkeypatch.delenv("UNSLOTH_HOME", raising = False)
monkeypatch.delenv("UNSLOTH_PORTABLE", raising = False)
legacy = tmp_path / "legacy" / "diffusion_compile_cache"
monkeypatch.setattr(cc, "_LEGACY_ROOT", legacy)
def _raise(self, *a, **k):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(Path, "exists", _raise)
assert cc.legacy_cache_root() is None
def test_an_unreadable_legacy_bundle_pair_is_a_miss_not_a_failure(monkeypatch, tmp_path):
legacy = tmp_path / "legacy" / "diffusion_compile_cache"
legacy.mkdir(parents = True)
monkeypatch.setattr(cc, "legacy_cache_root", lambda: legacy)
ctx = cc.CacheContext(
key = "abc",
dir = tmp_path / "new" / "abc",
bundle = tmp_path / "new" / "abc" / "cache.bin",
manifest_path = tmp_path / "new" / "abc" / "manifest.json",
env_fp = "e",
model_fp = "m",
mode = "auto",
)
def _raise(self, *a, **k):
raise OSError(5, "Input/output error")
monkeypatch.setattr(Path, "exists", _raise)
assert cc._load_from_legacy(ctx, None) is False
def test_a_manifest_that_is_not_an_object_is_a_miss(monkeypatch, tmp_path, fake_megacache):
"""json.loads returns [] for "[]" and None for "null", and .get() on either raises.
_try_load's whole contract is that a bad cache entry is a miss, and the legacy root makes
this reachable in a way it was not before: the manifest being validated was written by an
older build, on a disk this run has never checked.
"""
legacy, _, legacy_bundle = _seed_legacy_bundle(monkeypatch, tmp_path, fake_megacache)
for payload in ("[]", "null", '"a string"', "42"):
for key_dir in legacy.iterdir():
(key_dir / cc._MANIFEST_NAME).write_text(payload, encoding = "utf-8")
ctx = cc.begin(transformer = _transformer(), **_BEGIN_KW)
assert ctx.hit is False, payload
def test_an_unreadable_write_root_falls_back_to_legacy(monkeypatch, tmp_path, fake_megacache):
"""Path.exists() raises rather than answering False when a parent denies traversal.
This branch moves the write root, so it can land on a directory the process does not own on
some machine. Unguarded, that exception left begin() before the legacy fallback could run,
which is the fallback the relocation depends on for a warm start.
"""
legacy, studio_home, legacy_bundle = _seed_legacy_bundle(monkeypatch, tmp_path, fake_megacache)
real_exists = Path.exists
def _raise_under_studio(self, *a, **k):
if str(self).startswith(str(studio_home)):
raise PermissionError(13, "Permission denied")
return real_exists(self, *a, **k)
monkeypatch.setattr(Path, "exists", _raise_under_studio)
ctx = cc.begin(transformer = _transformer(), **_BEGIN_KW)
assert ctx.hit is True
# ------------------------------------------------------------------ background save worker
# save_async hands the write to a single module-level daemon worker so a generation stops paying for it. These drive
# the worker directly, gating the fake save_cache_artifacts on Events so ordering is asserted, never slept on.

View file

@ -3,7 +3,9 @@
from __future__ import annotations
import json
import os
import subprocess
import sys
import threading
import time
@ -315,3 +317,174 @@ def test_diffusion_loader_calls_pin_the_cache_dir():
assert (
"cache_dir" in window or "kwargs" in window
), f"{rel}:{index} calls {call} without a pinned cache_dir"
# _stored_cache_home skips the database read when nothing uses one, so `unsloth train` does not
# build a studio.db on a machine that never opened Studio. Only a positively observed absence may
# license that skip. Driven in a subprocess: the skip needs storage.studio_db out of sys.modules.
_GUARD_PROBE = """
import json, os, sys
sys.path.insert(0, os.environ["FAKE_STORAGE"])
sys.path.insert(1, os.environ["BACKEND_DIR"])
from utils import hf_cache_settings
assert "storage.studio_db" not in sys.modules, "the probe must exercise the skip"
stored = hf_cache_settings._stored_cache_home()
print(json.dumps({"stored": None if stored is None else str(stored)}))
"""
_FAKE_STUDIO_DB = """
import os
from pathlib import Path
def get_app_setting(key, fallback = None):
Path(os.environ["READ_WITNESS"]).write_text("read", encoding = "utf-8")
if key == "hugging_face_cache_home":
return os.environ["STORED_CACHE_HOME"]
return fallback
"""
def _run_guard_probe(
tmp_path,
studio_home: Path,
stored: Path,
*,
probe: str | None = None,
extra_env: dict[str, str] | None = None,
) -> tuple[str | None, bool]:
"""Return (_stored_cache_home() answer, whether the database was read)."""
fake = tmp_path / "fake_storage"
(fake / "storage").mkdir(parents = True, exist_ok = True)
(fake / "storage" / "__init__.py").write_text("", encoding = "utf-8")
(fake / "storage" / "studio_db.py").write_text(_FAKE_STUDIO_DB, encoding = "utf-8")
witness = tmp_path / "read_witness"
witness.unlink(missing_ok = True)
environment = dict(os.environ)
environment.update(
BACKEND_DIR = _BACKEND_DIR,
FAKE_STORAGE = str(fake),
READ_WITNESS = str(witness),
STORED_CACHE_HOME = str(stored),
UNSLOTH_STUDIO_HOME = str(studio_home),
PYTHONPATH = "",
)
environment.update(extra_env or {})
for key in ("HF_HOME", "HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "HF_XET_CACHE"):
environment.pop(key, None)
result = subprocess.run(
[sys.executable, "-c", probe if probe is not None else _GUARD_PROBE],
capture_output = True,
text = True,
env = environment,
timeout = 120,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.strip().splitlines()[-1])["stored"], witness.exists()
def test_absent_studio_db_skips_the_database_read(tmp_path):
# The skip 912024e84 added must survive the tightening below: no studio.db answers None
# WITHOUT a connection, which is what stops the CLI creating a 250 KB database.
studio_home = tmp_path / "root" / "studio"
studio_home.mkdir(parents = True)
answer, was_read = _run_guard_probe(tmp_path, studio_home, tmp_path / "chosen")
assert answer is None
assert not was_read, "an absent database must not be opened"
@pytest.mark.parametrize("fixture", ["not_a_directory", "symlink_loop", "unreadable_parent"])
def test_uninspectable_studio_db_keeps_the_stored_cache_home(tmp_path, fixture):
# Path.exists reports ENOTDIR and ELOOP as absence on every release, and swallows EACCES
# from 3.14. Reading any as "no database" discards the cache home chosen in Settings.
chosen = tmp_path / "chosen"
studio_home = tmp_path / "root" / "studio"
if fixture == "not_a_directory":
studio_home.parent.mkdir(parents = True)
studio_home.write_text("", encoding = "utf-8")
elif fixture == "symlink_loop":
studio_home.parent.mkdir(parents = True)
studio_home.symlink_to(studio_home)
else:
studio_home.mkdir(parents = True)
(studio_home / "studio.db").write_bytes(b"")
os.chmod(studio_home, 0o000)
try:
answer, was_read = _run_guard_probe(tmp_path, studio_home, chosen)
finally:
if fixture == "unreadable_parent":
os.chmod(studio_home, 0o755)
# unreadable_parent is the case 3.14 newly breaks; the other two hold on every release.
assert was_read, "a database we could not inspect must still be read"
assert answer == str(chosen)
_WINDOWS_SHAPED_GUARD_PROBE = """
import errno, json, os, sys
# Windows reports a path whose PARENT is a file as ERROR_PATH_NOT_FOUND, which Python raises as
# FileNotFoundError; POSIX raises NotADirectoryError. Reproduce the error shape, since the Linux
# runners cannot produce it, and do it before hf_cache_settings is imported.
_NOT_A_DIR = os.environ["NOT_A_DIRECTORY"]
_real_stat = os.stat
def _stat(path, *args, **kwargs):
text = os.fspath(path)
if text != _NOT_A_DIR and text.startswith(_NOT_A_DIR + os.sep):
raise FileNotFoundError(errno.ENOENT, "The system cannot find the path specified", text)
return _real_stat(path, *args, **kwargs)
os.stat = _stat
sys.path.insert(0, os.environ["FAKE_STORAGE"])
sys.path.insert(1, os.environ["BACKEND_DIR"])
from utils import hf_cache_settings
assert "storage.studio_db" not in sys.modules, "the probe must exercise the skip"
stored = hf_cache_settings._stored_cache_home()
print(json.dumps({"stored": None if stored is None else str(stored)}))
"""
def test_a_studio_home_that_is_a_file_still_reads_the_database_on_windows(tmp_path):
"""The POSIX arm of this is test_uninspectable_studio_db_keeps_the_stored_cache_home
[not_a_directory], which passes there because ENOTDIR is its own exception type. Windows
reports the same situation as FileNotFoundError, so the skip read it as "no database stored"
and discarded the cache home chosen in Settings -- on Windows and nowhere else. Caught by the
cross-platform leg, held here by reproducing the error shape rather than the platform.
"""
studio_home = tmp_path / "root" / "studio"
studio_home.parent.mkdir(parents = True)
studio_home.write_text("", encoding = "utf-8")
chosen = tmp_path / "chosen"
answer, was_read = _run_guard_probe(
tmp_path,
studio_home,
chosen,
probe = _WINDOWS_SHAPED_GUARD_PROBE,
extra_env = {"NOT_A_DIRECTORY": str(studio_home)},
)
assert was_read, "a studio home we could not inspect was read as no database"
assert answer == str(chosen)
def test_a_studio_home_that_does_not_exist_still_skips_the_database_read(tmp_path):
"""The other half. The fix above must not widen the skip into the case it exists for: a
machine that has never opened Studio has no studio/ directory at all, and falling through
there builds the 250 KB database this guard was added to avoid."""
studio_home = tmp_path / "never-created" / "studio"
answer, was_read = _run_guard_probe(tmp_path, studio_home, tmp_path / "chosen")
assert answer is None
assert not was_read, "an absent studio home must not open a database"

File diff suppressed because it is too large Load diff

View file

@ -37,6 +37,9 @@ _STORAGE_ROOTS_PATH = Path(__file__).resolve().parent.parent / "utils/paths/stor
def _isolate_studio_home(monkeypatch, tmp_path):
# Keep _setup_cache_env's UV/VLLM mkdirs out of the real ~/.unsloth/studio.
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path / "studio"))
# _setup_cache_env writes os.environ directly, so a portable test would leak these forward.
for key in ("UNSLOTH_HOME", "UNSLOTH_PORTABLE", "TORCH_HOME"):
monkeypatch.delenv(key, raising = False)
@pytest.fixture(autouse = True)
@ -73,10 +76,27 @@ def _load_storage_roots():
def _clear_hf_env(monkeypatch):
for key in ("HF_HOME", "HF_HUB_CACHE", "HF_XET_CACHE", "HUGGINGFACE_HUB_CACHE"):
for key in (
"HF_HOME",
"HF_HUB_CACHE",
"HF_XET_CACHE",
"HUGGINGFACE_HUB_CACHE",
"HF_DATASETS_CACHE",
"HF_ASSETS_CACHE",
"HF_MODULES_CACHE",
):
monkeypatch.delenv(key, raising = False)
def _portable_install(monkeypatch, tmp_path):
"""Turn the fixture's studio home into a portable one and return its root."""
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
monkeypatch.delenv("UNSLOTH_PORTABLE", raising = False)
master = tmp_path / "portable"
monkeypatch.setenv("UNSLOTH_HOME", str(master))
return master
def test_custom_hf_home_seeds_hub_and_xet(monkeypatch, tmp_path):
_clear_hf_env(monkeypatch)
custom = tmp_path / "shared" / "huggingface"
@ -127,6 +147,179 @@ def test_whitespace_hf_home_falls_back_to_default(monkeypatch, tmp_path):
assert os.environ["HF_HUB_CACHE"] == str(tmp_path / "xdg" / "huggingface" / "hub")
def test_explicit_hf_home_keeps_the_datasets_and_assets_caches(monkeypatch, tmp_path):
# A user who names one HF_HOME gets one Hugging Face cache: assets and datasets derive
# from it, so pinning either under the portable root splits it across two volumes.
_clear_hf_env(monkeypatch)
master = _portable_install(monkeypatch, tmp_path)
chosen = tmp_path / "bigdisk" / "huggingface"
monkeypatch.setenv("HF_HOME", str(chosen))
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert os.environ["HF_HUB_CACHE"] == str(chosen / "hub")
assert os.environ["HF_XET_CACHE"] == str(chosen / "xet")
assert "HF_DATASETS_CACHE" not in os.environ
assert "HF_ASSETS_CACHE" not in os.environ
assert "HF_MODULES_CACHE" not in os.environ
# Containment is only given up for the root the user named.
assert os.environ["TORCH_HOME"].startswith(str(master))
def test_a_dedicated_cache_var_still_outranks_an_explicit_hf_home(monkeypatch, tmp_path):
_clear_hf_env(monkeypatch)
_portable_install(monkeypatch, tmp_path)
monkeypatch.setenv("HF_HOME", str(tmp_path / "bigdisk" / "huggingface"))
mine = tmp_path / "mine" / "datasets"
monkeypatch.setenv("HF_DATASETS_CACHE", str(mine))
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert os.environ["HF_DATASETS_CACHE"] == str(mine)
def test_portable_mode_without_an_explicit_hf_home_still_contains_them(monkeypatch, tmp_path):
# The other side: with no HF_HOME of the user's own, both derive from the host copy
# Unsloth leaves behind and would still write outside the volume.
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
master = _portable_install(monkeypatch, tmp_path)
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert os.environ["HF_DATASETS_CACHE"].startswith(str(master))
assert os.environ["HF_ASSETS_CACHE"].startswith(str(master))
# transformers derives this from HF_HOME, which stays on the host, and appends it to
# sys.path, so a trust_remote_code load would leave generated modules outside the volume.
assert os.environ["HF_MODULES_CACHE"].startswith(str(master))
def test_a_normal_install_leaves_the_modules_cache_where_transformers_looks(monkeypatch, tmp_path):
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.delenv("UNSLOTH_HOME", raising = False)
monkeypatch.delenv("UNSLOTH_PORTABLE", raising = False)
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert "HF_MODULES_CACHE" not in os.environ
def test_an_explicit_modules_cache_outranks_the_portable_default(monkeypatch, tmp_path):
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
_portable_install(monkeypatch, tmp_path)
mine = tmp_path / "mine" / "modules"
monkeypatch.setenv("HF_MODULES_CACHE", str(mine))
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert os.environ["HF_MODULES_CACHE"] == str(mine)
@pytest.mark.parametrize("hub_variable", ["HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE"])
def test_a_hub_only_override_still_contains_the_xet_cache(monkeypatch, tmp_path, hub_variable):
# huggingface_hub derives HF_XET_CACHE from HF_HOME, never from HF_HUB_CACHE, so a hub-only
# override would keep a portable install writing Xet data outside the volume.
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "home" / ".cache"))
master = _portable_install(monkeypatch, tmp_path)
chosen = tmp_path / "bigdisk" / "hub"
monkeypatch.setenv(hub_variable, str(chosen))
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert os.environ["HF_HUB_CACHE"] == str(chosen)
assert os.environ["HF_XET_CACHE"].startswith(str(master))
def test_an_explicit_xet_cache_outranks_the_portable_default(monkeypatch, tmp_path):
# Containment must not collapse into "always redirect": a named Xet cache is what was asked.
_clear_hf_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
_portable_install(monkeypatch, tmp_path)
mine = tmp_path / "bigdisk" / "xet"
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path / "bigdisk" / "hub"))
monkeypatch.setenv("HF_XET_CACHE", str(mine))
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert os.environ["HF_XET_CACHE"] == str(mine)
def test_a_normal_install_leaves_the_xet_cache_in_the_host_home(monkeypatch, tmp_path):
# Portable mode's promise alone: a normal install keeps the platform default so chunks
# shared with plain huggingface_hub still hit.
_clear_hf_env(monkeypatch)
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path / "bigdisk" / "hub"))
sr = _load_storage_roots()
sr._setup_cache_env()
import os
assert os.environ["HF_XET_CACHE"] == str(tmp_path / "xdg" / "huggingface" / "xet")
def test_the_libraries_really_derive_these_caches_from_hf_home(monkeypatch, tmp_path):
# Leaving them unset is only correct if huggingface_hub and datasets derive them from
# HF_HOME. Asked of a fresh interpreter: both snapshot their constants at import.
pytest.importorskip("datasets")
_clear_hf_env(monkeypatch)
_portable_install(monkeypatch, tmp_path)
chosen = tmp_path / "bigdisk" / "huggingface"
monkeypatch.setenv("HF_HOME", str(chosen))
sr = _load_storage_roots()
sr._setup_cache_env()
import json
import os
import subprocess
probe = subprocess.run(
[
sys.executable,
"-c",
"import json, huggingface_hub.constants as c, datasets.config as d;"
"print(json.dumps({'assets': c.HF_ASSETS_CACHE, 'hub': c.HF_HUB_CACHE,"
"'datasets': str(d.HF_DATASETS_CACHE)}))",
],
env = dict(os.environ),
capture_output = True,
text = True,
check = True,
)
result = json.loads(probe.stdout.strip().splitlines()[-1])
assert Path(result["assets"]) == chosen / "assets"
assert Path(result["datasets"]) == chosen / "datasets"
assert Path(result["hub"]) == chosen / "hub"
def test_unwritable_hf_home_does_not_crash(monkeypatch, tmp_path):
# HF_HOME under a regular file -> mkdir fails; startup must not crash and the
# env var is still set (HF surfaces a clear error later, at download time).

View file

@ -11,6 +11,8 @@ owns an explicit cache snapshot for each operation instead of trying to refresh
from __future__ import annotations
import os
import stat
import sys
import shutil
import tempfile
import threading
@ -91,7 +93,10 @@ def _environment_paths() -> Optional[HuggingFaceCachePaths]:
default_home = _default_cache_home()
hf_home = _canonical(explicit_home) if explicit_home else default_home
hub = _canonical(explicit_hub) if explicit_hub else hf_home / "hub"
xet = _canonical(explicit_xet) if explicit_xet else hf_home / "xet"
# huggingface_hub derives HF_XET_CACHE from HF_HOME, never from HF_HUB_CACHE, so a hub-only
# override would leave the chunk and shard caches in the host home.
xet_home = hf_home if explicit_home else (_portable_cache_home() or hf_home)
xet = _canonical(explicit_xet) if explicit_xet else xet_home / "xet"
controlling = next(
key
for key in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "HF_HOME")
@ -108,7 +113,54 @@ def _environment_paths() -> Optional[HuggingFaceCachePaths]:
return HuggingFaceCachePaths(display_home, hub, xet, "environment", controlling)
def _absence_is_real(path: Path) -> bool:
"""Whether a FileNotFoundError for *path* proves the file is not there.
On POSIX it always does: a parent component that is a file raises NotADirectoryError, and a
parent that cannot be traversed raises PermissionError, both of which are their own types.
Windows collapses every one of those into ERROR_PATH_NOT_FOUND, which Python surfaces as
FileNotFoundError, so on Windows the exception means "not there, OR somewhere above it is not
a directory, OR I could not look" -- and the caller must only skip its read for the first.
Decided by the nearest ancestor that can actually be stat'ed: a directory means the tree is
real and the file genuinely is not in it; anything else means a component is a file or a
reparse point and the path could never have existed; an ancestor that cannot be inspected at
all is not proof either way, so it reads as "not proven". Running out of ancestors means
nothing along the path exists, which is the machine that has never opened Studio -- the case
the skip is FOR, so the absence is real there.
"""
current = os.path.dirname(os.fspath(path))
while current:
try:
info = os.stat(current)
except FileNotFoundError:
parent = os.path.dirname(current)
if parent == current:
return True
current = parent
continue
except OSError:
return False
return stat.S_ISDIR(info.st_mode)
return True
def _stored_cache_home() -> Optional[Path]:
# get_app_setting CREATES and migrates studio.db, so an unconditional read built one on
# machines that had never opened Studio. os.stat, not Path.exists: only a positively observed
# absence may skip the read, and from 3.14 the predicates report EACCES and EIO as False.
# stat, not lstat: sqlite follows symlinks.
try:
if "storage.studio_db" not in sys.modules:
from utils.paths.storage_roots import studio_db_path
database = studio_db_path()
try:
os.stat(database)
except FileNotFoundError:
if _absence_is_real(database):
return None
except Exception: # noqa: BLE001 - fall through to the read on any doubt
pass
try:
from storage.studio_db import get_app_setting
from utils.account_context import OWNER, run_as
@ -147,6 +199,22 @@ def configured_cache_key() -> str:
return "default"
def _portable_cache_home() -> Optional[Path]:
"""The HF cache home a portable install uses, else None: a normal install keeps the platform
default so models shared with other tools are not re-downloaded. Lazy import, since
storage_roots reaches into this module during startup."""
try:
from utils.paths.storage_roots import cache_root, portable_mode
except ImportError:
return None
if not portable_mode():
return None
try:
return _canonical(cache_root() / "huggingface")
except (OSError, RuntimeError, ValueError):
return None
def get_hf_cache_paths() -> HuggingFaceCachePaths:
env_paths = _environment_paths()
if env_paths is not None:
@ -160,7 +228,8 @@ def get_hf_cache_paths() -> HuggingFaceCachePaths:
_canonical(xet) if xet else stored / "xet",
"studio",
)
home = _default_cache_home()
# Ranks below env vars and Settings: portable mode is a default, not an override.
home = _portable_cache_home() or _default_cache_home()
xet = _EXPLICIT_CACHE_ENV.get("HF_XET_CACHE")
return HuggingFaceCachePaths(
home,

View file

@ -38,12 +38,16 @@ def _version_meets_floor(version: str) -> bool:
def managed_node_dir() -> Path:
"""Isolated Node install dir. Mirrors ``_find_llama_server_binary``: shares a parent with llama.cpp, ``<STUDIO_HOME>`` in custom mode, else legacy ``~/.unsloth``."""
"""Isolated Node install dir. Mirrors ``_find_llama_server_binary``: shares a parent with llama.cpp, ``UNSLOTH_HOME`` when set, else ``<STUDIO_HOME>`` in custom mode, else legacy ``~/.unsloth``."""
legacy_node = Path.home() / ".unsloth" / "node"
try:
# Lazy import (mirrors _find_llama_server_binary) so this module stays importable even if utils.paths cannot be loaded.
from utils.paths.storage_roots import studio_root
from utils.paths.storage_roots import studio_root, unsloth_home
# setup.sh installs Node at <master root>/node, beside studio/.
master = unsloth_home()
if master is not None:
return master / "node"
resolved = studio_root()
legacy_studio = Path.home() / ".unsloth" / "studio"
try:

View file

@ -3,10 +3,13 @@
from __future__ import annotations
import importlib.util
import json
import ntpath
import os
import platform
import re
import stat as stat_module
import sys
import threading
from pathlib import Path, PurePosixPath, PureWindowsPath
@ -53,21 +56,196 @@ def _infer_studio_home_from_venv() -> Path | None:
return candidate
def studio_root() -> Path:
"""Unsloth install root.
def _resolved(value: str) -> Path:
try:
return Path(value).expanduser().resolve()
except (OSError, ValueError):
return Path(value).expanduser()
Priority: UNSLOTH_STUDIO_HOME, then STUDIO_HOME alias, then sys.prefix
inference, then legacy ~/.unsloth/studio. UNSLOTH_STUDIO_HOME wins if
both are set (specific signal beats generic alias).
MASTER_ROOT_NOTE = ".unsloth-master-root"
_recorded_master_roots: dict[str, Path | None] = {}
_recorded_master_lock = threading.Lock()
def _studio_root_without_master() -> Path:
"""The studio root as studio_root() would give it with the master root out of the picture.
studio_root() asks unsloth_home(), so the note reader cannot ask studio_root() back.
"""
override = (os.environ.get("UNSLOTH_STUDIO_HOME") or "").strip()
if not override:
override = (os.environ.get("STUDIO_HOME") or "").strip()
if override:
return _resolved(override)
inferred = _infer_studio_home_from_venv()
if inferred is not None:
return inferred
return Path.home() / ".unsloth" / "studio"
def _is_legacy_studio_tree(studio: Path) -> bool:
"""Whether the tree a note was read from is the ordinary `~/.unsloth/studio` install.
A legacy-rooted install has no master root, so a note there says something no reader needs;
both uninstallers already refuse the value it produces. Two things it broke: a one-command
`UNSLOTH_HOME=$HOME/.unsloth` left a note that kept `portable_mode()` true for good, moving
the one cache this module promises not to move; and a note naming any ANCESTOR of the tree
(`$HOME` passes containment) reintroduced through the filesystem what `process.rs` scrubs
from every managed spawn, which no `env_remove` can reach. Hence keyed on the TREE, not on
the recorded value. An explicit UNSLOTH_HOME never gets here: unsloth_home() returns the
override first.
"""
try:
return studio.resolve() == (Path.home() / ".unsloth" / "studio").resolve()
except (OSError, RuntimeError, ValueError):
return False
def _recorded_master_root() -> Path | None:
"""The master root setup recorded inside the studio tree, or None.
UNSLOTH_HOME is settable for a single command (`UNSLOTH_HOME=/mnt/portable unsloth studio
update`) and nothing persists it, so without this every later launch resolved node, llama.cpp
and whisper.cpp somewhere other than the siblings of studio/ setup created. setup already
writes the root to share/.unsloth-master-root for the uninstallers.
Honoured only while it still describes reality: the root must exist, the Studio directory the
note was read from must lie INSIDE it, and it must not be the legacy default. Containment
rather than an exact <root>/studio match, since the flat layout (both variables naming one
directory) is supported; the uninstallers apply the same rule.
"""
studio = _studio_root_without_master()
key = str(studio)
with _recorded_master_lock:
if key in _recorded_master_roots:
return _recorded_master_roots[key]
found: Path | None = None
# A miss is cached; a failure to LOOK is not. EACCES on share/ or EIO off a mount that came
# back is a fact about one instant, and cached it pinned the backend to the legacy runtime
# paths for its whole lifetime, with forget_recorded_master_root() the only way out and
# nothing calling it in production.
definitive = True
try:
recorded = (studio / "share" / MASTER_ROOT_NOTE).read_text(encoding = "utf-8").strip()
except (FileNotFoundError, NotADirectoryError, ValueError, UnicodeDecodeError):
recorded = ""
except OSError:
recorded = ""
definitive = False
if recorded:
master = _resolved(recorded)
try:
return Path(override).expanduser().resolve()
here = studio.resolve()
if (
master.is_dir()
and (here == master or master in here.parents)
and not _is_legacy_studio_tree(studio)
):
found = master
except (OSError, ValueError):
return Path(override).expanduser()
found = None
if definitive:
with _recorded_master_lock:
_recorded_master_roots[key] = found
return found
def forget_recorded_master_root() -> None:
"""Drop the cached note reading. For tests, and for an installer that writes the note into
a tree this process has already looked at."""
with _recorded_master_lock:
_recorded_master_roots.clear()
def unsloth_home() -> Path | None:
"""The master root, or None. STUDIO_HOME is its studio/ child; llama.cpp, node and
whisper.cpp are SIBLINGS of studio/, the layout setup.sh already gives UNSLOTH_HOME."""
override = (os.environ.get("UNSLOTH_HOME") or "").strip()
if override:
return _resolved(override)
return _recorded_master_root()
_PORTABLE_ON_VALUES = ("1", "true", "yes", "on")
_PORTABLE_OFF_VALUES = ("0", "false", "off", "no")
# portable_mode() runs on every cache-var lookup, so warn once per process, not once per call.
_warned_unrecognized_portable = False
def _warn_unrecognized_portable(raw: str) -> None:
global _warned_unrecognized_portable
if _warned_unrecognized_portable:
return
_warned_unrecognized_portable = True
# Not "to leave it off": an off value declines to turn portable mode on by itself, it does
# not veto the master root that carries it.
logger.warning(
"Ignoring UNSLOTH_PORTABLE=%r: expected one of %s to turn portable mode on, or one of "
"%s to leave that choice to UNSLOTH_HOME, which turns it on when it names a master root.",
raw,
"/".join(_PORTABLE_ON_VALUES),
"/".join(_PORTABLE_OFF_VALUES),
)
def portable_mode() -> bool:
"""Whether this install keeps everything under one directory. Implied by UNSLOTH_HOME, and
settable on its own so an existing install can opt in."""
# Case-folded: UNSLOTH_PORTABLE=FALSE read as "on" moves the caches out from under a user
# who asked for the opposite.
raw = (os.environ.get("UNSLOTH_PORTABLE") or "").strip()
value = raw.lower()
if value in _PORTABLE_ON_VALUES:
return True
if value and value not in _PORTABLE_OFF_VALUES:
_warn_unrecognized_portable(raw)
# Unrecognized means no opinion, as an off value does: neither vetoes a real master root.
return unsloth_home() is not None
# Warned once per distinct pair rather than per call: studio_root() runs many times a request,
# and both variables can be rebound inside one process.
_warned_root_conflicts: set[tuple[str, str]] = set()
def _warn_root_conflict(resolved: Path, master: Path) -> None:
key = (str(resolved), str(master))
if key in _warned_root_conflicts:
return
_warned_root_conflicts.add(key)
# Not fatal: failing here would break a resolver called at import time.
logger.warning(
"UNSLOTH_STUDIO_HOME (%s) is outside UNSLOTH_HOME (%s); this "
"install is not self-contained.",
resolved,
master,
)
def studio_root() -> Path:
"""Unsloth install root.
Priority: UNSLOTH_STUDIO_HOME, then STUDIO_HOME alias, then UNSLOTH_HOME's studio/ child, then
sys.prefix inference, then legacy ~/.unsloth/studio. UNSLOTH_STUDIO_HOME outranks both: it
names this exact directory, while the others only name the tree it sits in.
"""
override = (os.environ.get("UNSLOTH_STUDIO_HOME") or "").strip()
if not override:
override = (os.environ.get("STUDIO_HOME") or "").strip()
if override:
resolved = _resolved(override)
master = unsloth_home()
# Path.parents excludes the path itself, so a flat layout (both naming one root) would
# otherwise warn on every call.
if master is not None and master != resolved and master not in resolved.parents:
_warn_root_conflict(resolved, master)
return resolved
master = unsloth_home()
if master is not None:
return master / "studio"
inferred = _infer_studio_home_from_venv()
if inferred is not None:
return inferred
@ -510,6 +688,367 @@ def well_known_model_dirs() -> list[Path]:
return _existing_dirs(candidates, resolve = True)
def _user_set_hf_home() -> bool:
"""Whether HF_HOME was set by the user rather than seeded by Unsloth.
initialize_hf_cache_environment fills a blank HF_HOME first, so the import-time snapshot is
the only record of who chose it.
"""
try:
from utils.hf_cache_settings import _EXPLICIT_CACHE_ENV
except ImportError:
return False
return bool(_EXPLICIT_CACHE_ENV.get("HF_HOME"))
def _portable_cache_defaults(root: Path) -> dict[str, str]:
"""Cache vars that only move under the root in portable mode, since they hold shared user data
or large re-downloads. The hub and xet caches are handled inside hf_cache_settings, which must
agree with the Settings UI. HF_HOME never moves: credentials should not follow a cache onto a
removable volume.
"""
if not portable_mode():
return {}
if _user_set_hf_home():
# Assets, datasets and modules derive from an explicit HF_HOME: pinning them splits one
# deliberately chosen cache across two volumes.
return {"TORCH_HOME": str(root / "torch")}
return {
"HF_DATASETS_CACHE": str(root / "huggingface" / "datasets"),
"HF_ASSETS_CACHE": str(root / "huggingface" / "assets"),
# transformers.utils.hub reads this at import and appends it to sys.path, so a
# trust_remote_code load leaves generated modules on the host without it.
"HF_MODULES_CACHE": str(root / "huggingface" / "modules"),
"TORCH_HOME": str(root / "torch"),
}
def _triton_cache_defaults(root: Path) -> dict[str, str]:
"""Triton's regenerable directories, named one at a time.
Not TRITON_HOME (triton-lang/triton#4265): it would take ~/.triton/override with it, and a
TRITON_KERNEL_OVERRIDE=1 run would silently fall back to the compiler's own output. The
dedicated variables outrank the derivation (triton/knobs.py cache_knobs).
"""
if (os.environ.get("TRITON_HOME") or "").strip():
# Whoever moved the whole tree meant the cache with it; TRITON_CACHE_DIR would outrank it.
return {}
return {
"TRITON_CACHE_DIR": str(root / "triton"),
# A sibling, not a child: dumps are asked for by hand and should outlive a cache wipe.
"TRITON_DUMP_DIR": str(root / "triton-dump"),
}
def _nothing_at(path: Path, *, ending: str = "") -> bool:
"""Whether *path* positively holds nothing, the only state that licenses a pin.
Not Path.exists/is_dir/glob: they report ENOTDIR, ELOOP, EBADF (and from 3.14 EACCES and EIO)
as absence, so a directory we merely cannot inspect would read as empty and the redirect would
hide the user's files. os.lstat and os.scandir raise on all of it. lstat rather than stat: a
dangling symlink is still something the user put there. *ending* asks instead whether the
DIRECTORY holds no entry with that suffix; pass it lowercase, as Windows glob matched
case-insensitively.
"""
try:
if not ending:
os.lstat(path)
return False
# lstat, not scandir, at the directory itself: scandir follows a link, so an empty or
# unmounted target would read as an empty directory and hide what is placed there.
if stat_module.S_ISLNK(os.lstat(path).st_mode) or _is_reparse_point(path):
return False
with os.scandir(path) as entries:
return not any(entry.name.lower().endswith(ending) for entry in entries)
except FileNotFoundError:
# Not absence on its own: Windows collapses NotADirectoryError and PermissionError into
# ERROR_PATH_NOT_FOUND, so with a FILE where ~/.matplotlib belongs POSIX declined the pin
# and Windows took it.
return _nothing_above(path)
except (OSError, ValueError):
# Could not look. Declining a pin costs a shared cache directory; taking one wrongly
# hides the configuration or datasets underneath it.
return False
def _nothing_above(path: Path) -> bool:
"""Whether a FileNotFoundError for *path* really means nothing is there.
Decided by the nearest ancestor that can be stat'ed: a directory means real absence, anything
else means a component is a file or a reparse point, and an ancestor that cannot be inspected
is proof of neither. Same rule as hf_cache_settings._absence_is_real.
"""
current = os.path.dirname(os.fspath(path))
while current:
try:
info = os.stat(current)
except FileNotFoundError:
parent = os.path.dirname(current)
if parent == current:
return True
current = parent
continue
except OSError:
return False
return stat_module.S_ISDIR(info.st_mode)
return True
def _matplotlib_config_dir() -> Path | None:
"""Where matplotlib reads matplotlibrc and stylelib/ from when MPLCONFIGDIR is unset, or None
when this machine has no such directory. Mirrors _get_config_or_cache_dir: XDG config base on
Linux/FreeBSD, %LOCALAPPDATA% on Windows but keeping a pre-existing ~/.matplotlib there.
None means matplotlib falls back to a temporary directory, so a pin can strand nothing. The
Windows branch is matplotlib 3.11's and the pinned 3.10.9 uses ~/.matplotlib; that
disagreement only ever costs us the pin, it never hides a file matplotlib reads.
"""
# XDG_CONFIG_HOME ahead of Path.home(), as _get_xdg_config_dir does: an install that sets it
# has a config dir even with no resolvable home.
if sys.platform.startswith(("linux", "freebsd")):
base = (os.environ.get("XDG_CONFIG_HOME") or "").strip()
if base:
return Path(base) / "matplotlib"
try:
home = Path.home()
except (OSError, RuntimeError):
return None
if sys.platform.startswith(("linux", "freebsd")):
return home / ".config" / "matplotlib"
if sys.platform == "win32":
legacy = home / ".matplotlib"
# Not is_dir(): an unreadable ~/.matplotlib raises before 3.14 and reads as absent from
# 3.14, which sends the probe to a %LOCALAPPDATA% that is empty on exactly the machines
# where the old directory holds the rc.
if not _nothing_at(legacy):
return legacy
local_app_data = (os.environ.get("LOCALAPPDATA") or "").strip()
return Path(local_app_data) / "matplotlib" if local_app_data else legacy
return home / ".matplotlib"
def _matplotlib_defaults(root: Path) -> dict[str, str]:
"""MPLCONFIGDIR, unless matplotlib's own directory holds user configuration.
The one variable moves the CONFIG directory as well as the cache, so pinning it would drop a
user matplotlibrc and every custom style, silently changing the loss plots
core/training/training.py draws. matplotlib creates the directory on import, so contents
decide, not existence.
"""
managed = root / "matplotlib"
pinned = {"MPLCONFIGDIR": str(managed)}
# Our own configuration first: the legacy probe re-runs every launch, so on its own a
# ~/.config/matplotlib created later by another tool takes over a matplotlibrc written HERE,
# and the plot style flips back and forth across launches.
if not (
_nothing_at(managed / "matplotlibrc")
and _nothing_at(managed / "stylelib", ending = ".mplstyle")
):
return pinned
config_dir = _matplotlib_config_dir()
if config_dir is not None and not (
_nothing_at(config_dir / "matplotlibrc")
and _nothing_at(config_dir / "stylelib", ending = ".mplstyle")
):
return {}
return pinned
def _is_reparse_point(path: Path) -> bool:
"""A Windows junction, which is_symlink() answers False for.
os.path.isjunction arrived in 3.12 and the Studio venv can be 3.11, so its absence means
"no junctions to worry about" rather than an error.
"""
isjunction = getattr(os.path, "isjunction", None)
if isjunction is None:
return False
try:
return bool(isjunction(path))
except (OSError, ValueError):
return False
def _data_designer_in_use(home: Path) -> bool:
"""Whether the managed Data Designer home holds work worth keeping.
_setup_cache_env creates this directory and its managed-assets child on the first launch, so
existence alone would pin a home nobody has written to. A home we cannot list counts as in
use: dropping the pin would hide the recipes here behind a re-seeded ~/.data-designer.
"""
try:
entries = list(home.iterdir())
except FileNotFoundError:
return False
except (OSError, ValueError):
return True
for entry in entries:
try:
if entry.name != "managed-assets":
return True
# A link here is the user redirecting their assets, which is state; is_dir() follows
# it, so an empty target read as the untouched layout and took the redirect with it.
if entry.is_symlink() or _is_reparse_point(entry):
return True
if not entry.is_dir():
return True
if any(entry.iterdir()):
return True
except FileNotFoundError:
continue
except (OSError, ValueError):
return True
return False
def _data_designer_defaults(root: Path) -> dict[str, str]:
"""Data Designer's home, unless the user already has one.
Not a cache: repointing an existing ~/.data-designer hides its yaml configs and multi-GB
parquet behind a re-seeded default. MANAGED_ASSETS_PATH derives from the home, so it is only
ours to set when the home is. data_designer.config.utils.constants reads both at IMPORT time.
"""
if (os.environ.get("DATA_DESIGNER_HOME") or "").strip():
return {}
home = root.parent / "data-designer"
pinned = {
"DATA_DESIGNER_HOME": str(home),
"DATA_DESIGNER_MANAGED_ASSETS_PATH": str(home / "managed-assets"),
}
# Our own populated home first: the legacy probe re-runs every launch, so a ~/.data-designer
# created later by a standalone run would otherwise take over the recipes written here.
if _data_designer_in_use(home):
return pinned
try:
legacy = Path.home() / ".data-designer"
except (OSError, RuntimeError):
# No home, so the pin can hide nothing: data_designer's default is off the same call.
return pinned
return pinned if _nothing_at(legacy) else {}
def _path_safe(value: str) -> str:
"""A directory-name-safe rendering of a build field."""
return re.sub(r"[^A-Za-z0-9.]+", "-", value)
def _torch_version_fields() -> dict[str, str]:
"""The build identity torch.version exposes, read without importing torch.
Runs on a startup path that executes before torch exists in a fresh venv, so it stays a file
read. torch/version.py assigns only literals, but whether it annotates them
(``cuda: Optional[str] = ...``) varies by release, hence a regex rather than ast or exec.
"""
origin = getattr(importlib.util.find_spec("torch"), "origin", None)
if not origin:
return {}
text = (Path(origin).parent / "version.py").read_text(encoding = "utf-8")
found = re.findall(
r"""^(__version__|debug|cuda|hip|xpu)\s*(?::[^=\n]+)?=\s*([^\s#]+)""",
text,
re.MULTILINE,
)
return {name: value.strip("'\"") for name, value in found}
def _torch_accelerator_tag(fields: dict[str, str]) -> str:
"""torch's own cu_str, widened to the runtimes it declines to name: cpp_extension picks 'cpu'
whenever version.cuda is unset, filing a ROCm build beside a real CPU one. hip first, as
torch main prioritises ROCm."""
for field, prefix in (("hip", "rocm"), ("cuda", "cu"), ("xpu", "xpu")):
value = fields.get(field)
if not value or value == "None":
continue
# torch spells the CUDA version without dots: 12.8 -> cu128.
return prefix + _path_safe(value.replace(".", "") if field == "cuda" else value)
return "cpu"
def _torch_runtime_tag() -> str:
"""Name the extension cache after the runtime that builds into it.
torch.utils.cpp_extension._get_build_directory appends a ``py<ver>_<accelerator>`` folder to
the DEFAULT root only, never to a TORCH_EXTENSIONS_DIR we supply, so pinning a flat path drops
the isolation that keeps a py313/cu128 build from being loaded by a py312/cu126 one. The
accelerator comes from the generated cuda/hip fields, not from a local version segment:
conda-forge's CPU and CUDA packages of one release share a __version__. __version__ stays in
the tag anyway, since segments like +cpu.cxx11.abi mark ABI splits no other field records.
"""
tag = f"py{sys.version_info.major}{sys.version_info.minor}{getattr(sys, 'abiflags', '')}"
# Architecture too, which torch's py<ver>_<cu_str> omits: an arm64 and a Rosetta x86_64
# python agree on every other field, so ninja reads the other's build as up to date.
tag += "_" + _path_safe(f"{sys.platform}-{platform.machine() or 'unknown'}")
try:
fields = _torch_version_fields()
except (ImportError, OSError, ValueError, AttributeError):
# No torch yet, or a half-built tree; the interpreter tag alone still isolates.
return tag
if not fields:
return tag
tag += "_" + _torch_accelerator_tag(fields)
version = fields.get("__version__")
if version:
tag += "_" + _path_safe(version)
if fields.get("debug") == "True":
# A debug build keeps the soname of a release one but not its ABI.
tag += "_debug"
return tag
# Caches whose path is pasted into a compiler command line, unquoted, by somebody else's code:
# torch/_inductor/cpp_builder.py joins the g++ invocation with spaces and reparses it with
# shlex.split, so a root containing a space splits in two and the build fails outright
# ("g++: fatal error: input file .../my is the same as output file"). The quoting is PyTorch's
# to fix; skipping the pin just returns to the temporary directory Inductor used before. Not a
# rare shape: "C:\Users\First Last" is an ordinary Windows account name.
_TOOLCHAIN_PATH_KEYS = frozenset(
{
"TORCHINDUCTOR_CACHE_DIR",
"TORCH_EXTENSIONS_DIR",
"TRITON_CACHE_DIR",
"TRITON_DUMP_DIR",
"TRITON_HOME",
"CUDA_CACHE_PATH",
}
)
def _usable_dir(value: str) -> bool:
"""Whether a path we generated is actually a directory the toolchain can compile into.
A real create, not is_dir() alone. torch falls back to its own temporary directory only when
the variable is UNSET, then calls os.makedirs(exist_ok = True), which succeeds on an existing
read-only directory and leaves every later write to fail
(torch/_inductor/runtime/cache_dir_utils.py), so publishing an unwritable path is worse than
publishing none. Same rule install.sh states for the uv cache probe.
"""
try:
if not Path(value).is_dir():
return False
except (OSError, ValueError):
return False
try:
handle, probe = tempfile.mkstemp(dir = value, prefix = ".unsloth-write-probe.")
except (OSError, ValueError):
return False
# Guarded like the unlink below: an EIO or ENOSPC on close escaping here would take the
# whole backend start with it, for a probe whose job is to answer yes or no.
try:
os.close(handle)
except OSError:
return False
try:
os.unlink(probe)
except OSError:
pass
return True
def _toolchain_unsafe(key: str, value: str) -> bool:
"""Whether pinning *key* to *value* would hand a compiler a path it cannot parse."""
return key in _TOOLCHAIN_PATH_KEYS and any(ch.isspace() for ch in value)
def _setup_cache_env() -> None:
"""Set cache env vars for HuggingFace, uv, and vLLM.
@ -529,11 +1068,37 @@ def _setup_cache_env() -> None:
# cache landed in the user home. Must be set before unsloth_zoo.compiler imports: it reads the value at import
# time and puts it on sys.path.
"UNSLOTH_COMPILE_LOCATION": str(root.parent / "compiled_cache"),
# Regenerable and process-scoped. Shared user data (HF hub cache, torch.hub checkpoints)
# stays where the other tools look, except in portable mode.
"TORCHINDUCTOR_CACHE_DIR": str(root / "torchinductor"),
# Tagged: torch only inserts its own ABI folder when TORCH_EXTENSIONS_DIR is unset, so a
# flat pin would let two runtimes sharing this root import each other's .so.
"TORCH_EXTENSIONS_DIR": str(root / "torch-extensions" / _torch_runtime_tag()),
# NVIDIA's JIT compile cache; ~/.nv/ComputeCache otherwise.
"CUDA_CACHE_PATH": str(root / "cuda"),
"NUMBA_CACHE_DIR": str(root / "numba"),
}
defaults.update(_matplotlib_defaults(root))
defaults.update(_triton_cache_defaults(root))
defaults.update(_data_designer_defaults(root))
defaults.update(_portable_cache_defaults(root))
for key, value in defaults.items():
# Blank counts as unset: an inherited KEY= would otherwise pin the cache to "", which puts an empty entry on
# sys.path and sends the compiler to the system temp directory instead.
if not (os.environ.get(key) or "").strip():
# An explicit value is still honoured above: the caller chose it, and only a default
# we invented is ours to withhold.
if _toolchain_unsafe(key, value):
logger.debug(
"leaving %s unset: %s contains whitespace, which the C++ builders "
"paste into a command line unquoted",
key,
value,
)
# Popped, not blanked: Inductor reads " " as a relative path and hands it to
# the very command line being refused here.
os.environ.pop(key, None)
continue
os.environ[key] = value
# Best-effort: a non-writable custom HF_HOME must not crash startup
try:
@ -550,6 +1115,14 @@ def _setup_cache_env() -> None:
(Path(value) / CACHE_MARKER).touch(exist_ok = True)
except (OSError, ImportError):
pass
# A toolchain path we invented and could not make is worse than none: torch treats
# it as authoritative and every compile fails, where unset would have used the
# library's temporary cache. Only these keys -- UNSLOTH_COMPILE_LOCATION unset falls
# back to a CWD-relative name, the bug that pin exists to fix, and the data roots
# have no library default to fall back to.
if key in _TOOLCHAIN_PATH_KEYS and not _usable_dir(value):
logger.debug("leaving %s unset: %s is not a usable directory", key, value)
os.environ.pop(key, None)
def setup_cache_env() -> None:

View file

@ -763,13 +763,32 @@ function Test-UnslothCmdShimFile {
return ($text -like "*unsloth-studio-managed-launcher*" -and $text -like "*from unsloth_cli import app*")
}
# Explicit staging root, shared default cache, or the custom Unsloth home's tree.
# The master root storage_roots.unsloth_home() reads. llama.cpp, node and whisper.cpp sit
# BESIDE studio\ under it, so deriving them from $StudioHome would put them one level below
# where every runtime resolver looks.
function Get-MasterRootOverride {
if ([string]::IsNullOrWhiteSpace($env:UNSLOTH_HOME)) { return $null }
$value = $env:UNSLOTH_HOME.Trim()
if ($value -eq "~") {
$value = $env:USERPROFILE
} elseif ($value -like "~/*" -or $value -like "~\*") {
$value = (Join-Path $env:USERPROFILE $value.Substring(1).TrimStart('/', '\'))
}
return (Get-CanonicalDir -Path $value)
}
# Explicit staging root, the master root, the shared default cache, or the custom Unsloth
# home's tree.
function Get-ManagedLlamaCppDir {
param([AllowNull()][string]$StagingRoot = $null)
if ($StagingRoot) {
return (Join-Path $StagingRoot "llama.cpp")
}
$masterRoot = Get-MasterRootOverride
if ($masterRoot) {
return (Join-Path $masterRoot "llama.cpp")
}
if (-not (Test-StudioHomeIsCustom)) {
return (Join-Path $env:USERPROFILE ".unsloth\llama.cpp")
}
@ -785,8 +804,9 @@ function Invoke-ManagedLlamaCppPreflight {
$dir = Get-ManagedLlamaCppDir -StagingRoot $StagingRoot
if ((Get-LlamaCppInstallReadState -Path $dir) -ne "Denied") { return $null }
Write-StudioLine ""
# A denied custom home cannot be claimed as an Unsloth-managed cache.
$homeIsCustom = Test-StudioHomeIsCustom
# A denied custom home cannot be claimed as an Unsloth-managed cache. Computed rather
# than read off $RuntimeRootIsCustom: this runs beside the line that defines it.
$homeIsCustom = (Test-StudioHomeIsCustom) -or [bool](Get-MasterRootOverride)
# Preserve user-supplied wording when either override names this tree, or
# names a build inside it: moving or deleting this folder takes that build
# with it, and the later --with-llama-cpp-dir check then aborts on a path
@ -2792,9 +2812,170 @@ $StudioStaleMarker = ".unsloth-studio-stale"
$NoTorchMarker = ".unsloth-no-torch"
$LegacyStudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
$StudioHomeIsCustom = Test-StudioHomeIsCustom
# Ownership applies to node\, llama.cpp\ and whisper.cpp\ whenever a master root moves them,
# even with $StudioHome left at the legacy path: $StudioHomeIsCustom is false there, and false
# is what licenses the installers to replace and delete without the Unsloth-owned marker. The
# Studio home itself, and the venvs under it, keep the other flag. Mirrors setup.sh.
$RuntimeRootIsCustom = $StudioHomeIsCustom
# Keyed on where the runtimes LAND, not on whether a master root was named: UNSLOTH_HOME set to
# the root an install already uses moves nothing, and calling that custom would demand an owner
# marker from a legacy source-built .unsloth\llama.cpp that predates markers. The comparison
# setup.sh makes; taking any non-empty master root instead was a real divergence.
#
# Staging is excluded for the same reason, not as an exception to it: Get-ManagedLlamaCppDir
# gives $StageRoot precedence over the master root, so during a staged update the master root is
# not where anything lands. ASSIGNED, not merely raised, or a custom Studio home whose runtimes
# land in the legacy root keeps the flag true and demands markers from exactly the pre-marker
# tree this comparison exists to spare.
$_masterRootForOwnership = Get-MasterRootOverride
if ($_masterRootForOwnership -and -not $StageRoot) {
# Canonicalised the same way Get-MasterRootOverride canonicalises its answer, or a
# junctioned profile compares unequal to itself. Case-insensitively, since two Windows
# paths differing only in case are one directory.
$_legacyRuntimeRoot = Get-CanonicalDir -Path (Join-Path $env:USERPROFILE ".unsloth")
$RuntimeRootIsCustom = ($_masterRootForOwnership -ine $_legacyRuntimeRoot)
}
$LlamaCppDir = Get-ManagedLlamaCppDir -StagingRoot $StageRoot
$UnslothHome = Split-Path -Parent $LlamaCppDir
# Record the master root inside the Studio tree, for the uninstaller: UNSLOTH_HOME can be set
# for a single command, and without the note a later uninstall removed the Studio tree and
# stranded multi-gigabyte runtimes beside it.
#
# Only for a master root (the other branches derive the root from paths the uninstaller already
# knows), and only when a reader will honour it. setup.sh's _master_root_note_is_honoured holds
# the same two rules for the same reasons; keep them together.
function Test-MasterRootNoteIsHonoured {
param([string]$Root, [string]$StudioRoot)
$norm = Get-CanonicalDir -Path $Root
if (-not $norm) { return $false }
$here = Get-CanonicalDir -Path $StudioRoot
if (-not $here) { return $false }
# The legacy default is the one root every reader finds without help, and both uninstallers
# refuse it outright. Recording it turned a one-command UNSLOTH_HOME=%USERPROFILE%\.unsloth
# into a permanent portable install: every later bare launch read it back and HF_HUB_CACHE
# moved off the shared Hugging Face cache this change promises not to move.
$legacy = Get-CanonicalDir -Path (Join-Path $env:USERPROFILE ".unsloth")
if ($legacy -and ($norm -ieq $legacy)) { return $false }
# Keyed on the TREE as well, because that is what the readers key on: storage_roots'
# _is_legacy_studio_tree and the CLI both decline ANY note found in the legacy Studio tree,
# whatever it records. Without this, UNSLOTH_HOME=%USERPROFILE% passed containment (the
# legacy tree is inside the profile) and is not the legacy root, so the note was written,
# warned about nothing, and was then honoured by nobody.
$legacyStudio = Get-CanonicalDir -Path (Join-Path $env:USERPROFILE ".unsloth\studio")
if ($legacyStudio -and ($here -ieq $legacyStudio)) {
Write-StudioLine " note: the managed runtimes were installed under $norm, but Studio is the" -ForegroundColor Yellow
Write-StudioLine " default install at $here, where no reader honours a recorded root." -ForegroundColor Yellow
Write-StudioLine " That root cannot be recorded, so a later launch or uninstall will" -ForegroundColor Yellow
Write-StudioLine " not find them. Set UNSLOTH_STUDIO_HOME to $norm\studio, or re-set" -ForegroundColor Yellow
Write-StudioLine " UNSLOTH_HOME whenever you run Unsloth." -ForegroundColor Yellow
return $false
}
# A note must describe the tree it is written into: every reader requires the Studio
# directory to lie INSIDE the root it names, so a tree copied between master roots cannot aim
# a removal at the original install. install.ps1 does not read UNSLOTH_HOME yet, so a master
# root on an ordinary install leaves Studio at the legacy path with the runtimes elsewhere,
# which is that shape: say so, rather than leave a file that behaves as no note at all.
$sep = [System.IO.Path]::DirectorySeparatorChar
if (($here -ieq $norm) -or $here.StartsWith($norm + $sep, [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
Write-StudioLine " note: the managed runtimes were installed under $norm, but Studio lives at" -ForegroundColor Yellow
Write-StudioLine " $here, which is outside it. That root cannot be recorded, so a later" -ForegroundColor Yellow
Write-StudioLine " launch or uninstall will not find them. Set UNSLOTH_STUDIO_HOME to" -ForegroundColor Yellow
Write-StudioLine " $norm\studio, or re-set UNSLOTH_HOME whenever you run Unsloth." -ForegroundColor Yellow
return $false
}
if ((Get-MasterRootOverride) -and -not $StageRoot -and
(Test-MasterRootNoteIsHonoured -Root $UnslothHome -StudioRoot $StudioHome)) {
try {
$noteDir = Join-Path $StudioHome "share"
if (-not (Test-Path -LiteralPath $noteDir -PathType Container)) {
[void][System.IO.Directory]::CreateDirectory($noteDir)
}
# Staged then renamed: a reader that caught a half-written note would name a truncated
# path, and this note licenses deletions.
$notePath = Join-Path $noteDir ".unsloth-master-root"
$noteBytes = [System.Text.Encoding]::UTF8.GetBytes($UnslothHome + [Environment]::NewLine)
# Nothing to do when the note already says this, which is every run after the first.
# Move-Item -Force is NOT an atomic replace: the provider deletes the destination then
# moves the source, so an interruption leaves NO note and a backend that looks next
# caches "no master root". Re-entering that window to rewrite identical bytes is the
# avoidable half.
$noteCurrent = $null
try {
if (Test-Path -LiteralPath $notePath -PathType Leaf) {
$noteCurrent = [System.IO.File]::ReadAllBytes($notePath)
}
} catch { $noteCurrent = $null }
$noteSame = $false
if ($null -ne $noteCurrent -and $noteCurrent.Length -eq $noteBytes.Length) {
$noteSame = $true
for ($i = 0; $i -lt $noteBytes.Length; $i++) {
if ($noteCurrent[$i] -ne $noteBytes[$i]) { $noteSame = $false; break }
}
}
if (-not $noteSame) {
# An unpredictable name opened CreateNew, not "$notePath.$PID" through WriteAllText:
# the predictable pair lets anyone who can write share/ precreate that name as a link
# and have this write land on its target. Move-Item -Force replaces a link at the
# final path rather than following it.
$noteTmp = Join-Path $noteDir (".unsloth-master-root." + [System.IO.Path]::GetRandomFileName())
try {
$noteStream = [System.IO.File]::Open(
$noteTmp,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
try {
$noteStream.Write($noteBytes, 0, $noteBytes.Length)
} finally {
$noteStream.Dispose()
}
# Move-Item -Force, not [IO.File]::Move with an overwrite flag: that overload is
# .NET Core only and setup.ps1 still runs under Windows PowerShell 5.1.
Move-Item -LiteralPath $noteTmp -Destination $notePath -Force
$noteTmp = $null
} finally {
# The staging file is unreadable to every reader but still occupies share/, and
# nothing else ever removes it. Cleared whether the move threw or not.
if ($noteTmp) { Remove-Item -LiteralPath $noteTmp -Force -ErrorAction SilentlyContinue }
}
}
} catch {
Write-StudioLine " note: could not record the master root for uninstall: $($_.Exception.Message)" -ForegroundColor DarkGray
}
}
# Clear a note an earlier run left naming the legacy default root, whatever this run was asked
# to do: the gate above stops new ones, but an install that already has one keeps reading it
# back and no later update would reach the write block to correct it. Narrow on purpose -- both
# uninstallers already refuse this value, so removing it takes nothing a reader could act on.
try {
$staleNote = Join-Path (Join-Path $StudioHome "share") ".unsloth-master-root"
if (Test-Path -LiteralPath $staleNote -PathType Leaf) {
# -Encoding UTF8: the writer emits BOM-less UTF-8 and Windows PowerShell 5.1 decodes
# BOM-less input with the ANSI code page, so a root holding non-ASCII read back
# mangled and never matched the value this sweep exists to clear.
$staleValue = (Get-Content -LiteralPath $staleNote -TotalCount 1 -Encoding UTF8 -ErrorAction Stop)
if ($staleValue) { $staleValue = $staleValue.Trim() }
if ($staleValue) {
if ($staleValue -eq "~") { $staleValue = $env:USERPROFILE }
elseif ($staleValue -like "~/*" -or $staleValue -like "~\*") {
$staleValue = (Join-Path $env:USERPROFILE $staleValue.Substring(1).TrimStart('/', '\'))
}
$staleNorm = Get-CanonicalDir -Path $staleValue
$legacyNorm = Get-CanonicalDir -Path (Join-Path $env:USERPROFILE ".unsloth")
if ($staleNorm -and $legacyNorm -and ($staleNorm -ieq $legacyNorm)) {
Remove-Item -LiteralPath $staleNote -Force -ErrorAction SilentlyContinue
}
}
}
} catch {
# A note this run cannot read is one it has no business deleting.
}
$WithLlamaCppDir = $null
$llamaPreflightFailure = Invoke-ManagedLlamaCppPreflight -StagingRoot $StageRoot
if ($llamaPreflightFailure) {
@ -4434,8 +4615,12 @@ $NodeSource = $null
if (-not $IsPipInstall) {
# Put Node beside the Unsloth root. OXC can still need npm when the
# frontend build is skipped.
$_masterRoot = Get-MasterRootOverride
if ($StageRoot) {
$NodeParent = $StageRoot
} elseif ($_masterRoot) {
# Same derivation as Get-ManagedLlamaCppDir: beside studio\, not inside it.
$NodeParent = $_masterRoot
} else {
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $NodeOverride = $env:UNSLOTH_STUDIO_HOME.Trim() }
elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $NodeOverride = $env:STUDIO_HOME.Trim() }
@ -4701,11 +4886,34 @@ if ($NeedNodeForSetup) {
} elseif ($NodeSource -eq "bundled") {
New-Item -ItemType Directory -Force -Path $NodeParent -ErrorAction SilentlyContinue | Out-Null
# Minimal ownership guard; never os.replace over a user-owned dir.
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
# $RuntimeRootIsCustom as well as $NodeOverride: the master-root branch above leaves
# $NodeOverride null, so <master>\node reached install_node_prebuilt.py's whole-directory
# replacement with none of the ownership evidence a custom UNSLOTH_STUDIO_HOME requires,
# and the master root is the user's directory too. Or, not replacing: a custom Studio
# home keeps the guard it had.
#
# Anything at the path counts as occupied, not just a directory: _swap_into_place renames
# whatever it finds out of the way, so a file or symlink named `node` was displaced by a
# guard that only asked about containers. Ownership evidence lives inside a directory, so
# a non-directory can never carry it and is refused outright.
# Get-Item -Force, not Test-Path: under Windows PowerShell 5.1 Test-Path reports whether
# the TARGET resolves, so a dangling link named `node` reads as absent and
# install_node_prebuilt.py installs through it.
$nodeEntry = if ($NodeOverride -or $RuntimeRootIsCustom) {
Get-Item -LiteralPath $NodeDir -Force -ErrorAction SilentlyContinue
} else { $null }
if ($nodeEntry) {
$nodeOwnedMarker = Join-Path $NodeDir ".unsloth-studio-owned"
$nodeMeta = Join-Path $NodeDir "UNSLOTH_NODE_PREBUILT_INFO.json"
if (-not (Test-Path -LiteralPath $nodeOwnedMarker) -and -not (Test-Path -LiteralPath $nodeMeta)) {
Write-StudioLine "[ERROR] $NodeDir already exists and is not an Unsloth-owned Node install." -ForegroundColor Red
# From the entry, not a second probe: a dangling link is a container to neither, and
# a reparse point carrying no ownership evidence is not an install of ours.
$nodeIsDir = $nodeEntry.PSIsContainer -and
-not ($nodeEntry.Attributes -band [IO.FileAttributes]::ReparsePoint)
if (-not $nodeIsDir -or (
-not (Test-Path -LiteralPath $nodeOwnedMarker) -and
-not (Test-Path -LiteralPath $nodeMeta))) {
$what = if ($nodeIsDir) { "an Unsloth-owned Node install" } else { "a directory" }
Write-StudioLine "[ERROR] $NodeDir already exists and is not $what." -ForegroundColor Red
Write-StudioLine " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
Exit-SetupFailure "$NodeDir is not an Unsloth-owned Node install"
}
@ -4742,7 +4950,9 @@ if ($NeedNodeForSetup) {
Write-StudioLine " Install Node >= 20.19 (with npm >= 11) from https://nodejs.org/ and re-run, or check your network." -ForegroundColor Yellow
Exit-SetupFailure "Could not install an isolated Node runtime"
}
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
# Same condition as the guard above, or the tree this run just created under a master
# root stays unmarked and the uninstaller declines to remove it as somebody else's.
if (($NodeOverride -or $RuntimeRootIsCustom) -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
New-Item -ItemType File -Force -Path (Join-Path $NodeDir ".unsloth-studio-owned") -ErrorAction SilentlyContinue | Out-Null
}
$env:PATH = "$NodeDir;" + $env:PATH
@ -5023,26 +5233,41 @@ function Assert-StudioOwnedOrAbsent {
[Parameter(Mandatory = $true)][string]$Label,
# whisper.cpp is non-fatal by contract, so it needs the denial handed back
# rather than exited on. Only this mode returns a value.
[switch]$NonFatal
[switch]$NonFatal,
# The runtime children pass $RuntimeRootIsCustom; everything under the
# Studio home keeps $StudioHomeIsCustom.
[AllowNull()][object]$IsCustom = $null
)
$isCustomRoot = $StudioHomeIsCustom
if ($null -ne $IsCustom) { $isCustomRoot = [bool]$IsCustom }
# Denied is not Absent: a root we cannot read cannot be proven ours, and
# returning here would let the caller replace it. Both stops stay gated on
# $StudioHomeIsCustom, as before; a default-home denial is reported by the
# phase that owns the path.
# returning here would let the caller replace it; a default-home denial is
# reported by the phase that owns the path.
$pathState = Get-PathState -Path $Path -PathType Container
if ($pathState -ne "Present") {
if ($StudioHomeIsCustom -and $pathState -eq "Denied") {
if ($isCustomRoot -and $pathState -eq "Denied") {
if ($NonFatal) { return "Denied" }
Exit-PathAccessDenied -Path $Path -Label $Label -OwnershipUnverified
}
return
# A Container probe answers false for a regular file and for a link whose target is gone,
# and the caller then deleted the path or let install_*_prebuilt.py replace it; both
# shapes are things the user put in a directory they chose. Get-Item -Force still sees
# the directory entry where Test-Path does not, which is the dangling link under 5.1.
if (-not $isCustomRoot) { return }
if (-not (Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue)) { return }
# Ownership evidence lives inside a directory, so a non-directory can never carry it
# and is refused outright rather than asked for markers. Fatal even under -NonFatal:
# that mode rescues a denial, not somebody else's file.
Write-StudioLine "[ERROR] $Path already exists and is not a directory." -ForegroundColor Red
Write-StudioLine " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
Exit-SetupFailure "$Label path is not an Unsloth-owned install: $Path"
}
$markerState = Get-PathState -Path (Join-Path $Path $StudioOwnedMarker) -PathType Leaf
if ($StudioHomeIsCustom -and $markerState -eq "Denied") {
if ($isCustomRoot -and $markerState -eq "Denied") {
if ($NonFatal) { return "Denied" }
Exit-PathAccessDenied -Path $Path -Label $Label -OwnershipUnverified
}
if ($StudioHomeIsCustom -and $markerState -ne "Present") {
if ($isCustomRoot -and $markerState -ne "Present") {
$adoptState = Get-StudioAdoptableState -Path $Path
if ($adoptState -eq "Denied") {
if ($NonFatal) { return "Denied" }
@ -7221,9 +7446,24 @@ if ($script:UnslothVerbose) {
# Triton/inductor filenames are long and can hit Windows MAX_PATH (260). With long
# paths on, cache under Unsloth home; else use a short drive-root dir for headroom.
if ($StageRoot -or $LongPathsEnabled) {
#
# Persisted to the USER environment below, so every later Studio process inherits it and
# _setup_cache_env's fill-if-unset default never applies on Windows: it has to name the
# directory the resolver would have chosen, or the containment does not happen here at all.
#
# Two things outrank that. Long paths off keeps the short drive-root directory, since Inductor's
# filenames hit MAX_PATH and a contained cache that cannot be written is worse than an
# uncontained one. And a path containing a space is refused as
# storage_roots._TOOLCHAIN_PATH_KEYS refuses one: cpp_builder.py pastes it into a compiler
# command line unquoted, and "C:\Users\First Last" is an ordinary account name.
$TorchCacheDir = $null
if ($StageRoot) {
$TorchCacheDir = Join-Path $RuntimeRoot "TORCHINDUCTOR_CACHE_DIR"
} else {
} elseif ($LongPathsEnabled) {
$candidate = Join-Path (Join-Path $StudioHome "cache") "torchinductor"
if ($candidate -notmatch '\s') { $TorchCacheDir = $candidate }
}
if (-not $TorchCacheDir) {
$TorchCacheDir = "C:\tc"
}
if (-not (Test-Path -LiteralPath $TorchCacheDir)) { [System.IO.Directory]::CreateDirectory($TorchCacheDir) | Out-Null }
@ -8465,8 +8705,8 @@ if ($LocalLlamaCppSrc) {
throw
}
}
if ($StudioHomeIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install"
if ($RuntimeRootIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install" -IsCustom $RuntimeRootIsCustom
}
# The destination is about to be deleted and replaced, so a denial here
# must stop rather than throw raw: under a default home nothing above
@ -8519,7 +8759,7 @@ if ($LocalLlamaCppLinked) {
# Keep this late guard as defense in depth before the prebuilt installer.
$llamaDirState = Get-LlamaCppInstallReadState -Path $LlamaCppDir
if ($llamaDirState -eq "Denied") {
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$StudioHomeIsCustom
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$RuntimeRootIsCustom
}
if ($llamaDirState -eq "Readable") {
substep "Existing llama.cpp install detected -- validating staged prebuilt update before replacement"
@ -8563,8 +8803,8 @@ if ($LocalLlamaCppLinked) {
}
substep "installing prebuilt llama.cpp bundle (preferred path)..."
# install_llama_prebuilt.py's os.replace() would displace a custom-home llama.cpp first.
if ($StudioHomeIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install"
if ($RuntimeRootIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install" -IsCustom $RuntimeRootIsCustom
}
$prebuiltArgs = @(
"$PSScriptRoot\install_llama_prebuilt.py",
@ -8643,7 +8883,7 @@ if ($LocalLlamaCppLinked) {
} else {
step "llama.cpp" "prebuilt installed and validated"
}
if ($StudioHomeIsCustom -and (Test-Path -LiteralPath $LlamaCppDir -PathType Container)) {
if ($RuntimeRootIsCustom -and (Test-Path -LiteralPath $LlamaCppDir -PathType Container)) {
Mark-StudioOwned -Path $LlamaCppDir
}
$installedRelease = Get-InstalledLlamaPrebuiltRelease -InstallDir $LlamaCppDir
@ -8730,8 +8970,8 @@ if ($env:WHISPER_SERVER_PATH -or $env:UNSLOTH_WHISPER_CPP_PATH) {
substep "whisper.cpp: using a user-configured binary/dir; skipping managed install"
} elseif ($env:UNSLOTH_SKIP_WHISPER_INSTALL -eq "1") {
substep "whisper.cpp: install skipped (UNSLOTH_SKIP_WHISPER_INSTALL=1)"
} elseif ($StudioHomeIsCustom -and (Test-Path -LiteralPath $WhisperInstaller) -and
(Assert-StudioOwnedOrAbsent -Path $WhisperCppDir -Label "whisper.cpp install" -NonFatal) -eq "Denied") {
} elseif ($RuntimeRootIsCustom -and (Test-Path -LiteralPath $WhisperInstaller) -and
(Assert-StudioOwnedOrAbsent -Path $WhisperCppDir -Label "whisper.cpp install" -NonFatal -IsCustom $RuntimeRootIsCustom) -eq "Denied") {
# Never fatal, per the phase header: the guard below would exit the whole run
# on an unreadable tree, taking llama.cpp down with it. Only the denial is
# caught here; an unowned tree still stops.
@ -8739,8 +8979,8 @@ if ($env:WHISPER_SERVER_PATH -or $env:UNSLOTH_WHISPER_CPP_PATH) {
} elseif (Test-Path -LiteralPath $WhisperInstaller) {
# The installer's atomic activation replaces the whole directory, so the
# custom-home ownership guard must run first (mirrors the llama block).
if ($StudioHomeIsCustom) {
Assert-StudioOwnedOrAbsent -Path $WhisperCppDir -Label "whisper.cpp install"
if ($RuntimeRootIsCustom) {
Assert-StudioOwnedOrAbsent -Path $WhisperCppDir -Label "whisper.cpp install" -IsCustom $RuntimeRootIsCustom
}
$whisperArgs = @($WhisperInstaller, "--install-dir", $WhisperCppDir)
if ($env:UNSLOTH_WHISPER_RELEASE_TAG) {
@ -8780,7 +9020,7 @@ if ($env:WHISPER_SERVER_PATH -or $env:UNSLOTH_WHISPER_CPP_PATH) {
} else {
step "whisper.cpp" "prebuilt installed"
}
if ($StudioHomeIsCustom -and (Test-PathQuiet $WhisperCppDir "Container")) {
if ($RuntimeRootIsCustom -and (Test-PathQuiet $WhisperCppDir "Container")) {
Mark-StudioOwned -Path $WhisperCppDir
}
} elseif ($whisperExit -eq 3) {
@ -8873,7 +9113,7 @@ $NeedRebuild = $false
$llamaBinState = if ($LocalLlamaCppLinked) { "Absent" } else { Get-PathState -Path $LlamaServerBin -PathType Leaf }
if ($llamaBinState -eq "Denied") {
# Nothing proved this tree is ours here, so do not advise deleting it.
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$StudioHomeIsCustom
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$RuntimeRootIsCustom
}
if ($llamaBinState -eq "Present") {
$CmakeCacheFile = Join-Path $BuildDir "CMakeCache.txt"
@ -8883,7 +9123,7 @@ if ($llamaBinState -eq "Present") {
$cachedCuda = Select-String -LiteralPath $CmakeCacheFile -Pattern 'GGML_CUDA:BOOL=ON' -Quiet
} catch {
if (-not (Test-AccessDeniedError $_)) { throw }
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$StudioHomeIsCustom
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$RuntimeRootIsCustom
}
if ($HasNvidiaSmi -and -not $cachedCuda) {
Write-StudioLine " Existing llama-server is CPU-only but GPU is available -- rebuilding" -ForegroundColor Yellow
@ -9098,8 +9338,8 @@ if ($LocalLlamaCppLinked) {
# why: in-place git mutation (remote set-url, checkout -B, clean -fdx)
# rewrites $LlamaCppDir; mirror the prebuilt and temp-dir-swap guards
# so an unrelated workspace .git tree is never silently overwritten.
if ($StudioHomeIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install"
if ($RuntimeRootIsCustom) {
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install" -IsCustom $RuntimeRootIsCustom
}
Write-StudioLine " Syncing llama.cpp to $ResolvedSourceRef..." -ForegroundColor Gray
# Always sync the remote URL so switching between default/fork sources works
@ -9172,7 +9412,7 @@ if ($LocalLlamaCppLinked) {
}
}
# In-place git-sync must mark the tree, else a later Assert-StudioOwnedOrAbsent exits.
if ($BuildOk -and $StudioHomeIsCustom) {
if ($BuildOk -and $RuntimeRootIsCustom) {
Mark-StudioOwned -Path $LlamaCppDir
}
} else {
@ -9398,7 +9638,7 @@ if ($LocalLlamaCppLinked) {
# Swap temp build dir into final location (only if we built in a temp dir)
if ($BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) {
Assert-StudioOwnedOrAbsent -Path $OriginalLlamaCppDir -Label "llama.cpp install"
Assert-StudioOwnedOrAbsent -Path $OriginalLlamaCppDir -Label "llama.cpp install" -IsCustom $RuntimeRootIsCustom
if ((Get-PathState -Path $OriginalLlamaCppDir) -ne "Absent") {
Remove-Item -LiteralPath $OriginalLlamaCppDir -Recurse -Force -ErrorAction SilentlyContinue
# Any unreadable or locked child survives the removal, and Move-Item
@ -9467,7 +9707,7 @@ if ($LocalLlamaCppLinked) {
$llamaCppItem = Get-Item -LiteralPath $LlamaCppDir -Force -ErrorAction SilentlyContinue
$llamaCppIsLink = $llamaCppItem -and ($llamaCppItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
if (-not $llamaCppIsLink -and (
-not $StudioHomeIsCustom -or
-not $RuntimeRootIsCustom -or
(Test-PathQuiet (Join-Path $LlamaCppDir $StudioOwnedMarker) "Leaf") -or
(Test-StudioOwnedAdoptable $LlamaCppDir)
)) {

View file

@ -1443,6 +1443,63 @@ _STUDIO_HOME_IS_CUSTOM=false
if [ "$_studio_home_canon" != "$_LEGACY_STUDIO_HOME" ]; then
_STUDIO_HOME_IS_CUSTOM=true
fi
# The master root storage_roots.unsloth_home() reads. llama.cpp, node and whisper.cpp sit BESIDE
# studio/ under it, so installing them under $STUDIO_HOME would put them one level below where
# every runtime resolver looks. Captured here because section 7 assigns over UNSLOTH_HOME.
# Stripped before anything else, like _studio_override above: " " counts as unset and " /opt/uns "
# names /opt/uns, which is what the Python resolver and the CLI both see.
_MASTER_ROOT=$(printf '%s' "${UNSLOTH_HOME:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$_MASTER_ROOT" ]; then
case "$_MASTER_ROOT" in
"~") _MASTER_ROOT="$HOME" ;;
"~/"*) _MASTER_ROOT="$HOME/${_MASTER_ROOT#'~/'}" ;;
esac
# Absolute against the CALLER's directory even when it does not exist yet, as Path.resolve()
# does: node is chosen before the first `cd "$SCRIPT_DIR"` and llama.cpp after it, so a
# relative value names two different directories and matches the backend's neither.
case "$_MASTER_ROOT" in
/*) ;;
*) _MASTER_ROOT="$PWD/$_MASTER_ROOT" ;;
esac
if [ -d "$_MASTER_ROOT" ]; then
# Keep the expanded value when it cannot be canonicalized, as the Python resolver does:
# dropping it here would send the runtimes to a root nothing else agrees on.
_master_root_canon=$(CDPATH= cd -P -- "$_MASTER_ROOT" 2>/dev/null && pwd -P) || _master_root_canon=""
[ -z "$_master_root_canon" ] || _MASTER_ROOT="$_master_root_canon"
unset _master_root_canon
fi
fi
# Ownership applies to node/, llama.cpp/ and whisper.cpp/ whenever a master root moves them,
# even with STUDIO_HOME left at the legacy path, where _STUDIO_HOME_IS_CUSTOM is false and
# "false" licenses the installers to os.replace() and rm -rf unmarked trees. The Studio home
# and its venvs keep the other flag.
_RUNTIME_ROOT_IS_CUSTOM="$_STUDIO_HOME_IS_CUSTOM"
# Keyed on where the runtimes LAND, not on whether a master root was named: UNSLOTH_HOME set to
# the root an install already uses moves nothing, and calling that custom would demand an owner
# marker from a legacy source-built ~/.unsloth/llama.cpp that predates markers.
#
# Staging is excluded for the same reason, not as an exception to it: the placement below gives
# STAGE_ROOT precedence over the master root, so during a staged update the master root is not
# where anything lands and must not decide ownership. Assigned rather than raised, or a custom
# STUDIO_HOME kept the flag true while the runtimes went to the legacy root, which demanded
# markers from exactly the pre-marker trees this comparison exists to spare.
if [ -z "${STAGE_ROOT:-}" ] && [ -n "$_MASTER_ROOT" ]; then
# Canonicalised like _MASTER_ROOT, or a symlinked $HOME compares unequal to itself and the
# legacy root reads as custom after all.
_rrc_legacy="$HOME/.unsloth"
if [ -d "$_rrc_legacy" ]; then
_rrc_canon=$(CDPATH= cd -P -- "$_rrc_legacy" 2>/dev/null && pwd -P) || _rrc_canon=""
[ -z "$_rrc_canon" ] || _rrc_legacy="$_rrc_canon"
unset _rrc_canon
fi
if [ "$_MASTER_ROOT" = "$_rrc_legacy" ]; then
_RUNTIME_ROOT_IS_CUSTOM=false
else
_RUNTIME_ROOT_IS_CUSTOM=true
fi
unset _rrc_legacy
fi
# Directory-local evidence Unsloth created "$1": only prebuilt-installer metadata
# counts (UNSLOTH_PREBUILT_INFO.json for llama.cpp, UNSLOTH_NODE_PREBUILT_INFO.json
# for Node, UNSLOTH_WHISPER_PREBUILT_INFO.json for whisper.cpp), all written only
@ -1527,11 +1584,37 @@ _report_denied_ancestor() {
fi
}
# What is at "$1", for the refusal message. A user told the path is "not an Unsloth install"
# when it is their own dangling symlink has no idea what to move aside.
_studio_path_shape() {
if [ -L "$1" ]; then
if [ -e "$1" ]; then printf 'a symlink'; else printf 'a dangling symlink'; fi
elif [ -f "$1" ]; then printf 'a regular file'
elif [ -d "$1" ]; then printf 'a directory'
else printf 'an existing path'
fi
}
# $3 is the ownership flag: the runtime children pass _RUNTIME_ROOT_IS_CUSTOM, everything
# under the Studio home keeps _STUDIO_HOME_IS_CUSTOM.
_assert_studio_owned_or_absent() {
_aso_dir="$1"
_aso_label="$2"
[ -d "$_aso_dir" ] || return 0
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ ! -f "$_aso_dir/$_STUDIO_OWNED_MARKER" ]; then
_aso_custom="${3:-$_STUDIO_HOME_IS_CUSTOM}"
# -d alone read a dangling symlink or a regular file as "nothing is here", and the caller
# then rm -rf'd it or os.replace()d over it. A dangling link is the ordinary case: its
# volume is simply not mounted, and following it later installs onto somebody's other disk.
if [ ! -d "$_aso_dir" ] && [ ! -e "$_aso_dir" ] && [ ! -L "$_aso_dir" ]; then
return 0
fi
if [ "$_aso_custom" = true ] && [ ! -f "$_aso_dir/$_STUDIO_OWNED_MARKER" ]; then
# Only a directory can carry the marker or the prebuilt metadata, so anything else here
# is unowned by construction and the adoption path below cannot apply to it.
if [ ! -d "$_aso_dir" ]; then
echo "ERROR: $_aso_dir already exists and is not an Unsloth-owned $_aso_label." >&2
echo " It is $(_studio_path_shape "$_aso_dir"). Move it aside before re-running." >&2
setup_fail 1 "$_aso_label path is not an Unsloth-owned install: $_aso_dir"
fi
if _studio_owned_adoptable "$_aso_dir"; then
: > "$_aso_dir/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
return 0
@ -1641,6 +1724,8 @@ decide_node_source() {
# Mirror the llama.cpp UNSLOTH_HOME derivation; the frontend build runs first.
if [ -n "$STAGE_ROOT" ]; then
_NODE_PARENT="$RUNTIME_ROOT"
elif [ -n "$_MASTER_ROOT" ]; then
_NODE_PARENT="$_MASTER_ROOT"
elif [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
_NODE_PARENT="$STUDIO_HOME"
else
@ -1659,8 +1744,8 @@ elif [ "$NODE_SOURCE" = bundled ]; then
mkdir -p "$_NODE_PARENT"
# install_node_prebuilt.py uses os.replace(); guard a custom-home dir so we
# never displace a user-owned $UNSLOTH_STUDIO_HOME/node.
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$NODE_DIR" "Node install"
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$NODE_DIR" "Node install" "$_RUNTIME_ROOT_IS_CUSTOM"
fi
substep "installing isolated Node (system Node/npm left untouched)..."
# Runs before the venv is activated, so bare `python` may be absent; resolve
@ -1695,7 +1780,7 @@ elif [ "$NODE_SOURCE" = bundled ]; then
fi
grep -Fq "already matches" "$_NODE_LOG" && verbose_substep "isolated Node already up to date"
rm -f "$_NODE_LOG"
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ -d "$NODE_DIR" ]; then
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ] && [ -d "$NODE_DIR" ]; then
: > "$NODE_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
fi
# Prepend the isolated bin (this process only) so node/npm/bun resolve here.
@ -3664,12 +3749,107 @@ fi
# default keeps ~/.unsloth/llama.cpp so pre-PR builds are still discovered.
if [ -n "$STAGE_ROOT" ]; then
UNSLOTH_HOME="$RUNTIME_ROOT"
elif [ -n "$_MASTER_ROOT" ]; then
UNSLOTH_HOME="$_MASTER_ROOT"
elif [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
UNSLOTH_HOME="$STUDIO_HOME"
else
UNSLOTH_HOME="$HOME/.unsloth"
fi
mkdir -p "$UNSLOTH_HOME"
# Record the master root inside the Studio tree, for the uninstaller: UNSLOTH_HOME can be set
# for a single command, and without the note the uninstaller removed the Studio tree and
# stranded multi-gigabyte llama.cpp, node and whisper.cpp trees beside it.
#
# Only for a master root (the other branches derive UNSLOTH_HOME from paths the uninstaller
# already knows), and only when a reader will honour it: both rejected shapes were being
# written, so the file on disk claimed a recorded root while every reader refused it.
_master_root_note_is_honoured() {
# Canonicalised here rather than trusting the value from the top of the script: that one was
# resolved before the mkdir above, so a root created by this run kept its uncanonical
# spelling, and the containment test below is textual.
_mrn_root=$(CDPATH= cd -P -- "$1" 2>/dev/null && pwd -P) || _mrn_root=""
[ -n "$_mrn_root" ] || return 1
# The legacy default is the one root every reader finds without help, and both uninstallers
# refuse it outright. Writing it turned a one-command `UNSLOTH_HOME=$HOME/.unsloth` into a
# permanent portable install: every later bare launch read the note back and HF_HUB_CACHE
# moved off ~/.cache/huggingface, the one cache this change promises not to move.
_mrn_legacy=$(CDPATH= cd -P -- "${HOME:-}/.unsloth" 2>/dev/null && pwd -P) || _mrn_legacy="${HOME:-}/.unsloth"
[ "$_mrn_root" != "$_mrn_legacy" ] || return 1
# Keyed on the TREE as well, because that is what the readers key on: storage_roots'
# _is_legacy_studio_tree and the CLI both decline ANY note found in ~/.unsloth/studio,
# whatever it records. Without this, UNSLOTH_HOME=$HOME passed containment (the legacy tree
# is inside $HOME) and the value is not the legacy root, so the note was written, warned
# about nothing, and then honoured by nobody.
_mrn_studio=$(CDPATH= cd -P -- "$STUDIO_HOME" 2>/dev/null && pwd -P) || _mrn_studio="$STUDIO_HOME"
_mrn_legacy_studio=$(CDPATH= cd -P -- "${HOME:-}/.unsloth/studio" 2>/dev/null && pwd -P) \
|| _mrn_legacy_studio="${HOME:-}/.unsloth/studio"
if [ "$_mrn_studio" = "$_mrn_legacy_studio" ]; then
echo "NOTE: the managed runtimes were installed under $_mrn_root, but Studio itself is the" >&2
echo " default install at $_mrn_studio, where no reader honours a recorded root. That" >&2
echo " root cannot be recorded, so a later launch or uninstall will not find them." >&2
echo " Set UNSLOTH_STUDIO_HOME=$_mrn_root/studio, or re-export UNSLOTH_HOME whenever" >&2
echo " you run Unsloth." >&2
return 1
fi
# A note must describe the tree it is written into: all four readers require the Studio
# directory to lie INSIDE the root it names, so a tree copied between master roots cannot
# aim a removal at the original install. install.sh and install.ps1 do not read UNSLOTH_HOME
# yet, so `UNSLOTH_HOME=/mnt/portable unsloth studio update` leaves Studio at
# ~/.unsloth/studio with the runtimes at /mnt/portable, which is exactly that shape: warn,
# rather than leave a file that reads as a recorded root and behaves as none.
case "$STUDIO_HOME" in
"$_mrn_root"|"$_mrn_root"/*) return 0 ;;
esac
echo "NOTE: the managed runtimes were installed under $_mrn_root, but Studio itself lives at" >&2
echo " $STUDIO_HOME, which is outside it. That root cannot be recorded, so a later launch" >&2
echo " or uninstall will not find them. Set UNSLOTH_STUDIO_HOME=$_mrn_root/studio, or" >&2
echo " re-export UNSLOTH_HOME whenever you run Unsloth." >&2
return 1
}
if [ -n "$_MASTER_ROOT" ] && [ -z "$STAGE_ROOT" ] && _master_root_note_is_honoured "$UNSLOTH_HOME"; then
if mkdir -p "$STUDIO_HOME/share" 2>/dev/null; then
# Staged then renamed: a reader catching a half-written note would name a truncated
# path, and this note licenses deletions. mktemp rather than "$$", as
# _uv_cache_root_is_writable states above: a redirection into a predictable name follows
# a symlink anyone who can write share/ could have precreated there.
if ! _mrn_tmp=$(mktemp "$STUDIO_HOME/share/.unsloth-master-root.XXXXXX" 2>/dev/null); then
_mrn_tmp=""
fi
if [ -n "$_mrn_tmp" ] && printf '%s\n' "$UNSLOTH_HOME" > "$_mrn_tmp" 2>/dev/null; then
mv -f "$_mrn_tmp" "$STUDIO_HOME/share/.unsloth-master-root" 2>/dev/null \
|| rm -f "$_mrn_tmp" 2>/dev/null || true
elif [ -n "$_mrn_tmp" ]; then
rm -f "$_mrn_tmp" 2>/dev/null || true
fi
unset _mrn_tmp
fi
fi
# Clear a note an earlier run left naming the legacy default root, whatever this run was asked
# to do. The gate above stops new ones, but an install that already has one keeps reading it
# back on every bare launch -- portable mode on, the Hugging Face hub cache moved off
# ~/.cache/huggingface -- and no later `unsloth studio update` would ever pass through the write
# block to correct it, since that block only runs when UNSLOTH_HOME is set again. Narrow on
# purpose: this is the single value both uninstallers already refuse, so removing it takes
# nothing any reader was entitled to act on. A note naming any other root is left alone, since
# an unreadable or unexpected one may simply describe an install this run cannot see.
if [ -f "$STUDIO_HOME/share/.unsloth-master-root" ]; then
_mrn_old=$(head -n 1 "$STUDIO_HOME/share/.unsloth-master-root" 2>/dev/null \
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') || _mrn_old=""
if [ -n "$_mrn_old" ]; then
case "$_mrn_old" in
"~") _mrn_old="${HOME:-}" ;;
"~/"*) _mrn_old="${HOME:-}/${_mrn_old#'~/'}" ;;
esac
_mrn_old_canon=$(CDPATH= cd -P -- "$_mrn_old" 2>/dev/null && pwd -P) || _mrn_old_canon=""
[ -n "$_mrn_old_canon" ] && _mrn_old="$_mrn_old_canon"
_mrn_legacy=$(CDPATH= cd -P -- "${HOME:-}/.unsloth" 2>/dev/null && pwd -P) || _mrn_legacy="${HOME:-}/.unsloth"
if [ "$_mrn_old" = "$_mrn_legacy" ]; then
rm -f "$STUDIO_HOME/share/.unsloth-master-root" 2>/dev/null || true
fi
fi
unset _mrn_old _mrn_old_canon
fi
LLAMA_CPP_DIR="$UNSLOTH_HOME/llama.cpp"
LLAMA_SERVER_BIN="$LLAMA_CPP_DIR/build/bin/llama-server"
_NEED_LLAMA_SOURCE_BUILD=false
@ -4014,8 +4194,8 @@ if [ -n "${UNSLOTH_LOCAL_LLAMA_CPP_DIR:-}" ]; then
# for a custom UNSLOTH_STUDIO_HOME (the assert would otherwise follow the
# link into the user's dir and reject it as unowned).
[ -L "$LLAMA_CPP_DIR" ] && rm -f "$LLAMA_CPP_DIR"
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install"
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install" "$_RUNTIME_ROOT_IS_CUSTOM"
fi
rm -rf "$LLAMA_CPP_DIR" || true
if [ -e "$LLAMA_CPP_DIR" ]; then
@ -4040,8 +4220,8 @@ fi
# swap only reaches its own guards after the whole build, so check here instead.
# Local-link paths are excluded: they already replaced or reused the tree above.
if [ "$_LOCAL_LLAMA_CPP_LINKED" != true ]; then
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install"
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install" "$_RUNTIME_ROOT_IS_CUSTOM"
fi
if _studio_dir_unreadable "$LLAMA_CPP_DIR"; then
_path_access_denied "$LLAMA_CPP_DIR" "llama.cpp install"
@ -4071,8 +4251,8 @@ else
# why: install_llama_prebuilt.py uses os.replace(), which would displace
# an unrelated $UNSLOTH_STUDIO_HOME/llama.cpp before the source-build
# ownership check below ever runs.
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install"
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install" "$_RUNTIME_ROOT_IS_CUSTOM"
fi
# The ownership check above misses the default cache; stop before pathlib
# turns an unreadable one into a traceback.
@ -4145,7 +4325,7 @@ else
else
step "llama.cpp" "prebuilt installed and validated"
fi
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ -d "$LLAMA_CPP_DIR" ]; then
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ] && [ -d "$LLAMA_CPP_DIR" ]; then
: > "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
fi
print_installed_llama_prebuilt_release "$LLAMA_CPP_DIR"
@ -4223,7 +4403,7 @@ if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] && \
[ -x "$LLAMA_CPP_DIR/build/bin/llama-quantize" ]; then
step "llama.cpp" "existing source build found; skipping rebuild"
ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize"
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ]; then
: > "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
fi
_NEED_LLAMA_SOURCE_BUILD=false
@ -4784,7 +4964,7 @@ else
fi
# Swap only after build succeeds -- preserves existing install on failure
if [ "$BUILD_OK" = true ]; then
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install"
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install" "$_RUNTIME_ROOT_IS_CUSTOM"
# || true: without it a raw rm error aborts under errexit to a bare exit
# code, build stranded. Keep stderr: rm names the exact subpath, we cannot.
rm -rf "$LLAMA_CPP_DIR" || true
@ -4862,7 +5042,7 @@ if [ "$_LLAMA_CPP_DEGRADED" = true ] \
fi
if [ ! -L "$LLAMA_CPP_DIR" ] && {
[ "$_STUDIO_HOME_IS_CUSTOM" != true ] ||
[ "$_RUNTIME_ROOT_IS_CUSTOM" != true ] ||
[ -f "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" ] ||
_studio_owned_adoptable "$LLAMA_CPP_DIR"
}; then
@ -4881,8 +5061,8 @@ if [ -n "${WHISPER_SERVER_PATH:-}" ] || [ -n "${UNSLOTH_WHISPER_CPP_PATH:-}" ];
elif [ "${UNSLOTH_SKIP_WHISPER_INSTALL:-0}" = "1" ]; then
verbose_substep "whisper.cpp: install skipped (UNSLOTH_SKIP_WHISPER_INSTALL=1)"
else
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$WHISPER_CPP_DIR" "whisper.cpp install"
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$WHISPER_CPP_DIR" "whisper.cpp install" "$_RUNTIME_ROOT_IS_CUSTOM"
fi
_WHISPER_CMD=(python "$SCRIPT_DIR/install_whisper_prebuilt.py" --install-dir "$WHISPER_CPP_DIR")
if [ -n "${UNSLOTH_WHISPER_RELEASE_TAG:-}" ]; then
@ -4914,7 +5094,7 @@ else
else
step "whisper.cpp" "prebuilt installed"
fi
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ -d "$WHISPER_CPP_DIR" ]; then
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ] && [ -d "$WHISPER_CPP_DIR" ]; then
: > "$WHISPER_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
fi
rm -f "$_WHISPER_LOG"
@ -4940,7 +5120,7 @@ else
env UNSLOTH_HOME="$UNSLOTH_HOME" sh "$_WHISPER_BUILD"; then
_WHISPER_RECOVERED=true
step "whisper.cpp" "source build installed"
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ -d "$WHISPER_CPP_DIR" ]; then
if [ "$_RUNTIME_ROOT_IS_CUSTOM" = true ] && [ -d "$WHISPER_CPP_DIR" ]; then
: > "$WHISPER_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
fi
else

View file

@ -580,8 +580,12 @@ fn spawn_script(
// Tauri only does default-root installs; install.sh / install.ps1 reject
// these under --tauri. Scrub so an inherited value can't trip the guard.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
// Applied by hand here, the one managed spawn outside
// apply_managed_cli_context: the Python setup.sh starts reads whatever is
// exported, even though setup.sh assigns UNSLOTH_HOME itself.
for name in crate::process::MANAGED_CHILD_SCRUBBED_ENV {
cmd.env_remove(name);
}
cmd.env(
"UNSLOTH_DESKTOP_BACKEND_VERSION",
crate::preflight::expected_backend_version(),

View file

@ -1964,6 +1964,7 @@ fn windows_roots_from(
/// PATH are absent: they are not single paths. Mirrors `_RELATIVE_PATH_ENV` in
/// unsloth_cli/_system_dir_guard.py, held identical by a parity test.
pub(crate) const RELATIVE_PATH_ENV: &[&str] = &[
"UNSLOTH_HOME",
"UNSLOTH_STUDIO_HOME",
"STUDIO_HOME",
"UNSLOTH_STUDIO_DOCUMENTS_HOME",
@ -1997,6 +1998,18 @@ pub(crate) const RELATIVE_PATH_ENV: &[&str] = &[
"UNSLOTH_DG_SHIM",
"UNSLOTH_COMPILE_LOCATION",
"TORCHINDUCTOR_CACHE_DIR",
// storage_roots.py fills these only when blank, so a relative value the user set is kept as
// written and would name a different folder after the move.
"TORCH_EXTENSIONS_DIR",
"TORCH_HOME",
"TRITON_HOME",
"TRITON_CACHE_DIR",
"TRITON_DUMP_DIR",
"CUDA_CACHE_PATH",
"MPLCONFIGDIR",
"NUMBA_CACHE_DIR",
"DATA_DESIGNER_HOME",
"DATA_DESIGNER_MANAGED_ASSETS_PATH",
"UNSLOTH_DIFFUSION_COMPILE_CACHE_DIR",
"UNSLOTH_DIFFUSION_COND_CACHE_DIR",
"HF_HOME",
@ -2005,6 +2018,9 @@ pub(crate) const RELATIVE_PATH_ENV: &[&str] = &[
"HF_XET_CACHE",
"HF_DATASETS_CACHE",
"HF_ASSETS_CACHE",
// transformers appends this to sys.path, so a relative value would import a
// different generated module after the move.
"HF_MODULES_CACHE",
// huggingface_hub resolves the credential file from here; a relative value
// would follow the child and silently lose access to gated repos.
"HF_TOKEN_PATH",
@ -2315,7 +2331,17 @@ fn names_a_path(name: &str, value: &str) -> bool {
/// Names every managed spawn removes before starting the child: Tauri uses the
/// legacy Unsloth root whatever the environment says. Resolving one can only
/// invent a failure for a value the child is never going to see.
const MANAGED_CHILD_SCRUBBED_ENV: &[&str] = &["UNSLOTH_STUDIO_HOME", "STUDIO_HOME"];
///
/// UNSLOTH_HOME moves the databases, assets and caches exactly as
/// UNSLOTH_STUDIO_HOME does, plus the managed llama.cpp, node and whisper.cpp
/// dirs. UNSLOTH_PORTABLE names no root but portable_mode() reads it on its own,
/// repointing the Hugging Face and torch caches away from the shared user ones.
pub(crate) const MANAGED_CHILD_SCRUBBED_ENV: &[&str] = &[
"UNSLOTH_HOME",
"UNSLOTH_STUDIO_HOME",
"STUDIO_HOME",
"UNSLOTH_PORTABLE",
];
/// Read only by the update and installer path (install_python_stack.py), so a
/// stale value must not be able to fail a probe, a backend start or an auth
@ -3566,11 +3592,13 @@ pub fn start_backend(
#[cfg(target_os = "linux")]
scrub_appimage_python_env(&mut cmd);
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
// scrub so the spawned Python backend can't diverge. UNSLOTH_LLAMA_CPP_PATH
// is a pre-existing user-controlled llama.cpp dir override; keep it.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
// Tauri uses the legacy root whatever the environment says; scrub so the
// spawned Python backend can't diverge. Off the shared list, so a name added
// there cannot be honoured by the backend and missed here.
// UNSLOTH_LLAMA_CPP_PATH is a pre-existing user-controlled override; keep it.
for name in MANAGED_CHILD_SCRUBBED_ENV {
cmd.env_remove(name);
}
// read_output_stream decodes as UTF-8; without these, Python encodes its
// redirected streams with the locale code page and non-ASCII lands as U+FFFD.

View file

@ -0,0 +1,101 @@
#!/usr/bin/env bash
# The path resolver must work in a clean interpreter with only structlog, and
# seeding must create no studio.db (get_app_setting CREATES one). Needs uv.
set -u
REPO="${1:-$(CDPATH= cd -P -- "$(dirname "$0")/../.." && pwd -P)}"
if ! command -v uv >/dev/null 2>&1; then
echo "SKIP: uv not on PATH, cannot build an isolated venv"
exit 0
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
fails=0
check() { if [ "$2" = 0 ]; then echo " PASS $1"; else echo " FAIL $1 ${3:-}"; fails=$((fails+1)); fi; }
echo "[1] build an isolated venv (uv, no site packages from the workspace)"
uv venv --python 3.13 "$WORK/venv" >/dev/null 2>&1 || { echo "FAIL: uv venv"; exit 1; }
PY="$WORK/venv/bin/python"
# structlog is the only thing storage_roots pulls in via loggers/path_utils.
uv pip install --python "$PY" structlog >/dev/null 2>&1 || { echo "FAIL: pip install"; exit 1; }
echo " venv: $("$PY" -V), packages: $(uv pip list --python "$PY" 2>/dev/null | wc -l)"
echo
echo "[2] resolver imports and runs with only structlog installed"
HOME_A="$WORK/home_a"; mkdir -p "$HOME_A"
out="$(env -i HOME="$HOME_A" PATH="/usr/bin:/bin" UNSLOTH_HOME="$WORK/root_a" \
"$PY" -c "
import sys, os, json
sys.path.insert(0, '$REPO/studio/backend')
from utils.paths import storage_roots as sr
sr.setup_cache_env()
print(json.dumps({
'studio_root': str(sr.studio_root()),
'portable': sr.portable_mode(),
'compile': os.environ.get('UNSLOTH_COMPILE_LOCATION'),
'triton_home': os.environ.get('TRITON_HOME'),
}))
" 2>&1)"
case "$out" in
*studio_root*) check "resolver runs on a bare interpreter" 0; echo " $out" ;;
*) check "resolver runs on a bare interpreter" 1 "$out" ;;
esac
echo
echo "[3] no torch, transformers or other heavy import is required"
heavy="$(env -i HOME="$HOME_A" PATH="/usr/bin:/bin" "$PY" -c "
import sys
sys.path.insert(0, '$REPO/studio/backend')
from utils.paths import storage_roots as sr
sr.setup_cache_env()
bad = [m for m in ('torch','transformers','numpy','huggingface_hub','fastapi') if m in sys.modules]
print(','.join(bad) if bad else 'NONE')
" 2>&1 | tail -1)"
[ "$heavy" = "NONE" ] && check "seeding pulls in no heavy modules" 0 \
|| check "seeding pulls in no heavy modules" 1 "imported: $heavy"
echo
echo "[4] CLI seeding side effects: must not create a studio DB or leak outside the root"
HOME_B="$WORK/home_b"; mkdir -p "$HOME_B"
ROOT_B="$WORK/root_b"
env -i HOME="$HOME_B" PATH="/usr/bin:/bin" UNSLOTH_HOME="$ROOT_B" "$PY" -c "
import sys
sys.path.insert(0, '$REPO/studio/backend')
from utils.paths.storage_roots import setup_cache_env
setup_cache_env()
" >/dev/null 2>&1
dbs="$(find "$ROOT_B" "$HOME_B" -name '*.db' 2>/dev/null | wc -l)"
[ "$dbs" = 0 ] && check "seeding creates no database" 0 || check "seeding creates no database" 1 "found $dbs"
outside="$(find "$HOME_B" -mindepth 1 2>/dev/null | wc -l)"
[ "$outside" = 0 ] && check "seeding writes nothing to HOME" 0 \
|| check "seeding writes nothing to HOME" 1 "$(find "$HOME_B" -mindepth 1 | head -5 | tr '\n' ' ')"
echo
echo "[5] seeding is idempotent and cheap on repeat"
t0=$(date +%s%N)
env -i HOME="$HOME_B" PATH="/usr/bin:/bin" UNSLOTH_HOME="$ROOT_B" "$PY" -c "
import sys
sys.path.insert(0, '$REPO/studio/backend')
from utils.paths.storage_roots import setup_cache_env
for _ in range(50):
setup_cache_env()
" >/dev/null 2>&1
t1=$(date +%s%N)
ms=$(( (t1 - t0) / 1000000 ))
[ "$ms" -lt 20000 ] && check "50 repeat seedings in ${ms}ms" 0 || check "50 repeat seedings too slow (${ms}ms)" 1
echo
echo "[6] resolver survives a missing HOME entirely"
out6="$(env -i PATH="/usr/bin:/bin" UNSLOTH_HOME="$WORK/root_c" "$PY" -c "
import sys
sys.path.insert(0, '$REPO/studio/backend')
from utils.paths import storage_roots as sr
print(sr.studio_root())
" 2>&1 | tail -1)"
case "$out6" in
"$WORK/root_c/studio") check "no HOME set still resolves the portable root" 0 ;;
*) check "no HOME set still resolves the portable root" 1 "$out6" ;;
esac
echo
if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed"; exit 1; fi
echo "All isolated-venv checks passed"

View file

@ -98,8 +98,18 @@ check "setup.ps1 does not install Git while staging" \
"$(has "$SETUP_PS1" 'Background staging cannot install Git; retry with the foreground updater.')"
check "setup.ps1 preserves foreground Git bootstrap" \
"$(has "$SETUP_PS1" 'if ($gitNeeded -or -not $StageRoot) {')"
# The branch that decides TORCHINDUCTOR_CACHE_DIR, by what it does rather than by its old
# spelling: a staged run must take the stage root and must be asked FIRST, ahead of the Studio
# cache and the short drive-root fallback that a normal install chooses between.
_tcd="$(awk '/^\$TorchCacheDir = \$null$/{g=1} g{print} g && /^\$env:TORCHINDUCTOR_CACHE_DIR/{exit}' "$SETUP_PS1")"
check "setup.ps1 keeps the staging compiler cache under the stage root" \
"$(has "$SETUP_PS1" 'if ($StageRoot -or $LongPathsEnabled) {')"
"$(printf '%s' "$_tcd" | grep -qF 'if ($StageRoot) {' \
&& printf '%s' "$_tcd" | grep -qF '$TorchCacheDir = Join-Path $RuntimeRoot "TORCHINDUCTOR_CACHE_DIR"' \
&& echo 0 || echo 1)"
check "setup.ps1 asks about staging before anything else" \
"$([ "$(printf '%s' "$_tcd" | grep -n 'if ($StageRoot) {' | cut -d: -f1)" -lt \
"$(printf '%s' "$_tcd" | grep -n 'LongPathsEnabled' | head -1 | cut -d: -f1)" ] \
&& echo 0 || echo 1)"
# ── the staged activation cannot dot-source a copied Activate script ──
# A venv copied out of $STUDIO_HOME still names the original root in its activate

View file

@ -0,0 +1,320 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# Unit test for UNSLOTH_HOME master-root removal in scripts/uninstall.sh.
#
# setup.sh and setup.ps1 install llama.cpp, node and whisper.cpp as CHILDREN of the master
# root, beside studio/, so removing the Studio root alone strands multi-gigabyte trees. The
# root is user-chosen, so only a tree carrying the Unsloth owner marker may be deleted.
# Hermetic, following test_uninstall_sd_cpp_custom_root.sh: the real helpers and the real
# removal block are extracted from uninstall.sh and run against per-test fixtures.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
UNINSTALL_SH="$SCRIPT_DIR/../../scripts/uninstall.sh"
PASS=0
FAIL=0
_TMP_ROOT=$(mktemp -d)
trap 'rm -rf "$_TMP_ROOT"' EXIT
HOME="$_TMP_ROOT/home"
mkdir -p "$HOME"
assert_nodir() { _l="$1"; [ -e "$2" ] && { echo " FAIL: $_l (still present: $2)"; FAIL=$((FAIL+1)); } || { echo " PASS: $_l"; PASS=$((PASS+1)); }; }
assert_dir() { _l="$1"; [ -e "$2" ] && { echo " PASS: $_l"; PASS=$((PASS+1)); } || { echo " FAIL: $_l (missing $2)"; FAIL=$((FAIL+1)); }; }
assert_link() { _l="$1"; [ -L "$2" ] && { echo " PASS: $_l"; PASS=$((PASS+1)); } || { echo " FAIL: $_l (link missing $2)"; FAIL=$((FAIL+1)); }; }
# -e is false for a dangling symlink, which is exactly the shape one case below is about.
assert_present() { _l="$1"; { [ -e "$2" ] || [ -L "$2" ]; } && { echo " PASS: $_l"; PASS=$((PASS+1)); } || { echo " FAIL: $_l (missing $2)"; FAIL=$((FAIL+1)); }; }
assert_eq() { _l="$1"; [ "$2" = "$3" ] && { echo " PASS: $_l"; PASS=$((PASS+1)); } || { echo " FAIL: $_l (got '$2', want '$3')"; FAIL=$((FAIL+1)); }; }
HELPERS_FILE=$(mktemp -p "$_TMP_ROOT")
{
sed -n '/^_remove_path() {/,/^}/p' "$UNINSTALL_SH"
sed -n '/^_remove_lock_file() {/,/^}/p' "$UNINSTALL_SH"
sed -n '/^_is_unsafe_root() {/,/^}/p' "$UNINSTALL_SH"
sed -n '/^_master_root() {/,/^}/p' "$UNINSTALL_SH"
sed -n '/^_set_marker() {/,/^}/p' "$UNINSTALL_SH"
} > "$HELPERS_FILE"
# Every helper the block calls, by name. A helper that stops being extracted goes "command not
# found" inside a subshell the harness already swallows, which reads as "nothing was removed"
# and passes the keep-assertions while failing the remove-assertions for the wrong reason.
for _h in _remove_path _remove_lock_file _is_unsafe_root _master_root _set_marker; do
grep -q "^$_h() {" "$HELPERS_FILE" || { echo "FAIL: helpers missing $_h"; exit 1; }
done
# The real block, anchored on its own first line so a rename fails loudly instead of vacuously.
BLOCK_FILE=$(mktemp -p "$_TMP_ROOT")
sed -n '/^[[:space:]]*_mr_root="\$_MASTER_ROOT_SAVED"/,/^[[:space:]]*# end master-root children$/p' "$UNINSTALL_SH" > "$BLOCK_FILE"
[ -s "$BLOCK_FILE" ] || { echo "FAIL: master-root removal block not extracted"; exit 1; }
grep -q 'unsloth-studio-owned' "$BLOCK_FILE" || { echo "FAIL: extracted block lost the marker gate"; exit 1; }
run_block() {
# The real script resolves the master root ONCE before anything is removed, because the note
# it can read lives inside a tree the custom-root loop deletes. The block takes that saved
# value, so the harness has to seed it the same way.
( set -e; HOME="$1"; UNSLOTH_HOME="$2"; export HOME UNSLOTH_HOME
. "$HELPERS_FILE"; _MASTER_ROOT_SAVED="$(_master_root)"; . "$BLOCK_FILE" ) || true
}
echo "== an owned master root loses its runtime children =="
MR="$_TMP_ROOT/portable"
mkdir -p "$MR"/{studio,llama.cpp,node,whisper.cpp}
for d in llama.cpp node whisper.cpp; do : > "$MR/$d/.unsloth-studio-owned"; done
: > "$MR/.llama.cpp.install.lock"
: > "$MR/.node.install.lock"
run_block "$HOME" "$MR"
assert_nodir "llama.cpp removed" "$MR/llama.cpp"
assert_nodir "node removed" "$MR/node"
assert_nodir "whisper.cpp removed" "$MR/whisper.cpp"
assert_nodir "llama lock removed" "$MR/.llama.cpp.install.lock"
assert_nodir "node lock removed" "$MR/.node.install.lock"
assert_dir "studio root left to the Studio removal" "$MR/studio"
echo "== only a FILE is removed from an install-lock path =="
# prebuilt_core.install_lock creates the lock with os.open(O_CREAT | O_EXCL), so it is always a
# regular file. The names are fixed, so in a user-chosen root a directory here is the user's:
# rm -rf on it would take a tree that never carried the owner marker the children above need.
MRL="$_TMP_ROOT/lockshapes"
mkdir -p "$MRL/studio" "$MRL/.node.install.lock/keep"
: > "$MRL/.node.install.lock/keep/user-file"
: > "$MRL/.llama.cpp.install.lock"
# A link at a lock name is the user's too: O_CREAT | O_EXCL never makes one, and
# uninstall.ps1 already excluded reparse points, so unlinking it here was the two halves of
# one rule disagreeing. Two shapes, since a link to a file is -f and a dangling one is not.
ln -s "$MRL/.llama.cpp.install.lock" "$MRL/.whisper.cpp.install.lock"
ln -s "$MRL/nowhere" "$MRL/.sd.cpp.install.lock"
: > "$MRL/.sd.cpp.install.lock.stale.4242"
run_block "$HOME" "$MRL"
assert_nodir "a real lock file is removed" "$MRL/.llama.cpp.install.lock"
assert_nodir "a stale lock file is removed" "$MRL/.sd.cpp.install.lock.stale.4242"
assert_link "a linked lock is kept" "$MRL/.whisper.cpp.install.lock"
assert_link "a dangling linked lock is kept too" "$MRL/.sd.cpp.install.lock"
assert_dir "a directory at a lock path is kept" "$MRL/.node.install.lock"
assert_dir "and so is everything under it" "$MRL/.node.install.lock/keep/user-file"
echo "== an unmarked tree is somebody else's and is kept =="
MR2="$_TMP_ROOT/mixed"
mkdir -p "$MR2"/{llama.cpp,node}
: > "$MR2/node/.unsloth-studio-owned"
: > "$MR2/llama.cpp/my-own-build"
run_block "$HOME" "$MR2"
assert_dir "unmarked llama.cpp kept" "$MR2/llama.cpp"
assert_dir "its contents kept" "$MR2/llama.cpp/my-own-build"
assert_nodir "marked node removed" "$MR2/node"
echo "== an emptied master root is pruned, a used one is not =="
MR3="$_TMP_ROOT/empty-after"
mkdir -p "$MR3/node"
: > "$MR3/node/.unsloth-studio-owned"
run_block "$HOME" "$MR3"
assert_nodir "empty master root pruned" "$MR3"
MR4="$_TMP_ROOT/still-used"
mkdir -p "$MR4/node" "$MR4/my-datasets"
: > "$MR4/node/.unsloth-studio-owned"
run_block "$HOME" "$MR4"
assert_dir "master root with user files kept" "$MR4"
assert_dir "the user's files kept" "$MR4/my-datasets"
echo "== the value is stripped and tilde-expanded, like the installers =="
MR5="$HOME/tilde-root"
mkdir -p "$MR5/node"
: > "$MR5/node/.unsloth-studio-owned"
run_block "$HOME" " ~/tilde-root "
assert_nodir "padded tilde value resolved" "$MR5/node"
echo "== a shared .staging keeps whatever is not ours =="
MR6="$_TMP_ROOT/staged"
mkdir -p "$MR6/node" "$MR6/.staging/somebody-elses"
: > "$MR6/node/.unsloth-studio-owned"
run_block "$HOME" "$MR6"
assert_dir "a non-empty .staging is kept whole" "$MR6/.staging/somebody-elses"
MR7="$_TMP_ROOT/staged-empty"
mkdir -p "$MR7/node" "$MR7/.staging"
: > "$MR7/node/.unsloth-studio-owned"
run_block "$HOME" "$MR7"
assert_nodir "an empty .staging is pruned" "$MR7/.staging"
echo "== an unset or default root selects nothing =="
got=$( ( HOME="$HOME"; export HOME; unset UNSLOTH_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "unset UNSLOTH_HOME yields nothing" "$got" ""
got=$( ( HOME="$HOME"; UNSLOTH_HOME=" "; export HOME UNSLOTH_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "blank UNSLOTH_HOME yields nothing" "$got" ""
mkdir -p "$HOME/.unsloth"
got=$( ( HOME="$HOME"; UNSLOTH_HOME="$HOME/.unsloth"; export HOME UNSLOTH_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "the default root is left to the blocks that own it" "$got" ""
echo "== a deny-listed root is refused =="
got=$( ( HOME="$HOME"; UNSLOTH_HOME="/"; export HOME UNSLOTH_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "/ yields nothing" "$got" ""
echo "== a root that is gone does not end the uninstall =="
# Under set -e the canonicalizing `cd` fails whenever the root is absent (a second uninstall, a
# portable install on an unplugged drive) and a bare assignment takes that exit status. bash
# hides it by clearing errexit inside command substitution, so this runs the shells the
# advertised `| sh` one-liner reaches. REACHED_END is the assertion, not the value returned.
for _shell in dash "bash --posix" sh; do
command -v ${_shell%% *} > /dev/null 2>&1 || continue
# || true: this harness runs under set -e too, so without it the failing child takes the
# whole test file down and the check below never reports which shell broke.
out=$( env -i HOME="$HOME" PATH="$PATH" UNSLOTH_HOME="$_TMP_ROOT/never-existed" \
$_shell -c "set -e; . \"$HELPERS_FILE\"; r=\$(_master_root); echo REACHED_END" 2>&1 \
|| true )
assert_eq "$_shell keeps going past a missing root" "$out" "REACHED_END"
done
echo "== a dangling runtime symlink is not ours to unlink =="
# -e is false for a dangling symlink, so an unmarked one named llama.cpp fell past the marker
# gate into _remove_path, which treats -L as present and removes the link. Its target volume
# being unmounted is the ordinary reason, and the link is the user's.
MRD="$_TMP_ROOT/dangling"
mkdir -p "$MRD/studio" "$MRD/node"
: > "$MRD/node/.unsloth-studio-owned"
ln -s "$_TMP_ROOT/no-such-volume/llama.cpp" "$MRD/llama.cpp"
run_block "$HOME" "$MRD"
assert_present "an unmarked dangling link is kept" "$MRD/llama.cpp"
assert_nodir "a marked tree beside it still goes" "$MRD/node"
echo "== a whitespace-only Studio override does not suppress the master root =="
# storage_roots.studio_root() trims these, so " " is unset to every resolver. A bare -n test
# called it present, skipped the master-root branch, and then discarded the whitespace path:
# the runtime siblings went and <UNSLOTH_HOME>/studio stayed.
ROOTS_FILE=$(mktemp -p "$_TMP_ROOT")
sed -n '/^_custom_studio_roots() {/,/^}/p' "$UNINSTALL_SH" > "$ROOTS_FILE" 2>/dev/null || : > "$ROOTS_FILE"
if grep -q '_custom_studio_roots() {' "$ROOTS_FILE"; then
MRW="$_TMP_ROOT/blankoverride"
mkdir -p "$MRW/studio"
got=$( ( HOME="$HOME"; UNSLOTH_HOME="$MRW"; UNSLOTH_STUDIO_HOME=" "
export HOME UNSLOTH_HOME UNSLOTH_STUDIO_HOME
. "$HELPERS_FILE"
_emit() { printf '%s\n' "$1"; }
_from_conf() { :; }
. "$ROOTS_FILE"
_custom_studio_roots 2>/dev/null ) | grep -c "$MRW/studio" ) || got=0
assert_eq "a blank override still reaches the master root" "$got" "1"
else
echo " FAIL: _custom_studio_roots could not be extracted"; FAIL=$((FAIL+1))
fi
echo "== the master root is remembered when the environment does not carry it =="
# `UNSLOTH_HOME=/mnt/portable unsloth studio update` installs the runtimes there and leaves
# nothing behind in the environment. setup.sh writes a note in the Studio tree; without it an
# uninstall later removed the Studio tree and stranded the runtimes.
# The real layout: Studio lives at <master>/studio, and with a bare environment the only thing
# that names that directory is the default-mode studio.conf install.sh wrote.
NOTED="$_TMP_ROOT/noted"
mkdir -p "$HOME/.unsloth/studio/share" "$NOTED/studio/share" "$HOME/.local/share/unsloth"
printf "UNSLOTH_EXE='%s'\n" "$NOTED/studio/unsloth_studio/bin/unsloth" \
> "$HOME/.local/share/unsloth/studio.conf"
printf '%s\n' "$NOTED" > "$NOTED/studio/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME; export HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "the note names the master root" "$got" "$NOTED"
# The environment still outranks it, and the deny list still applies to whatever it names.
got=$( ( HOME="$HOME"; UNSLOTH_HOME="$_TMP_ROOT/from-env"; export HOME UNSLOTH_HOME
. "$HELPERS_FILE"; _master_root ) )
assert_eq "the environment outranks the note" "$got" "$_TMP_ROOT/from-env"
printf '%s\n' "$HOME/.unsloth" > "$NOTED/studio/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME; export HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "a note naming the default root is still refused" "$got" ""
# A tree copied from one master root to another keeps a note naming the ORIGINAL, whose
# runtimes carry the same owner markers the copy's would, so accepting it would authorise
# deleting the original install's llama.cpp, node and whisper.cpp.
COPIED="$_TMP_ROOT/copied"
mkdir -p "$COPIED/studio/share"
printf '%s\n' "$NOTED" > "$COPIED/studio/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME; UNSLOTH_STUDIO_HOME="$COPIED/studio"
export HOME UNSLOTH_STUDIO_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "a note carried in from another master root is refused" "$got" ""
rm -f "$NOTED/studio/share/.unsloth-master-root" "$HOME/.local/share/unsloth/studio.conf"
echo "== a named Studio root does not borrow another install's note =="
# Two installs on one box, the legacy tree carrying a note and the named one not: probing the
# legacy path first let its note win here while the removal worked on the named tree, so the run
# deleted one Studio and the OTHER install's runtime children.
BORROWED="$_TMP_ROOT/borrowed"
NAMED_MASTER="$_TMP_ROOT/named-master"
NAMED="$NAMED_MASTER/studio"
mkdir -p "$HOME/.unsloth/studio/share" "$BORROWED/studio" "$NAMED/share"
printf '%s\n' "$BORROWED" > "$HOME/.unsloth/studio/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME; UNSLOTH_STUDIO_HOME="$NAMED"
export HOME UNSLOTH_STUDIO_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "an unrelated install's note is not adopted" "$got" ""
# The alias is the same rule, and the named root's OWN note is still read.
printf '%s\n' "$NAMED_MASTER" > "$NAMED/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME UNSLOTH_STUDIO_HOME; STUDIO_HOME="$NAMED"
export HOME STUDIO_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "the named root's own note is still read" "$got" "$NAMED_MASTER"
rm -f "$HOME/.unsloth/studio/share/.unsloth-master-root" "$NAMED/share/.unsloth-master-root"
echo "== a padded Studio override still finds the note setup.sh wrote =="
# setup.sh trims UNSLOTH_STUDIO_HOME before choosing where to write, so appending to the padded
# value looked somewhere that does not exist: the note was never found, while the removal loop
# trimmed the same override and stranded the master-root runtimes.
PADDED_MASTER="$_TMP_ROOT/padded-master"
PADDED="$PADDED_MASTER/studio"
mkdir -p "$PADDED/share"
printf '%s\n' "$PADDED_MASTER" > "$PADDED/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME; UNSLOTH_STUDIO_HOME=" $PADDED "
export HOME UNSLOTH_STUDIO_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "a padded override finds the note" "$got" "$PADDED_MASTER"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME UNSLOTH_STUDIO_HOME; STUDIO_HOME=" $PADDED "
export HOME STUDIO_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "the padded alias finds it too" "$got" "$PADDED_MASTER"
echo "== only the installer's own stale locks are swept =="
# prebuilt_core.py moves a lock aside as <name>.stale.<pid>. A bare .*.install.lock.stale.* glob
# also matched an unrelated hidden file, and in a root the user chose that file is theirs.
STALE="$_TMP_ROOT/stale-root"
mkdir -p "$STALE"
: > "$STALE/.node.install.lock.stale.4242"
: > "$STALE/.backup.install.lock.stale.copy"
: > "$STALE/.llama.cpp.install.lock.stale.notapid"
run_block "$HOME" "$STALE"
assert_nodir "the installer's own stale lock goes" "$STALE/.node.install.lock.stale.4242"
assert_present "an unrelated hidden file stays" "$STALE/.backup.install.lock.stale.copy"
assert_present "a non-numeric suffix stays" "$STALE/.llama.cpp.install.lock.stale.notapid"
echo "== a note with a second line is refused, as the comment beside it promises =="
# The four readers have to agree, and this is the one that authorises a delete: the two Python
# readers strip the WHOLE file, so a two-line note is not a directory to them, while taking
# line 1 here licensed removing <master>/llama.cpp, node, whisper.cpp and sd.cpp.
MULTI_MASTER="$_TMP_ROOT/multiline-master"
MULTI="$MULTI_MASTER/studio"
mkdir -p "$MULTI/share" "$_TMP_ROOT/elsewhere"
printf '%s\n%s\n' "$MULTI_MASTER" "$_TMP_ROOT/elsewhere" > "$MULTI/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME; UNSLOTH_STUDIO_HOME="$MULTI"
export HOME UNSLOTH_STUDIO_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "a two-line note names no master root" "$got" ""
# ... and a blank second line is just the trailing newline of an ordinary note.
printf '%s\n\n' "$MULTI_MASTER" > "$MULTI/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME; UNSLOTH_STUDIO_HOME="$MULTI"
export HOME UNSLOTH_STUDIO_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "a trailing blank line is still one line" "$got" "$MULTI_MASTER"
# The ordinary note must keep working, or the check above proves only that nothing is read.
printf '%s\n' "$MULTI_MASTER" > "$MULTI/share/.unsloth-master-root"
got=$( ( HOME="$HOME"; unset UNSLOTH_HOME; UNSLOTH_STUDIO_HOME="$MULTI"
export HOME UNSLOTH_STUDIO_HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "a one-line note is still honoured" "$got" "$MULTI_MASTER"
echo "== a note in the legacy tree cannot aim the uninstall at a second install =="
# The legacy install carries a note naming $HOME, which setup used to accept: the profile
# CONTAINS ~/.unsloth/studio, so containment passed and the value is not the legacy root.
# _custom_studio_roots then emitted "$HOME/studio", and a SECOND marked install kept there was
# accepted as a Studio root to remove, taking its database and outputs. The deny list does not
# catch it: it refuses $HOME as a master root, not the Studio candidate derived from it.
# storage_roots._is_legacy_studio_tree and the CLI decline that note outright; so must this.
LEGACY_HOME="$_TMP_ROOT/legacy-note-home"
mkdir -p "$LEGACY_HOME/.unsloth/studio/share"
printf '%s\n' "$LEGACY_HOME" > "$LEGACY_HOME/.unsloth/studio/share/.unsloth-master-root"
mkdir -p "$LEGACY_HOME/studio/share" "$LEGACY_HOME/studio/outputs"
: > "$LEGACY_HOME/studio/.unsloth-studio-owned"
: > "$LEGACY_HOME/studio/studio.db"
got=$( ( HOME="$LEGACY_HOME"; unset UNSLOTH_HOME UNSLOTH_STUDIO_HOME STUDIO_HOME
export HOME; . "$HELPERS_FILE"; _master_root ) )
assert_eq "a legacy-tree note names no master root" "$got" ""
assert_dir "the second install is untouched" "$LEGACY_HOME/studio/studio.db"
echo
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View file

@ -33,6 +33,10 @@ HELPERS_FILE=$(mktemp -p "$_TMP_ROOT")
sed -n '/^_is_venv_dir() {/,/^}/p' "$UNINSTALL_SH"
sed -n '/^_is_studio_root() {/,/^}/p' "$UNINSTALL_SH"
sed -n '/^_is_unsafe_root() {/,/^}/p' "$UNINSTALL_SH"
# The loop now asks whether the Studio root is also the master root before removing it
# whole, so this helper has to come across or the block dies on "command not found" and
# every keep-assertion passes for the wrong reason.
sed -n '/^_master_root() {/,/^}/p' "$UNINSTALL_SH"
# The loop removes roots through this wrapper; without it and its marker helper the
# fragment dies with "command not found" and every assertion below is vacuous.
sed -n '/^_set_marker() {/,/^}/p' "$UNINSTALL_SH"
@ -45,7 +49,7 @@ HELPERS_FILE=$(mktemp -p "$_TMP_ROOT")
} > "$HELPERS_FILE"
grep -q '_owned_sd_cpp_roots' "$HELPERS_FILE" || { echo "FAIL: helpers missing _owned_sd_cpp_roots"; exit 1; }
# _is_studio_root's own helpers, or every root reads as foreign and nothing below is removed.
for _needed in _is_owner_marker _is_venv_dir _restore_owner_marker; do
for _needed in _is_owner_marker _is_venv_dir _restore_owner_marker _master_root; do
grep -q "^$_needed() {" "$HELPERS_FILE" || { echo "FAIL: helpers missing $_needed"; exit 1; }
done
grep -q '_sd_cpp_sibling_bases() {' "$HELPERS_FILE" || { echo "FAIL: helpers missing _sd_cpp_sibling_bases"; exit 1; }

View file

@ -41,6 +41,11 @@ setup_fail() { exit "$1"; }
_assert_studio_owned_or_absent() { :; }
C_ERR=""
_STUDIO_HOME_IS_CUSTOM=false
# The runtime children took their own ownership flag when UNSLOTH_HOME arrived: with no master
# root it carries _STUDIO_HOME_IS_CUSTOM, which is what this fixture is about. Unseeded it is an
# unbound variable under set -u, and the block dies before printing anything, which reads here as
# every state assertion getting an empty value rather than as a broken harness.
_RUNTIME_ROOT_IS_CUSTOM=false
_NEED_LLAMA_SOURCE_BUILD=UNSET
_SKIP_PREBUILT_INSTALL=UNSET
'
@ -54,10 +59,23 @@ SNIP="$PREAMBLE"$'\n'"$block"$'\n'"$EPILOGUE"
# run_link <local_dir> <canonical_llama_cpp_dir> -> prints state lines; RC in $RC
run_link() {
# stderr kept on failure: the block runs under set -u, so a variable this preamble does not
# seed kills it silently and every val() below returns empty. That looked like seven wrong
# answers rather than one missing seed, so say which it is.
OUT="$(env -i PATH="$PATH" HOME="$T" \
UNSLOTH_LOCAL_LLAMA_CPP_DIR="$1" LLAMA_CPP_DIR="$2" \
bash -c "$SNIP" 2>/dev/null)"
bash -c "$SNIP" 2>"$T/.stderr")"
RC=$?
# Only when the block claims it succeeded: some fixtures below expect setup_fail, and those
# legitimately exit before the epilogue.
if [ "$RC" -eq 0 ]; then
case "$OUT" in
*LINKED=*) : ;;
*) echo "FAIL: the block exited 0 and printed no state; stderr was:" >&2
sed 's/^/ /' "$T/.stderr" >&2
exit 1 ;;
esac
fi
}
val() { printf '%s\n' "$OUT" | grep "^$1=" | head -1 | cut -d= -f2-; }

View file

@ -34,6 +34,7 @@ SHARED_FUNCTIONS = (
"Write-PathAccessDenied",
"Get-CanonicalDir",
"Test-StudioHomeIsCustom",
"Get-MasterRootOverride",
"Get-ManagedLlamaCppDir",
"Invoke-ManagedLlamaCppPreflight",
)
@ -216,11 +217,12 @@ def test_a_custom_studio_home_is_never_called_a_cache_we_own() -> None:
"""Do not call an unreadable custom Unsloth home a managed cache."""
body = _function_source(INSTALL_PS1, "Invoke-ManagedLlamaCppPreflight")
# Use the same predicate for path selection and ownership wording.
assert "$homeIsCustom = Test-StudioHomeIsCustom" in body
# A master root counts as custom too: it moves llama.cpp out of the default location.
assert "$homeIsCustom = (Test-StudioHomeIsCustom) -or [bool](Get-MasterRootOverride)" in body
assert "-OwnershipUnverified:$homeIsCustom" in body
assert (
'Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install"'
" -OwnershipUnverified:$StudioHomeIsCustom" in SETUP_PS1
" -OwnershipUnverified:$RuntimeRootIsCustom" in SETUP_PS1
)

View file

@ -4408,6 +4408,7 @@ _LLAMA_CPP_NO_SPACE=false
_LLAMA_CPP_DEGRADED=false
_explicit_llama_backend=""
_STUDIO_HOME_IS_CUSTOM=false
_RUNTIME_ROOT_IS_CUSTOM=false
_STUDIO_OWNED_MARKER=".unsloth-owned"
step() { echo "step: $2"; }
substep() { echo "substep: $1"; }

View file

@ -720,6 +720,9 @@ _LLAMA_CPP_NO_SPACE=false
_LLAMA_CPP_DEGRADED=false
_explicit_llama_backend=""
_STUDIO_HOME_IS_CUSTOM=false
# The flag the runtime children switched to when UNSLOTH_HOME arrived: the guards inside the
# extracted block read it, and setup.sh derives it in the section this harness stands in for.
_RUNTIME_ROOT_IS_CUSTOM=false
_STUDIO_OWNED_MARKER=".unsloth-owned"
_WHISPER_RECOVERED=false
step() { echo "step|$1|$2"; }
@ -737,6 +740,8 @@ $ErrorActionPreference = "Stop"
$NeedLlamaSourceBuild = $false
$script:LlamaCppDegraded = $false
$StudioHomeIsCustom = $false
# Same reason as _RUNTIME_ROOT_IS_CUSTOM in the shell harness above.
$RuntimeRootIsCustom = $false
function step { param($a, $b, $c) Write-Output "step|$a|$b" }
function substep { param($a, $b) Write-Output "substep|$a" }
function Write-LlamaFailureLog { param($Output) }

View file

@ -94,10 +94,19 @@ def load_studio_run_module(monkeypatch):
startup_banner.stdout_supports_color = lambda: False
monkeypatch.setitem(sys.modules, "startup_banner", startup_banner)
# run.py exports UNSLOTH_STUDIO_HOME and UNSLOTH_LLAMA_CPP_PATH at module scope and this
# helper execs it in-process, so without monkeypatch they outlive the test.
for _leaked in ("UNSLOTH_STUDIO_HOME", "UNSLOTH_LLAMA_CPP_PATH"):
monkeypatch.setenv(_leaked, os.environ.get(_leaked, ""))
monkeypatch.delenv(_leaked)
paths = types.ModuleType("utils.paths")
paths.__path__ = []
storage_roots = types.ModuleType("utils.paths.storage_roots")
storage_roots.studio_root = lambda: PACKAGE_ROOT / ".studio-test-root"
# Imported at module scope too, so the stub carries it. None keeps these tests on the
# legacy-default path they were written for.
storage_roots.unsloth_home = lambda: None
paths.storage_roots = storage_roots
monkeypatch.setitem(sys.modules, "utils.paths", paths)
monkeypatch.setitem(sys.modules, "utils.paths.storage_roots", storage_roots)

View file

@ -75,7 +75,9 @@ def test_every_denial_route_reports_instead_of_proceeding():
assert '$llamaGitState = Get-PathState -Path (Join-Path $LlamaCppDir ".git")' in SETUP_PS1
assert '$llamaGitState -eq "Denied"' in SETUP_PS1
assert "$pathState = Get-PathState -Path $Path -PathType Container" in SETUP_PS1
assert '$StudioHomeIsCustom -and $pathState -eq "Denied"' in SETUP_PS1
# $isCustomRoot is the parameter form of $StudioHomeIsCustom: the runtime children pass
# $RuntimeRootIsCustom, which a master root widens, and it defaults to the other flag.
assert '$isCustomRoot -and $pathState -eq "Denied"' in SETUP_PS1
# The junction path replaces this destination, so it needs its own stop.
assert "$destState = Get-PathState -Path $LlamaCppDir" in SETUP_PS1
assert '$destState -eq "Denied"' in SETUP_PS1
@ -129,7 +131,8 @@ def test_ownership_guard_distinguishes_denied_from_unowned():
# genuinely-unowned case only.
assert "is not marked as an Unsloth-owned $Label" in guard
# Both stops stay gated, so default-home installs behave exactly as before.
assert guard.count("$StudioHomeIsCustom -and") >= 3
assert "$isCustomRoot = $StudioHomeIsCustom" in guard
assert guard.count("$isCustomRoot -and") >= 3
def test_no_bare_test_path_probes_inside_the_llama_install_tree():
@ -321,7 +324,7 @@ def test_the_source_build_denial_never_advises_deleting_an_unproven_tree():
block = _slice("$llamaBinState = ", "$WillBuildLlamaFromSource")
denials = [ln.strip() for ln in block.splitlines() if "Exit-PathAccessDenied" in ln]
assert len(denials) >= 2, denials
assert all(d.endswith("-OwnershipUnverified:$StudioHomeIsCustom") for d in denials), denials
assert all(d.endswith("-OwnershipUnverified:$RuntimeRootIsCustom") for d in denials), denials
assert all(d.startswith("Exit-PathAccessDenied -Path $LlamaCppDir ") for d in denials), denials
@ -368,10 +371,13 @@ def test_the_whisper_phase_survives_an_unreadable_whisper_tree():
# Only the denial is handed back; an unowned tree must still stop.
assert 'Exit-SetupFailure "$Label path is not an Unsloth-owned install' in guard
whisper = _whisper_phase()
assert '-Label "whisper.cpp install" -NonFatal) -eq "Denied"' in whisper, whisper
assert (
'-Label "whisper.cpp install" -NonFatal -IsCustom $RuntimeRootIsCustom) -eq "Denied"'
in whisper
), whisper
# Scoped to the new branch: both phrases occur elsewhere in the phase, so a
# phase-wide match proves nothing about this branch.
marker = '-NonFatal) -eq "Denied") {'
marker = '-NonFatal -IsCustom $RuntimeRootIsCustom) -eq "Denied") {'
assert marker in whisper, whisper
denial = whisper.split(marker, 1)[1].split("\n} elseif", 1)[0]
assert re.search(r'^\s*step "whisper\.cpp" ', denial, re.M), denial
@ -389,7 +395,7 @@ def test_the_whisper_phase_survives_an_unreadable_whisper_tree():
def test_the_whisper_skip_stays_behind_the_installer_gate():
"""The guard used to live inside the installer branch, so a tree without the
installer was a no-op. Hoisting it must not make that case fatal."""
marker = "-NonFatal) -eq"
marker = "-NonFatal -IsCustom $RuntimeRootIsCustom) -eq"
whisper = _whisper_phase()
assert marker in whisper, whisper
head = whisper.split(marker, 1)[0]

View file

@ -52,7 +52,14 @@ Check "the source-build .git probe stops on a denied checkout" (
$setupText -match '\$llamaGitState -eq "Denied"')
Check "the ownership guard stops on a denied root instead of returning" (
$setupText -match '\$pathState = Get-PathState -Path \$Path -PathType Container' -and
$setupText -match '\$StudioHomeIsCustom -and \$pathState -eq "Denied"')
$setupText -match '\$isCustomRoot -and \$pathState -eq "Denied"')
# The guard now takes the flag as a parameter, because the runtime children beside studio\ under
# a master root are owned even when the Studio home itself is the legacy one. With no override
# it must still be the script-level flag this file's other checks are about, or the guard would
# be reading something nothing sets.
Check "the guard's flag defaults to the script-level one" (
$setupText -match '\$isCustomRoot = \$StudioHomeIsCustom' -and
$setupText -match 'if \(\$null -ne \$IsCustom\) \{ \$isCustomRoot = \[bool\]\$IsCustom \}')
Check "guidance says an app reinstall does not reset the folder" (
$setupText -match 'reinstalling Unsloth Studio, to any drive, reuses it' -and
$setupText -match 'Reinstalling the app does not reset it\.')
@ -445,6 +452,28 @@ if ($assertSrc -and $markSrc) {
try { $null = Assert-StudioOwnedOrAbsent -Path $nfUnowned -Label "whisper.cpp install" -NonFatal }
catch { $threw = $true; $thrownBy = $_.Exception.Message }
Check "-NonFatal does not excuse an unowned tree" ($threw -and $thrownBy -eq "EXIT-SETUP")
# A non-directory at a runtime path under a user-chosen root. install_llama_prebuilt's
# activate_install_tree moves aside whatever Path.exists() finds, so a Container-only
# probe let a user's file be displaced by the install that followed.
$nfFile = Join-Path $nfRoot "llama.cpp"
Set-Content -LiteralPath $nfFile -Value "mine"
$nfLink = Join-Path $nfRoot "node"
New-Item -ItemType SymbolicLink -Path $nfLink -Target (Join-Path $nfRoot "gone") -ErrorAction SilentlyContinue | Out-Null
foreach ($shape in @(@("a regular file", $nfFile), @("a dangling link", $nfLink))) {
if (-not (Get-Item -LiteralPath $shape[1] -Force -ErrorAction SilentlyContinue)) { continue }
$threw = $false
$thrownBy = $null
try { $null = Assert-StudioOwnedOrAbsent -Path $shape[1] -Label "llama.cpp install" -NonFatal }
catch { $threw = $true; $thrownBy = $_.Exception.Message }
Check "$($shape[0]) at a custom runtime path stops setup" ($threw -and $thrownBy -eq "EXIT-SETUP")
# The legacy default home keeps the behaviour it had: only a chosen root is the
# user's directory, and setup.sh draws the line in the same place.
$threw = $false
try { $null = Assert-StudioOwnedOrAbsent -Path $shape[1] -Label "llama.cpp install" -IsCustom $false }
catch { $threw = $true }
Check "$($shape[0]) at a default runtime path is left to the caller" (-not $threw)
}
} finally {
Set-NfDenied $false
Remove-Item -Recurse -Force -LiteralPath $nfRoot -ErrorAction SilentlyContinue
@ -458,7 +487,7 @@ $installPath = [System.IO.Path]::Combine($repoRoot, "install.ps1")
$preflightFns = @("Test-AccessDeniedError", "Get-PathState", "Get-LlamaCppInstallReadState",
"Get-PathDenialDetail", "Get-SecuritySoftwareNote",
"Write-PathAccessDenied", "Get-CanonicalDir",
"Test-StudioHomeIsCustom", "Get-ManagedLlamaCppDir",
"Test-StudioHomeIsCustom", "Get-MasterRootOverride", "Get-ManagedLlamaCppDir",
"Invoke-ManagedLlamaCppPreflight")
$preflightSrc = @()
foreach ($fn in $preflightFns) {
@ -466,6 +495,31 @@ foreach ($fn in $preflightFns) {
Check "install.ps1 defines $fn" ($null -ne $src)
if ($src) { $preflightSrc += $src }
}
# Anything the lifted bodies call that install.ps1 defines and the lift then leaves behind,
# other than the stubs the harness supplies. A missing helper does not announce itself: the
# child dies on the first call, every Write-Host after it is lost, and the assertions below
# fail as though the preflight had resolved the wrong directory. This is how
# Get-MasterRootOverride was missed. The test is "install.ps1 defines it", not "this session
# can resolve it": Get-SecuritySoftwareNote reaches Get-CimInstance and Get-MpPreference, which
# exist on the Windows host that runs the preflight and on no Linux runner, so resolvability
# would report those two as unlifted helpers here and nothing at all there.
$harnessStubs = @("step", "substep", "Write-StudioLine")
$preflightCalls = @()
foreach ($src in $preflightSrc) {
$tokens = [System.Management.Automation.PSParser]::Tokenize($src, [ref]$null)
foreach ($t in $tokens) {
if ($t.Type -eq "Command" -and $t.Content -match "^(Get|Test|Invoke|Write|Set|New)-[A-Za-z]+$") {
$preflightCalls += $t.Content
}
}
}
$unlifted = @($preflightCalls | Sort-Object -Unique | Where-Object {
$_ -notin $preflightFns -and $_ -notin $harnessStubs -and
$null -ne (Get-FunctionSource -Path $installPath -Name $_)
})
Check ("the preflight lifts every install.ps1 helper it calls" +
$(if ($unlifted.Count) { " (not lifted: " + ($unlifted -join ", ") + ")" } else { "" })) `
($unlifted.Count -eq 0)
if ($preflightSrc.Count -eq $preflightFns.Count) {
$preflightHarness = @"
`$ErrorActionPreference = "Stop"

View file

@ -1036,6 +1036,8 @@ def test_cli_guard_pins_every_storage_root_override_studio_reads():
source = storage_roots.read_text(encoding = "utf-8")
overrides = set(re.findall(r'environ\.get\(\s*"(UNSLOTH_[A-Z_]*(?:HOME|PATH|DIR))"', source))
assert overrides, "no storage root overrides found: has storage_roots.py moved?"
# Same case: _setup_cache_env fills each only when blank, so a user's relative value survives.
overrides |= set(re.findall(r'^\s*"([A-Z][A-Z0-9_]*)":\s*str\(', source, re.MULTILINE))
missing = sorted(overrides - set(_system_dir_guard._RELATIVE_PATH_ENV))
assert not missing, f"relative values of {missing} would be retargeted by the move"

View file

@ -0,0 +1,340 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""UNSLOTH_HOME names the tree, not the Studio directory inside it, and the native runtimes are
siblings of studio/, the spelling studio/setup.sh and scripts/build_whisper_cpp.sh already use. A
resolver deriving them from studio_root() would look in <root>/studio/<tool> for what the
installer put at <root>/<tool>, so managed Node and whisper.cpp go missing and run.py pins the
wrong llama.cpp path into every worker.
Run in a subprocess per case: these modules read the environment at import time.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
BACKEND = REPO / "studio" / "backend"
PROBE = """
import json, os, sys
sys.path.insert(0, os.environ["_BACKEND"])
from utils.paths import storage_roots as sr
from utils.node_runtime import managed_node_dir
from core.inference.stt_ggml_sidecar import _managed_whisper_cpp_dir
# studio_root() is called constantly, so a warning it emits for a supported
# layout is not one line, it is a flooded log.
_warnings = []
sr.logger.warning = lambda msg, *a, **k: _warnings.append(msg % a if a else msg)
print(json.dumps({
"studio": str(sr.studio_root()),
"master": None if sr.unsloth_home() is None else str(sr.unsloth_home()),
"node": str(managed_node_dir()),
"whisper": str(_managed_whisper_cpp_dir()),
"warnings": _warnings,
}))
"""
def _resolve(env_overrides: dict[str, str], home: Path) -> dict[str, str]:
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": str(home),
"USERPROFILE": str(home),
"_BACKEND": str(BACKEND),
}
# A hand-built environment has to carry what the interpreter needs: Windows python exits 1
# with no usable message when SYSTEMROOT is absent, which read as "the resolver answered
# wrongly" on every Windows runner.
for name in ("SYSTEMROOT", "SystemRoot", "COMSPEC", "PATHEXT", "TEMP", "TMP", "WINDIR"):
value = os.environ.get(name)
if value:
env.setdefault(name, value)
env.update(env_overrides)
out = subprocess.run([sys.executable, "-c", PROBE], env = env, capture_output = True, text = True)
# Not check = True: the child's stderr is the only thing that says why, and swallowing it is
# how a dead interpreter passes for a wrong answer.
assert out.returncode == 0, f"probe failed ({out.returncode}): {out.stderr.strip()}"
return json.loads(out.stdout.strip().splitlines()[-1])
def test_portable_root_puts_the_tools_beside_studio(tmp_path):
home = tmp_path / "home"
home.mkdir()
root = tmp_path / "portable"
r = _resolve({"UNSLOTH_HOME": str(root)}, home)
assert r["studio"] == str(root / "studio")
assert r["master"] == str(root)
assert r["node"] == str(root / "node")
assert r["whisper"] == str(root / "whisper.cpp")
def test_a_default_install_is_untouched(tmp_path):
home = tmp_path / "home"
home.mkdir()
r = _resolve({}, home)
assert r["master"] is None
assert r["studio"] == str(home / ".unsloth" / "studio")
assert r["node"] == str(home / ".unsloth" / "node")
assert r["whisper"] == str(home / ".unsloth" / "whisper.cpp")
def test_a_plain_custom_studio_home_is_untouched(tmp_path):
# No UNSLOTH_HOME: the tools stay children of the Studio root, as before.
home = tmp_path / "home"
home.mkdir()
custom = tmp_path / "custom"
r = _resolve({"UNSLOTH_STUDIO_HOME": str(custom)}, home)
assert r["master"] is None
assert r["studio"] == str(custom)
assert r["node"] == str(custom / "node")
assert r["whisper"] == str(custom / "whisper.cpp")
def test_a_flat_root_keeps_the_tools_at_that_root(tmp_path):
# UNSLOTH_HOME == UNSLOTH_STUDIO_HOME, so "beside studio/" and "inside it" are one directory.
home = tmp_path / "home"
home.mkdir()
root = tmp_path / "flat"
r = _resolve({"UNSLOTH_HOME": str(root), "UNSLOTH_STUDIO_HOME": str(root)}, home)
assert r["studio"] == str(root)
assert r["node"] == str(root / "node")
assert r["whisper"] == str(root / "whisper.cpp")
# Path.parents excludes the path itself, so the equality check is what keeps this warning off.
assert r["warnings"] == []
def test_a_studio_home_outside_the_master_root_still_warns(tmp_path):
home = tmp_path / "home"
home.mkdir()
r = _resolve(
{
"UNSLOTH_HOME": str(tmp_path / "portable"),
"UNSLOTH_STUDIO_HOME": str(tmp_path / "elsewhere"),
},
home,
)
assert any("not self-contained" in w for w in r["warnings"])
def test_the_builder_and_the_resolver_agree_on_the_same_directory(tmp_path):
# build_whisper_cpp.sh installs under UNSLOTH_HOME; a resolver one level off reports
# dictation unavailable with whisper-server sitting right there.
home = tmp_path / "home"
home.mkdir()
root = tmp_path / "portable"
built = root / "whisper.cpp"
built.mkdir(parents = True)
r = _resolve({"UNSLOTH_HOME": str(root)}, home)
assert r["whisper"] == str(built)
def _record_note(studio: Path, master: Path) -> None:
"""What setup.sh writes at the end of a master-root install."""
(studio / "share").mkdir(parents = True, exist_ok = True)
(studio / "share" / ".unsloth-master-root").write_text(f"{master}\n", encoding = "utf-8")
def test_a_recorded_master_root_outlives_the_command_that_set_it(tmp_path):
# UNSLOTH_HOME is settable for one command, which installs node and whisper.cpp BESIDE
# studio/, and nothing persists it: the next launch resolved both one level down, at
# <studio>/node and <studio>/whisper.cpp, with the real trees untouched next door.
home = tmp_path / "home"
home.mkdir()
root = tmp_path / "portable"
studio = root / "studio"
studio.mkdir(parents = True)
_record_note(studio, root)
# UNSLOTH_STUDIO_HOME alone: what the installer's launcher actually persists.
r = _resolve({"UNSLOTH_STUDIO_HOME": str(studio)}, home)
assert r["master"] == str(root)
assert r["studio"] == str(studio)
assert r["node"] == str(root / "node")
assert r["whisper"] == str(root / "whisper.cpp")
assert r["warnings"] == []
def test_a_note_whose_root_has_since_moved_is_ignored(tmp_path):
# A note licenses this process to adopt a root for caches and runtimes both. One naming a
# tree that is no longer there must not win over the layout in front of it.
home = tmp_path / "home"
home.mkdir()
studio = tmp_path / "custom"
studio.mkdir()
_record_note(studio, tmp_path / "gone")
r = _resolve({"UNSLOTH_STUDIO_HOME": str(studio)}, home)
assert r["master"] is None
assert r["node"] == str(studio / "node")
def test_a_note_copied_into_an_unrelated_install_is_ignored(tmp_path):
# The recorded root exists and has a studio/ child, but it is not THIS install's studio
# directory, so the note travelled rather than described. Checking only is_dir() would
# redirect this install's runtimes into someone else's tree.
home = tmp_path / "home"
home.mkdir()
other = tmp_path / "other"
(other / "studio").mkdir(parents = True)
studio = tmp_path / "custom"
studio.mkdir()
_record_note(studio, other)
r = _resolve({"UNSLOTH_STUDIO_HOME": str(studio)}, home)
assert r["master"] is None
assert r["node"] == str(studio / "node")
def test_an_empty_note_is_not_a_root(tmp_path):
# A truncated or zero-length note must read as "no record", not as the current directory.
home = tmp_path / "home"
home.mkdir()
studio = tmp_path / "custom"
(studio / "share").mkdir(parents = True)
(studio / "share" / ".unsloth-master-root").write_text("\n", encoding = "utf-8")
r = _resolve({"UNSLOTH_STUDIO_HOME": str(studio)}, home)
assert r["master"] is None
assert r["node"] == str(studio / "node")
def test_an_explicit_studio_home_without_a_note_does_not_borrow_anothers(tmp_path):
# Two installs on one box, only the legacy tree carrying a note: studio_root() stays on the
# named tree, so reading past it would send the runtimes and the portable caches to the
# OTHER install while Studio ran from here.
home = tmp_path / "home"
legacy_master = home / ".unsloth"
(legacy_master / "studio").mkdir(parents = True)
_record_note(legacy_master / "studio", legacy_master)
named = tmp_path / "named"
named.mkdir()
r = _resolve({"UNSLOTH_STUDIO_HOME": str(named)}, home)
assert r["master"] is None
assert r["studio"] == str(named)
assert r["node"] == str(named / "node")
assert r["whisper"] == str(named / "whisper.cpp")
def test_a_flat_recorded_root_is_still_honoured(tmp_path):
# The flat layout is supported, and the note then sits in the root it names rather than in a
# studio/ child: an exact <root>/studio match would refuse it, containment satisfies it.
home = tmp_path / "home"
home.mkdir()
flat = tmp_path / "flat"
(flat / "share").mkdir(parents = True)
(flat / "share" / ".unsloth-master-root").write_text(f"{flat}\n", encoding = "utf-8")
r = _resolve({"UNSLOTH_STUDIO_HOME": str(flat)}, home)
assert r["master"] == str(flat)
assert r["node"] == str(flat / "node")
assert r["warnings"] == []
def test_a_default_install_reads_no_note(tmp_path):
# Nothing writes the note for a default install, so the legacy tree must not acquire a
# master root by accident: this is the path every existing user is on.
home = tmp_path / "home"
(home / ".unsloth" / "studio" / "share").mkdir(parents = True)
r = _resolve({}, home)
assert r["master"] is None
assert r["node"] == str(home / ".unsloth" / "node")
_DISCOVERY_PROBE = """
import json, os, sys
sys.path.insert(0, os.environ["_BACKEND"])
from pathlib import Path
from utils.paths.storage_roots import studio_root, unsloth_home
from utils.llama_cpp_path_settings import mark_managed_llama_cpp_path
# Replay run.py's module-level block, which is what a real server start does
# before anything asks where llama-server is.
resolved = studio_root().resolve()
if resolved != (Path.home() / ".unsloth" / "studio"):
os.environ.setdefault("UNSLOTH_STUDIO_HOME", str(resolved))
managed = (unsloth_home() or resolved) / "llama.cpp"
os.environ.setdefault("UNSLOTH_LLAMA_CPP_PATH", str(managed))
mark_managed_llama_cpp_path(managed)
from core.inference.llama_cpp import LlamaCppBackend
print(json.dumps({
"exported": os.environ.get("UNSLOTH_LLAMA_CPP_PATH"),
"found": LlamaCppBackend._find_llama_server_binary(),
}))
"""
def _install_llama_server(directory: Path) -> Path:
# The name _find_llama_server_binary looks for on THIS platform: it appends .exe on Windows,
# so a fixture that only ever writes the POSIX name made discovery correctly answer None and
# all three discovery tests fail on a Windows runner for a reason in the fixture.
name = "llama-server.exe" if sys.platform == "win32" else "llama-server"
binary = directory / "build" / "bin" / name
binary.parent.mkdir(parents = True, exist_ok = True)
binary.write_text("#!/bin/sh\nexit 0\n", encoding = "utf-8")
binary.chmod(0o755)
return binary
def _discover(env_overrides: dict[str, str], home: Path) -> dict[str, str]:
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": str(home),
"USERPROFILE": str(home),
"_BACKEND": str(BACKEND),
}
# Same reason as _resolve: the interpreter's own requirements travel with it.
for name in ("SYSTEMROOT", "SystemRoot", "COMSPEC", "PATHEXT", "TEMP", "TMP", "WINDIR"):
value = os.environ.get(name)
if value:
env.setdefault(name, value)
env.update(env_overrides)
out = subprocess.run(
[sys.executable, "-c", _DISCOVERY_PROBE],
env = env,
capture_output = True,
text = True,
)
assert out.returncode == 0, f"discovery probe failed ({out.returncode}): {out.stderr.strip()}"
return json.loads(out.stdout.strip().splitlines()[-1])
def test_discovery_finds_the_llama_server_the_master_root_holds(tmp_path):
# The managed marker makes discovery SKIP the env var for its own derivation, so the two
# must name one directory or every GGUF model reports no runtime.
home = tmp_path / "home"
home.mkdir()
root = tmp_path / "portable"
(root / "studio").mkdir(parents = True)
binary = _install_llama_server(root / "llama.cpp")
result = _discover({"UNSLOTH_HOME": str(root)}, home)
assert result["exported"] == str(root / "llama.cpp")
assert result["found"] == str(binary)
def test_discovery_still_prefers_a_plain_custom_studio_root(tmp_path):
home = tmp_path / "home"
home.mkdir()
custom = tmp_path / "custom"
custom.mkdir()
binary = _install_llama_server(custom / "llama.cpp")
result = _discover({"UNSLOTH_STUDIO_HOME": str(custom)}, home)
assert result["found"] == str(binary)
def test_discovery_still_finds_a_legacy_install(tmp_path):
home = tmp_path / "home"
(home / ".unsloth" / "studio").mkdir(parents = True)
binary = _install_llama_server(home / ".unsloth" / "llama.cpp")
result = _discover({}, home)
assert result["found"] == str(binary)

File diff suppressed because it is too large Load diff

View file

@ -697,15 +697,30 @@ def test_setup_helpers_gate_on_canonical_custom_root():
"""setup.sh/setup.ps1 ownership guards must gate on a canonical custom-vs-legacy root comparison."""
sh_src = SETUP_SH.read_text(encoding = "utf-8")
sh_idx = sh_src.index("_assert_studio_owned_or_absent() {")
sh_func = sh_src[sh_idx : sh_idx + 600]
# To the end of the function, not a fixed width, for the reason the PowerShell half below
# already gives: a new parameter or comment pushes the assertions out of a fixed window and
# the test fails while the guard it is about is intact.
sh_func = sh_src[sh_idx:].split("\n}\n", 1)[0]
# The caller may name the flag (the runtime children pass _RUNTIME_ROOT_IS_CUSTOM), but the
# default has to stay the canonical one.
assert (
'"$_STUDIO_HOME_IS_CUSTOM" = true' in sh_func
), "setup.sh _assert_studio_owned_or_absent must gate on _STUDIO_HOME_IS_CUSTOM"
'_aso_custom="${3:-$_STUDIO_HOME_IS_CUSTOM}"' in sh_func
and '"$_aso_custom" = true' in sh_func
), "setup.sh _assert_studio_owned_or_absent must gate on the canonical custom-root flag"
assert (
"_LEGACY_STUDIO_HOME=" in sh_src
and "_studio_home_canon=" in sh_src
and "_STUDIO_HOME_IS_CUSTOM=" in sh_src
), "setup.sh must compute the canonical custom-root flag"
# A master root moves the runtime children, so the flag has to widen to cover them. Not the
# exact one-liner this used to name: UNSLOTH_HOME can legitimately name ~/.unsloth, the root a
# default install already uses, and treating that as custom made an update reject a legacy
# source-built llama.cpp that carries no owner marker. tests/test_setup_master_root.py runs
# the shipped derivation for that case; the rule here is the two halves being present.
assert '_RUNTIME_ROOT_IS_CUSTOM="$_STUDIO_HOME_IS_CUSTOM"' in sh_src
assert (
"_RUNTIME_ROOT_IS_CUSTOM=true" in sh_src and "$_MASTER_ROOT" in sh_src
), "setup.sh must widen the flag to a master root, which moves the runtime children"
ps_src = SETUP_PS1.read_text(encoding = "utf-8")
ps_idx = ps_src.index("function Assert-StudioOwnedOrAbsent")
@ -713,8 +728,8 @@ def test_setup_helpers_gate_on_canonical_custom_root():
# out of.
ps_func = ps_src[ps_idx:].split("\nfunction ", 1)[0]
assert (
"$StudioHomeIsCustom -and" in ps_func
), "setup.ps1 Assert-StudioOwnedOrAbsent must gate on $StudioHomeIsCustom"
"$isCustomRoot = $StudioHomeIsCustom" in ps_func and "$isCustomRoot -and" in ps_func
), "setup.ps1 Assert-StudioOwnedOrAbsent must gate on the canonical custom-root flag"
assert (
"$StudioOwnedMarker) -PathType Leaf" in ps_func
), "setup.ps1 marker check must use -PathType Leaf so a directory cannot satisfy it"
@ -730,8 +745,8 @@ def test_setup_ps1_inplace_git_sync_marks_studio_owned():
"Mark-StudioOwned -Path $LlamaCppDir" in inplace_block
), "in-place git-sync branch must call Mark-StudioOwned on success"
assert (
"$StudioHomeIsCustom" in inplace_block
), "in-place Mark-StudioOwned call should be gated on $StudioHomeIsCustom"
"$RuntimeRootIsCustom" in inplace_block
), "in-place Mark-StudioOwned call should be gated on $RuntimeRootIsCustom"
def test_setup_ps1_inplace_git_sync_asserts_studio_owned_before_mutation():
@ -926,6 +941,99 @@ def test_tauri_preflight_scrubs_studio_home_env():
assert (
'cmd.env_remove("STUDIO_HOME")' in commands
), "commands.rs check_install_status must scrub STUDIO_HOME"
# Belt and braces: the managed context is what removes the whole list at these sites.
assert (
preflight.count("apply_managed_cli_context_tokio(&mut cmd)") >= 2
), "preflight probes must build the managed context, which is what applies the scrub list"
assert (
"apply_managed_cli_context_tokio(&mut cmd)" in commands
), "commands.rs check_install_status must build the managed context"
# The three storage_roots.py resolvers that CHOOSE a data root, as opposed to the ones that
# join a subdirectory onto whatever they chose. Every environment variable read inside them
# moves the desktop's databases, assets and caches, so every one has to be scrubbed.
# _env_unsloth_home is the env-reading half unsloth_home splits into once it grows an on-disk
# fallback. Absent resolvers are skipped, not failed, so this holds for either spelling.
_ROOT_CHOOSING_RESOLVERS = (
"unsloth_home",
"_env_unsloth_home",
"portable_mode",
"studio_root",
)
def _root_moving_env_names() -> set[str]:
"""The env vars storage_roots.py picks a root from, read out of the shipped source.
Derived rather than written down, so a fourth root variable fails this test on the commit
that adds it instead of on the bug report that follows it.
"""
source = (REPO_ROOT / "studio" / "backend" / "utils" / "paths" / "storage_roots.py").read_text(
encoding = "utf-8"
)
names: set[str] = set()
seen = 0
for resolver in _ROOT_CHOOSING_RESOLVERS:
marker = f"\ndef {resolver}("
if marker not in source:
continue
seen += 1
start = source.index(marker)
end = source.index("\ndef ", start + 1)
names |= set(re.findall(r'os\.environ\.get\(\s*"([A-Z][A-Z0-9_]*)"', source[start:end]))
assert seen, "none of the root-choosing resolvers exist under these names any more"
assert names, "the root-choosing resolvers read no environment variable any more"
return names
def _rust_string_list(source: str, const_name: str) -> list[str]:
"""The string literals in a `const NAME: &[&str] = &[...];` declaration."""
start = source.index(f"const {const_name}:")
return re.findall(r'"([^"]*)"', source[start : source.index(";", start)])
def test_tauri_managed_children_scrub_every_root_moving_env():
"""A shell-level Unsloth root must not reach the packaged desktop's Python children.
The desktop pins the legacy ~/.unsloth root and hardcodes it in Rust, so any variable
storage_roots.py would resolve a different root from has to be removed before the spawn,
or the databases, assets and caches move while the Rust half keeps reading the old place.
"""
src_root = REPO_ROOT / "studio" / "src-tauri" / "src"
process = (src_root / "process.rs").read_text(encoding = "utf-8")
install = (src_root / "install.rs").read_text(encoding = "utf-8")
scrubbed = _rust_string_list(process, "MANAGED_CHILD_SCRUBBED_ENV")
assert len(scrubbed) == len(set(scrubbed)), f"duplicate names in the scrub list: {scrubbed}"
missing = sorted(_root_moving_env_names() - set(scrubbed))
assert not missing, (
f"{missing} would redirect the managed backend's data root away from the legacy one "
"the Tauri shell hardcodes"
)
# Second scrub path: start_backend removes the list again after the managed context, so
# the skip is a fact about the child. The whole list, not a subset that can drift.
start = process.index("pub fn start_backend(")
end = min(
offset
for offset in (process.find("\npub fn ", start + 1), process.find("\nfn ", start + 1))
if offset != -1
)
backend_start = process[start:end]
assert (
"for name in MANAGED_CHILD_SCRUBBED_ENV" in backend_start
), "start_backend must scrub the whole list, not the names that were in it when it was written"
# Third spawn path: the installer runs install.sh / install.ps1 directly, never reaching
# apply_managed_cli_context, so it applies the list itself.
assert (
"for name in crate::process::MANAGED_CHILD_SCRUBBED_ENV" in install
), "install.rs spawns outside the managed context and must scrub the whole list"
for name in scrubbed:
assert (
f'cmd.env_remove("{name}")' not in install
), f"install.rs scrubs {name} by hand as well as by list; the hand-written copy can rot"
def test_install_sh_shim_uses_atomic_replace():

View file

@ -115,7 +115,10 @@ def test_kill_orphan_catches_oserror_from_studio_root():
def _exec_search_roots_block(
home: Path, studio_root_value: Path, resolve_raises: bool
home: Path,
studio_root_value: Path,
resolve_raises: bool,
master: Path | None = None,
) -> list[Path]:
"""Run _find_llama_server_binary's search_roots derivation -- plus the shared
_resolved_studio_root_and_is_legacy() classifier it delegates to -- with a
@ -138,6 +141,7 @@ def _exec_search_roots_block(
block = textwrap.dedent(" " * 8 + src[block_start:block_end])
fake_module = type(sys)("fake_storage_roots")
fake_module.studio_root = lambda: studio_root_value
fake_module.unsloth_home = lambda: master
sys.modules["utils.paths.storage_roots"] = fake_module
try:
original_resolve = Path.resolve
@ -183,6 +187,20 @@ def test_search_roots_keeps_custom_when_resolve_fails(tmp_path):
) not in roots, f"legacy llama path must not appear in custom-mode search_roots: {roots}"
def test_search_roots_follow_the_master_root(tmp_path):
# llama.cpp is a sibling of studio/ there; a failing resolve() must not drop the master root.
home = tmp_path / "home"
home.mkdir()
master = tmp_path / "portable"
roots = _exec_search_roots_block(
home = home,
studio_root_value = master / "studio",
resolve_raises = True,
master = master,
)
assert roots == [master / "llama.cpp"]
def test_search_roots_default_mode_uses_legacy_only(tmp_path):
home = tmp_path / "home"
home.mkdir()

View file

@ -0,0 +1,333 @@
# Unsloth Zoo - Utilities for Unsloth
# Copyright 2023-present Daniel Han-Chen, Michael Han-Chen & the Unsloth team. All rights reserved.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""UNSLOTH_HOME has to mean the same install to the CLI and to the backend: if the two disagree
the backend writes studio.db, auth and the pid file under <UNSLOTH_HOME>/studio while the CLI
reads ~/.unsloth/studio.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
_PROBE = """
import json, sys
sys.path.insert(0, {backend!r})
import os
from unsloth_cli.commands import studio as cli
from utils.paths.storage_roots import studio_root, unsloth_home
cli._ensure_studio_env_exported()
master = unsloth_home()
print(json.dumps({{
"cli": str(cli.STUDIO_HOME),
"cli_is_custom": bool(cli._STUDIO_HOME_IS_CUSTOM),
"backend": str(studio_root()),
"exported_studio_home": os.environ.get("UNSLOTH_STUDIO_HOME"),
"cli_llama": os.environ.get("UNSLOTH_LLAMA_CPP_PATH"),
"exported_master": os.environ.get("UNSLOTH_HOME"),
"backend_llama": str((master or studio_root()) / "llama.cpp"),
}}))
"""
def _probe(env_overrides: dict) -> dict:
env = dict(os.environ)
# UNSLOTH_LLAMA_CPP_PATH too, as _main_probe does: an inherited value measures the runner.
for key in ("UNSLOTH_HOME", "UNSLOTH_STUDIO_HOME", "STUDIO_HOME", "UNSLOTH_LLAMA_CPP_PATH"):
env.pop(key, None)
env.update(env_overrides)
env["PYTHONPATH"] = str(REPO_ROOT)
source = _PROBE.format(backend = str(REPO_ROOT / "studio" / "backend"))
out = subprocess.run(
[sys.executable, "-c", source],
capture_output = True,
text = True,
cwd = str(REPO_ROOT),
env = env,
check = True,
)
return json.loads(out.stdout.strip().splitlines()[-1])
def test_unsloth_home_resolves_to_one_studio_root(tmp_path):
master = tmp_path / "portable"
result = _probe({"UNSLOTH_HOME": str(master)})
assert result["backend"] == str(master / "studio")
assert result["cli"] == result["backend"]
# Custom, so `unsloth studio ...` re-exports UNSLOTH_STUDIO_HOME.
assert result["cli_is_custom"] is True
def test_studio_home_still_outranks_unsloth_home(tmp_path):
explicit = tmp_path / "explicit"
result = _probe(
{"UNSLOTH_HOME": str(tmp_path / "portable"), "UNSLOTH_STUDIO_HOME": str(explicit)}
)
assert result["backend"] == str(explicit)
assert result["cli"] == str(explicit)
def test_no_unsloth_home_keeps_the_legacy_default(tmp_path):
result = _probe({"HOME": str(tmp_path / "home"), "USERPROFILE": str(tmp_path / "home")})
legacy = str(tmp_path / "home" / ".unsloth" / "studio")
assert result["cli"] == legacy
assert result["backend"] == legacy
assert result["cli_is_custom"] is False
def test_the_cli_exports_the_llama_cpp_path_the_backend_will_use(tmp_path):
# run.py keeps a non-blank UNSLOTH_LLAMA_CPP_PATH, so a CLI exporting
# <root>/studio/llama.cpp pins that wrong path for every worker.
master = tmp_path / "portable"
result = _probe({"UNSLOTH_HOME": str(master)})
assert result["cli_llama"] == str(master / "llama.cpp")
assert result["cli_llama"] == result["backend_llama"]
def test_the_cli_recovers_a_recorded_master_root_for_the_setup_subprocess(tmp_path):
# A one-command UNSLOTH_HOME leaves nothing in a later environment, and the backend recovers
# the root from the note, so a CLI that did not would have setup refresh the runtimes at
# <master>/studio/ while the backend launched the stale ones at <master>/. Exported rather
# than read, since setup is a subprocess.
master = tmp_path / "portable"
studio = master / "studio"
(studio / "share").mkdir(parents = True)
(studio / "share" / ".unsloth-master-root").write_text(f"{master}\n", encoding = "utf-8")
result = _probe({"UNSLOTH_STUDIO_HOME": str(studio)})
assert result["exported_master"] == str(master)
assert result["cli_llama"] == str(master / "llama.cpp")
assert result["cli_llama"] == result["backend_llama"]
def test_the_cli_and_the_backend_accept_the_same_nested_studio_root(tmp_path):
# A real pairing: setup records <root>, and the Studio directory is inside it without being
# its studio/ child. A stricter rule in the CLI would decline a note the backend accepts.
master = tmp_path / "root"
studio = master / "custom" / "studio"
(studio / "share").mkdir(parents = True)
(studio / "share" / ".unsloth-master-root").write_text(f"{master}\n", encoding = "utf-8")
result = _probe({"UNSLOTH_STUDIO_HOME": str(studio)})
assert result["exported_master"] == str(master)
assert result["cli"] == str(studio)
assert result["cli_llama"] == str(master / "llama.cpp")
assert result["cli_llama"] == result["backend_llama"]
def test_the_cli_refuses_a_note_carried_in_from_another_master_root(tmp_path):
# A Studio tree copied from master root A to B keeps a note naming A. Exporting it would
# point an update at the original install; the Studio directory has to lie inside the root
# its note names, which is the rule storage_roots and both uninstallers apply.
original = tmp_path / "original"
(original / "studio").mkdir(parents = True)
copied = tmp_path / "copied" / "studio"
(copied / "share").mkdir(parents = True)
(copied / "share" / ".unsloth-master-root").write_text(f"{original}\n", encoding = "utf-8")
result = _probe({"UNSLOTH_STUDIO_HOME": str(copied)})
assert result["exported_master"] is None
assert result["cli_llama"] == str(copied / "llama.cpp")
assert result["cli_llama"] == result["backend_llama"]
def test_a_plain_custom_root_still_keeps_llama_cpp_inside_it(tmp_path):
explicit = tmp_path / "explicit"
result = _probe({"UNSLOTH_STUDIO_HOME": str(explicit)})
assert result["cli_llama"] == str(explicit / "llama.cpp")
assert result["cli_llama"] == result["backend_llama"]
def test_a_master_root_the_cli_declined_is_still_told_to_the_backend(tmp_path):
"""The one root the CLI picks that the backend would not pick for itself.
install.sh and install.ps1 do not read UNSLOTH_HOME yet, so _resolve_studio_home keeps an
exported-but-uninstalled master root off the legacy install that actually exists. That
fallback is not custom, and returning early on non-custom exported nothing at all, so
studio_root() in the backend went on honouring UNSLOTH_HOME with no install check: the CLI
ran the legacy venv while the backend under it wrote studio.db, auth and the pid file to
<master>/studio. The other tests here point the master root at an empty directory AND leave
the legacy root uninstalled, so the fallback is never entered by them.
"""
home = tmp_path / "home"
# The sentinel _looks_like_installer_managed_studio_home reads.
conf = home / ".unsloth" / "studio" / "share" / "studio.conf"
conf.parent.mkdir(parents = True)
conf.write_text("installed\n", encoding = "utf-8")
result = _probe(
{
"HOME": str(home),
"USERPROFILE": str(home),
"UNSLOTH_HOME": str(tmp_path / "portable"),
}
)
legacy = str(home / ".unsloth" / "studio")
assert result["cli"] == legacy
# Not custom: the root is the Unsloth-owned legacy one, and false is what lets the
# installers replace the tree without an owner marker.
assert result["cli_is_custom"] is False
assert result["exported_studio_home"] == legacy
assert result["backend"] == legacy
def test_a_whitespace_studio_home_does_not_defeat_the_export(tmp_path):
"""The same fallback as above, with an inherited UNSLOTH_STUDIO_HOME of " ".
Every resolver strips before deciding, so " " means unset to _resolve_studio_home and to
studio_root alike. The export guard read the raw value, where " " is truthy, so it left the
whitespace in place: the CLI ran the legacy install and the backend under it resolved
<master>/studio, which is the split this file exists to prevent. Blank was already covered by
the guard; whitespace-only was the shape that got through.
"""
home = tmp_path / "home"
conf = home / ".unsloth" / "studio" / "share" / "studio.conf"
conf.parent.mkdir(parents = True)
conf.write_text("installed\n", encoding = "utf-8")
legacy = str(home / ".unsloth" / "studio")
for inherited in (" ", "", "\t\n"):
result = _probe(
{
"HOME": str(home),
"USERPROFILE": str(home),
"UNSLOTH_HOME": str(tmp_path / "portable"),
"UNSLOTH_STUDIO_HOME": inherited,
"UNSLOTH_LLAMA_CPP_PATH": inherited,
}
)
assert result["cli"] == legacy, inherited
assert result["exported_studio_home"] == legacy, inherited
assert result["backend"] == legacy, inherited
# The runtimes live beside studio/ at the master root, and a whitespace value left in
# place would be scanned as a directory rather than replaced.
assert result["cli_llama"] == str(tmp_path / "portable" / "llama.cpp"), inherited
def test_a_legacy_install_still_keeps_llama_cpp_at_the_legacy_path(tmp_path):
home = tmp_path / "home"
(home / ".unsloth" / "studio").mkdir(parents = True)
result = _probe({"HOME": str(home), "USERPROFILE": str(home)})
# Not custom, so nothing is exported at all and the backend default stands.
assert result["cli_llama"] is None
assert result["backend_llama"] == str(home / ".unsloth" / "studio" / "llama.cpp")
_MAIN_PROBE = """
import json, sys, types
sys.path.insert(0, {backend!r})
# main.py pulls in the whole app on import, so exec only its module-level
# llama.cpp block, which is what decides the managed path and marks it.
import os, re
from pathlib import Path
src = Path({backend!r}, "main.py").read_text(encoding = "utf-8")
# Anchored on the master-root lookup rather than the `if`, because the guard
# reads _MASTER_ROOT and a slice starting at the `if` would not define it.
start = src.index('from utils.paths.storage_roots import unsloth_home as _unsloth_home')
end = src.index('# The studio bundles unsloth_zoo', start)
if 'if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT' not in src[start:end]:
raise AssertionError("the extracted slice no longer contains the export guard")
from utils.paths.storage_roots import studio_root as _studio_root
_LEGACY_STUDIO_ROOT = (Path.home() / ".unsloth" / "studio").resolve()
_STUDIO_ROOT_RESOLVED = _studio_root().resolve()
marked = []
mod = types.ModuleType("utils.llama_cpp_path_settings")
mod.mark_managed_llama_cpp_path = lambda p: marked.append(str(p))
sys.modules["utils.llama_cpp_path_settings"] = mod
exec(compile(src[start:end], "main.py", "exec"), globals())
print(json.dumps({{
"exported": os.environ.get("UNSLOTH_LLAMA_CPP_PATH"),
"marked": marked,
}}))
"""
def _main_probe(env_overrides: dict) -> dict:
env = dict(os.environ)
for key in ("UNSLOTH_HOME", "UNSLOTH_STUDIO_HOME", "STUDIO_HOME", "UNSLOTH_LLAMA_CPP_PATH"):
env.pop(key, None)
env.update(env_overrides)
env["PYTHONPATH"] = str(REPO_ROOT)
source = _MAIN_PROBE.format(backend = str(REPO_ROOT / "studio" / "backend"))
out = subprocess.run(
[sys.executable, "-c", source],
capture_output = True,
text = True,
cwd = str(REPO_ROOT),
env = env,
check = True,
)
return json.loads(out.stdout.strip().splitlines()[-1])
def test_main_marks_the_same_llama_cpp_path_run_py_exports(tmp_path):
# A direct `uvicorn main:app` exports this outright; under run.py main.py would instead
# mark the wrong path managed, making the bundled one look like an override.
master = tmp_path / "portable"
result = _main_probe({"UNSLOTH_HOME": str(master)})
assert result["exported"] == str(master / "llama.cpp")
assert result["marked"] == [str(master / "llama.cpp")]
def test_main_leaves_a_plain_custom_root_alone(tmp_path):
explicit = tmp_path / "explicit"
result = _main_probe({"UNSLOTH_STUDIO_HOME": str(explicit)})
assert result["exported"] == str(explicit / "llama.cpp")
assert result["marked"] == [str(explicit / "llama.cpp")]
def test_main_exports_for_a_master_root_that_is_the_legacy_path(tmp_path):
# A portable install pointed at the legacy Studio path still owns <root>/llama.cpp, so the
# legacy equality alone skipped the export and left unsloth_zoo on ~/.unsloth/llama.cpp.
home = tmp_path / "home"
legacy = home / ".unsloth" / "studio"
legacy.mkdir(parents = True)
result = _main_probe(
{
"HOME": str(home),
"USERPROFILE": str(home),
"UNSLOTH_HOME": str(legacy),
"UNSLOTH_STUDIO_HOME": str(legacy),
}
)
assert result["exported"] == str(legacy / "llama.cpp")
assert result["marked"] == [str(legacy / "llama.cpp")]
def test_main_still_exports_nothing_without_a_master_root(tmp_path):
# The other half: no master root, so the guard stays closed rather than pinning the default.
home = tmp_path / "home"
(home / ".unsloth" / "studio").mkdir(parents = True)
result = _main_probe({"HOME": str(home), "USERPROFILE": str(home)})
assert result["exported"] is None
assert result["marked"] == []

View file

@ -132,10 +132,41 @@ def read_json_checking_deferred_error(url: str, response):
return require_completed_padded_body(url, raise_for_deferred_error(url, body))
def ensure_studio_backend_path() -> None:
_cache_env_seeded = False
def _seed_cache_env() -> None:
"""Pin the cache locations the backend pins, for in-process CLI commands.
Otherwise they inherit unsloth_zoo's relative UNSLOTH_COMPILE_LOCATION, resolved against the
working directory, leaving an unsloth_compiled_cache cache_cleanup will not remove (#8865).
"""
global _cache_env_seeded
if _cache_env_seeded:
return
_cache_env_seeded = True
try:
from utils.paths.storage_roots import setup_cache_env
setup_cache_env()
except Exception: # noqa: BLE001 - never fail a command over cache placement
pass
def ensure_studio_backend_path(*, seed_cache_env: bool = True) -> None:
"""Put studio/backend on sys.path, and by default pin the cache locations too.
`seed_cache_env = False` is for callers that only want to IMPORT a path helper.
setup_cache_env() creates every cache directory it pins, so seeding it from
`unsloth start`'s Node discovery turned a read-only lookup into 18 mkdirs under a home
that may have nothing to do with the command being run -- including one against a remote
server. Seeding stays on for the ML entry points, where the pins are the point.
"""
backend_dir = str(Path(__file__).resolve().parents[1] / "studio" / "backend")
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
if seed_cache_env:
# After the path insert, before the caller's backend import pulls in unsloth_zoo.compiler.
_seed_cache_env()
def configure_quiet_logging() -> None:

View file

@ -681,7 +681,15 @@ def studio_backend_imports(feature: str = "This command", *, studio_only: bool =
Only ModuleNotFoundError is intercepted; any other ImportError from the
backend is a real bug and keeps its traceback.
Every CLI backend import goes through here, so the cache env is pinned here too: `export` and
`list-checkpoints` reach studio.backend.core.export without calling
ensure_studio_backend_path(), and would import unsloth_zoo.compiler with
UNSLOTH_COMPILE_LOCATION unset, writing unsloth_compiled_cache into the working dir (#8865).
"""
from unsloth_cli._inference import ensure_studio_backend_path
ensure_studio_backend_path()
try:
yield
except ModuleNotFoundError as exc:

View file

@ -281,6 +281,8 @@ def is_relocatable_invocation(argv, environ):
# Path overrides written relative to the folder being left: Unsloth resolves them with
# Path.resolve(), so moving first would silently retarget them.
_RELATIVE_PATH_ENV = (
# Unsloth roots: storage_roots.py.
"UNSLOTH_HOME",
"UNSLOTH_STUDIO_HOME",
"STUDIO_HOME",
"UNSLOTH_STUDIO_DOCUMENTS_HOME",
@ -321,6 +323,18 @@ _RELATIVE_PATH_ENV = (
"UNSLOTH_DG_SHIM",
"UNSLOTH_COMPILE_LOCATION",
"TORCHINDUCTOR_CACHE_DIR",
# storage_roots.py fills these only when blank, so a relative value the user set is kept as
# written and would name a different folder after the move.
"TORCH_EXTENSIONS_DIR",
"TORCH_HOME",
"TRITON_HOME",
"TRITON_CACHE_DIR",
"TRITON_DUMP_DIR",
"CUDA_CACHE_PATH",
"MPLCONFIGDIR",
"NUMBA_CACHE_DIR",
"DATA_DESIGNER_HOME",
"DATA_DESIGNER_MANAGED_ASSETS_PATH",
"UNSLOTH_DIFFUSION_COMPILE_CACHE_DIR",
"UNSLOTH_DIFFUSION_COND_CACHE_DIR",
"HF_HOME",
@ -329,6 +343,9 @@ _RELATIVE_PATH_ENV = (
"HF_XET_CACHE",
"HF_DATASETS_CACHE",
"HF_ASSETS_CACHE",
# transformers appends this to sys.path, so a relative value would import a different
# generated module after the move.
"HF_MODULES_CACHE",
# The credential file: a relative value would follow the child and lose access to gated repos.
"HF_TOKEN_PATH",
# Authoritative when non-blank (storage_roots.py), so `unsloth studio update` would install

View file

@ -3534,7 +3534,9 @@ def _refresh_windows_path() -> None:
def _managed_node_tools() -> Optional[tuple[Path, Path, bool]]:
# Best-effort: any failure here means "no managed Node", never a broken launch.
try:
ensure_studio_backend_path()
# Discovery only: this answers "is there a managed Node", including for a launch aimed
# at a remote server, so it must not create the cache tree on the way past.
ensure_studio_backend_path(seed_cache_env = False)
from utils.node_runtime import managed_node_binary, resolve_node_executable
node = Path(managed_node_binary())
except (ImportError, OSError, RuntimeError, TypeError, ValueError):

View file

@ -40,7 +40,9 @@ def _enable_verbose_access_logs() -> None:
os.environ["UNSLOTH_STUDIO_ACCESS_LOG_POLL_DEDUP_MS"] = "0"
# Root order: UNSLOTH_STUDIO_HOME, STUDIO_HOME, sys.prefix, legacy ~/.unsloth/studio. Markers mirror install.ps1 / uninstall.ps1 and are matched as bytes.
# Root order: UNSLOTH_STUDIO_HOME, STUDIO_HOME, UNSLOTH_HOME/studio, sys.prefix,
# legacy ~/.unsloth/studio. Keep this aligned with storage_roots.studio_root().
# Shim markers mirror install.ps1 / uninstall.ps1 and are matched as bytes.
_CMD_SHIM_MARKERS = (b"unsloth-studio-managed-launcher", b"from unsloth_cli import app")
_CMD_SHIM_MAX_BYTES = 8192
@ -76,6 +78,25 @@ def _resolve_studio_home() -> tuple[Path, bool]:
return Path(override).expanduser().resolve(), True
except (OSError, ValueError):
return Path(override).expanduser(), True
# Keeps the CLI on the same root as storage_roots.py; see test_unsloth_home_root_agreement.py.
master = (os.environ.get("UNSLOTH_HOME") or "").strip()
if master:
try:
candidate = Path(master).expanduser().resolve() / "studio"
except (OSError, ValueError):
candidate = Path(master).expanduser() / "studio"
try:
legacy = (Path.home() / ".unsloth" / "studio").resolve()
except (OSError, ValueError):
legacy = Path.home() / ".unsloth" / "studio"
# install.sh and install.ps1 do not read UNSLOTH_HOME yet, so they leave the venv and the
# launcher at the legacy root while setup puts the runtimes under the master root:
# preferring <master>/studio unconditionally reported "Unsloth Studio not set up" for an
# install that is right there. The master root wins only when it HAS an install.
if candidate != legacy and not _looks_like_installer_managed_studio_home(candidate):
if _looks_like_installer_managed_studio_home(legacy):
return legacy, False
return candidate, candidate != legacy
try:
prefix = Path(sys.prefix).resolve()
if prefix.name == "unsloth_studio":
@ -90,25 +111,93 @@ def _resolve_studio_home() -> tuple[Path, bool]:
STUDIO_HOME, _STUDIO_HOME_IS_CUSTOM = _resolve_studio_home()
MASTER_ROOT_NOTE = ".unsloth-master-root"
def _recorded_master_root() -> Optional[Path]:
"""The master root setup recorded in this Studio tree, or None.
Keep this aligned with storage_roots._recorded_master_root(); see
test_unsloth_home_root_agreement.py. `UNSLOTH_HOME=/mnt/portable unsloth studio update` puts
node, llama.cpp and whisper.cpp BESIDE studio/ and leaves nothing in a later environment. The
backend recovers that from the note, so a `unsloth studio update` that did not would hand
setup a plain Studio root, have it refresh the runtimes one level down at <master>/studio/,
and leave the backend still launching the stale trees at <master>/.
The recorded root must exist and THIS Studio directory must lie inside it, so a tree copied
from one master root to another does not send the update, or a removal, into the original
install. Containment, not an exact <root>/studio match: the flat layout names one directory
for both, and UNSLOTH_HOME=/root with UNSLOTH_STUDIO_HOME=/root/custom/studio is a root that
genuinely contains its Studio somewhere other than the default child. The backend and both
uninstallers apply exactly this rule, and a stricter one here would have the CLI decline a
note the backend accepts, which is the same split this function exists to close.
"""
try:
recorded = (STUDIO_HOME / "share" / MASTER_ROOT_NOTE).read_text(encoding = "utf-8").strip()
except (OSError, ValueError, UnicodeDecodeError):
return None
if not recorded:
return None
try:
master = Path(recorded).expanduser().resolve()
here = STUDIO_HOME.resolve()
if not (master.is_dir() and (here == master or master in here.parents)):
return None
# A legacy-rooted install has no master root, and both uninstallers already refuse what
# that shape records. storage_roots._is_legacy_studio_tree declines it too, and
# test_unsloth_home_root_agreement.py holds the two together: without this the CLI would
# export UNSLOTH_HOME for a note the backend has declined. Keyed on the TREE, so a note
# naming $HOME or any other ancestor goes with it.
try:
if here == (Path.home() / ".unsloth" / "studio").resolve():
return None
except (OSError, RuntimeError, ValueError):
pass
return master
except (OSError, ValueError):
return None
return None
def _ensure_studio_env_exported() -> None:
"""Re-export UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH for custom roots only, per
subcommand rather than at import, so unrelated importers see no env changes."""
if not _STUDIO_HOME_IS_CUSTOM:
"""Re-export UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH for custom roots, and for a master
root the resolver above declined, per subcommand rather than at import, so unrelated
importers see no env changes."""
# storage_roots.studio_root() honours UNSLOTH_HOME with no install check, so the fallback
# keeping this CLI on an installed legacy root must be told to the backend too: unexported,
# `unsloth studio` runs the legacy venv while the backend inside it writes studio.db, auth
# and the pid file under <master>/studio. Exporting the root does not make it custom, since
# the value equals the legacy path both installers compare against.
# The note, when this run has no UNSLOTH_HOME of its own. Exported rather than merely read,
# because setup runs as a subprocess and would otherwise refresh the runtimes at
# <master>/studio/ while the backend kept launching the ones at <master>/.
if not (os.environ.get("UNSLOTH_HOME") or "").strip():
_recorded = _recorded_master_root()
if _recorded is not None:
os.environ["UNSLOTH_HOME"] = str(_recorded)
if not _STUDIO_HOME_IS_CUSTOM and not (os.environ.get("UNSLOTH_HOME") or "").strip():
return
# Truthy-check, not setdefault: a blank UNSLOTH_STUDIO_HOME= must not win.
if not os.environ.get("UNSLOTH_STUDIO_HOME"):
if not (os.environ.get("UNSLOTH_STUDIO_HOME") or "").strip():
os.environ["UNSLOTH_STUDIO_HOME"] = str(STUDIO_HOME)
try:
_legacy_studio = (Path.home() / ".unsloth" / "studio").resolve()
_is_legacy = STUDIO_HOME.resolve() == _legacy_studio
except (OSError, ValueError):
_is_legacy = STUDIO_HOME == (Path.home() / ".unsloth" / "studio")
if _is_legacy:
# The runtimes are siblings of studio/, at the master root, so STUDIO_HOME/llama.cpp is one
# level too deep. run.py keeps a non-blank value, so a wrong export here wins everywhere.
_master = (os.environ.get("UNSLOTH_HOME") or "").strip()
if _master:
try:
_llama_dir = Path(_master).expanduser().resolve() / "llama.cpp"
except (OSError, ValueError):
_llama_dir = Path(_master).expanduser() / "llama.cpp"
elif _is_legacy:
_llama_dir = Path.home() / ".unsloth" / "llama.cpp"
else:
_llama_dir = STUDIO_HOME / "llama.cpp"
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
if not (os.environ.get("UNSLOTH_LLAMA_CPP_PATH") or "").strip():
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_llama_dir)

View file

@ -0,0 +1,155 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""`unsloth export` / `list-checkpoints` must pin the cache env before importing the export
backend.
Neither calls ensure_studio_backend_path(), so before the seeding moved into
studio_backend_imports() they reached studio.backend.core.export with UNSLOTH_COMPILE_LOCATION
unset and unsloth_zoo resolved its relative default against the shell's cwd (#8865).
"""
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
# Runs in its own interpreter: the seeding is process-global (os.environ plus a one-shot flag),
# so an in-process assertion would depend on test ordering.
PROBE = textwrap.dedent(
"""
import json, os, sys, types
import typer
from typer.testing import CliRunner
command_name = sys.argv[1]
recorded = {}
# Stand in for the heavy backend (unsloth, torch) so the probe stays
# import-light. The CLI's own import statement, and therefore everything that
# has to happen before it, is untouched.
class ExportBackend:
def __init__(self):
recorded["compile_location"] = os.environ.get("UNSLOTH_COMPILE_LOCATION")
def load_checkpoint(self, **kwargs):
return True, "loaded"
def export_merged_model(self, **kwargs):
return True, "exported", kwargs["save_directory"]
def scan_checkpoints(self, **kwargs):
return []
# Each stub keeps the REAL directory on __path__ instead of []. The point is to skip
# the heavy studio.backend.core.export import, not to make the whole studio tree
# unimportable: unsloth_cli/__init__ eagerly imports commands.start, which pulls
# studio.backend.utils.coding_agents, and an empty __path__ turned that into
# ModuleNotFoundError. Submodules still load from disk; the package __init__ files
# are the thing that never runs, which is what kept this probe light to begin with.
repo_root = sys.argv[2]
for name, relative in (
("studio", "studio"),
("studio.backend", "studio/backend"),
("studio.backend.core", "studio/backend/core"),
):
module = types.ModuleType(name)
module.__path__ = [os.path.join(repo_root, *relative.split("/"))]
sys.modules[name] = module
export_module = types.ModuleType("studio.backend.core.export")
export_module.ExportBackend = ExportBackend
sys.modules["studio.backend.core.export"] = export_module
from unsloth_cli.commands import export as export_command
app = typer.Typer()
if command_name == "export":
app.command()(export_command.export)
args = ["checkpoint", "exported"]
else:
app.command()(export_command.list_checkpoints)
args = []
result = CliRunner().invoke(app, args)
recorded["exit_code"] = result.exit_code
print("PROBE " + json.dumps(recorded))
"""
)
@pytest.mark.parametrize("command_name", ["export", "list-checkpoints"])
def test_export_commands_seed_compile_location(tmp_path, command_name):
unsloth_home = tmp_path / "unsloth"
workdir = tmp_path / "cwd"
workdir.mkdir()
env = dict(os.environ)
env.pop("UNSLOTH_COMPILE_LOCATION", None)
# Both outrank UNSLOTH_HOME in the resolver, and conftest.py sets a session-scoped
# UNSLOTH_STUDIO_HOME, so an inherited one would send the probe somewhere unnamed.
for key in ("UNSLOTH_STUDIO_HOME", "STUDIO_HOME", "UNSLOTH_PORTABLE"):
env.pop(key, None)
env.update(
HOME = str(tmp_path / "home"),
UNSLOTH_HOME = str(unsloth_home),
PYTHONPATH = str(REPO_ROOT),
)
completed = subprocess.run(
[sys.executable, "-c", PROBE, command_name, str(REPO_ROOT)],
cwd = str(workdir),
env = env,
capture_output = True,
text = True,
timeout = 300,
)
assert completed.returncode == 0, completed.stderr
line = next(l for l in completed.stdout.splitlines() if l.startswith("PROBE "))
recorded = json.loads(line[len("PROBE ") :])
assert recorded["exit_code"] == 0, recorded
location = recorded["compile_location"]
assert location, "UNSLOTH_COMPILE_LOCATION was unset when the export backend was imported"
# Absolute and inside the install root, not resolved against the CWD.
assert Path(location).is_absolute()
assert Path(location) == unsloth_home / "studio" / "compiled_cache"
assert not (workdir / "unsloth_compiled_cache").exists()
def test_node_discovery_does_not_build_the_cache_tree(tmp_path):
"""`unsloth start` asks whether a managed Node exists; that is a read, not an install.
ensure_studio_backend_path() seeds the cache environment, and setup_cache_env() CREATES
every directory it pins, so routing Node discovery through it turned the lookup into 18
mkdirs under a home that may have nothing to do with the command -- including a launch
aimed at a remote server. The merge base created nothing here.
"""
home = tmp_path / "home"
(home / ".unsloth" / "studio").mkdir(parents = True)
probe = (
"import json, os, sys\n"
f"sys.path.insert(0, {str(REPO_ROOT)!r})\n"
"from pathlib import Path\n"
"home = Path(os.environ['HOME'])\n"
"before = {str(p) for p in home.rglob('*') if p.is_dir()}\n"
"from unsloth_cli.commands import start\n"
"start._managed_node_tools()\n"
"after = {str(p) for p in home.rglob('*') if p.is_dir()}\n"
"print('PROBE ' + json.dumps(sorted(after - before)))\n"
)
completed = subprocess.run(
[sys.executable, "-c", probe],
cwd = str(tmp_path),
env = {"HOME": str(home), "PATH": os.environ.get("PATH", "/usr/bin:/bin")},
capture_output = True,
text = True,
timeout = 300,
)
assert completed.returncode == 0, completed.stderr
line = next(l for l in completed.stdout.splitlines() if l.startswith("PROBE "))
assert json.loads(line[len("PROBE ") :]) == []