mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-19 08:54:21 +00:00
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:
parent
e259903f83
commit
922e62297e
43 changed files with 7058 additions and 148 deletions
26
install.ps1
26
install.ps1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
1446
studio/backend/tests/test_setup_cache_env_containment.py
Normal file
1446
studio/backend/tests/test_setup_cache_env_containment.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
310
studio/setup.ps1
310
studio/setup.ps1
|
|
@ -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)
|
||||
)) {
|
||||
|
|
|
|||
218
studio/setup.sh
218
studio/setup.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
101
tests/sh/test_portable_isolated_venv.sh
Executable file
101
tests/sh/test_portable_isolated_venv.sh
Executable 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
320
tests/sh/test_uninstall_master_root.sh
Executable file
320
tests/sh/test_uninstall_master_root.sh
Executable 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 ]
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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-; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"; }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
340
tests/test_managed_tools_master_root.py
Normal file
340
tests/test_managed_tools_master_root.py
Normal 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)
|
||||
1473
tests/test_setup_master_root.py
Normal file
1473
tests/test_setup_master_root.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
333
tests/test_unsloth_home_root_agreement.py
Normal file
333
tests/test_unsloth_home_root_agreement.py
Normal 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"] == []
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
155
unsloth_cli/tests/test_export_cache_env.py
Normal file
155
unsloth_cli/tests/test_export_cache_env.py
Normal 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 ") :]) == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue