| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
studio: send mcp tool images to vision models (#10088) Pass MCP tool images to vision models during live calls and saved-history replay. Preserve image limits, attachment order, and local template compatibility. | 1 小时前 | |
studio: send mcp tool images to vision models (#10088) Pass MCP tool images to vision models during live calls and saved-history replay. Preserve image limits, attachment order, and local template compatibility. | 1 小时前 | |
Restore facts the comment trim cut from the Studio backend (#10329) * Restore facts the comment trim cut from the Studio backend Thirty-six sites where the trim shortened a comment past the fact that justified the code, plus nine drifted comments in one test file. Comments only; the gate reports code unchanged on all 32 files. Where the surviving comment stated a rule but not the failure it prevents: - process_lifetime.py: a silent failure leaks every child on a crash, which is why each path records a reason. - api_usage_db.py: FIFO ordering is what makes final shutdown drain instead of dropping usage. - unsloth_freeze_report.py: on a cold launch the watchdog answers before the webview finishes loading, so the first samples always look frozen. Without the warmup this reported healthy runs as FROZE. - download_registry.py: an unresumable partial holds the disk the refetch needs and pins the bar to its own stale high-water mark, so it is swept, but only once abandoned. - deletion.py: dependants are counted live, never from a stored count, and widening the match would purge a sibling quant out from under an in-flight download. Two comments had become false rather than merely short: - rag/ingestion.py said terminal is still False BECAUSE _run writes the DB status before emitting it. That is why the re-read catches terminality, not why the flag is False. - hub/tests/test_dataset_local_options.py had nine comments on the wrong tests. Three were duplicates of lines still living on their real anchors and are deleted; six are back on the test whose fixture they describe. Stale specifics were deliberately not restored: the retry budget's literal "three" is now the max_retries parameter, the export error string has since become "No export subprocess running", and one tail claiming an earlier run's bundles survive is false for the path that call site takes. * Do not claim EBADF means the data made it The restoration widened a comment scoped to two named cases into a blanket claim that every errno in _FSYNC_UNSUPPORTED means the platform would not flush the handle rather than that the write was lost. The set holds seven, and EBADF does not support that reading: _fsync_file's own docstring says CPython maps os.fsync to the UCRT _commit, which collapses every FlushFileBuffers failure into EBADF, so on Windows a genuine flush failure is swallowed there and durability rests on the per-file size check in read_checkpoint. So the comment asserted the opposite of what the same file documents twenty lines below. Narrowed to the six that do carry the reading, with EBADF named as the deliberate tradeoff it is, pointing at the docstring instead of pre-empting it. The code is right as written: EBADF cannot be dropped, since on Windows it is the only errno an unflushable volume reports, and failing every save is worse. Gating it on win32 so POSIX re-raises would be a behaviour change and belongs in its own PR. Comments only; the gate reports code unchanged. | 13 天前 | |
studio: support sqf script attachments Co-authored-by: mahiatlinux <mahiatlinux@users.noreply.github.com> | 3 小时前 | |
Add AGPL-3.0 license to studio folder | 6 个月前 | |
Use Unsloth branding in remaining user-facing text (#11254) | 18 小时前 | |
Use Unsloth branding in remaining user-facing text (#11254) | 18 小时前 | |
Use Unsloth branding in remaining user-facing text (#11254) | 18 小时前 | |
Studio installer: torch 2.11 on Linux CPU, Vulkan for AMD without ROCm, gfx1033 gate, and three install fixes (#8412) * Studio installer: torch 2.11 on Linux CPU, Vulkan for AMD without ROCm, gfx1033 gate, and three install fixes Split out of #8343, which also rewrites the Linux AppImage packaging. These six installer changes are independent of that work and verified on their own, so they land here while the AppImage side finishes. Original work by Thomas Eric (@ThomasEricB), who found all six on a Steam Deck. Co-authored-by: Thomas Eric <thombelcar@gmail.com> * Find the venv creation call by what it runs, not by its label #8479 moved venv creation behind _run_uv_venv, so the label this test grepped for no longer exists and the ordering assertion read as a too-late cache rather than a stale grep. Also drop a .studio-test-root artifact that a local test run left behind in the commit. * Three install.sh fixes: gfx1033 token match, override handoff, uv cache fallback Match gfx1033 as a TOKEN in the multiline ROCm probe. _probe_amd_gfx_arch keeps every `grep -oE` hit, and rocminfo names each GPU agent twice: once as the agent's own "Name: gfx1033" and once in its ISA Info block as "amdgcn-amd-amdhsa--gfx1033". A single-GPU Steam Deck therefore already probes as "gfx1033\ngfx1033", which the exact-string case did not match, so the host fell through to the version-keyed ROCm index and installed the very wheels the gate exists to avoid. Flatten to one space-delimited, lowercased, suffix-stripped line; the surrounding spaces keep gfx10330 out. Stop forwarding a rejected gfx1033 override to llama.cpp. Clearing the local _linux_inferred_gfx is not enough: setup.sh copies UNSLOTH_ROCM_GFX_ARCH straight into --rocm-gfx, and _apply_host_overrides reads any forwarded gfx as proof of ROCm (has_rocm = True). On a Van Gogh host with no ROCm that skips the AMD-without-ROCm Vulkan branch this release adds and asks for a ROCm prebuilt or a HIP source build the host cannot run. Unset the rejected override so setup.sh re-probes; a host that really does have ROCm still finds gfx1033 through its own rocminfo call, and a UNSLOTH_TORCH_INDEX_URL pin returns long before this block. Fall back to uv's default cache when the colocated one cannot be created. uv aborts with "Failed to initialize cache at ..." on a cache path it cannot create, so ignoring the mkdir status while keeping the export turned a disk optimisation into a hard install failure on a host where uv's own default would have worked. Measured: with STUDIO_HOME writable and its "cache" entry a file, `uv venv` exits 2 with the export and 0 without it. Tests: the gate test fed the gate a hand-built one-token probe, which is not what the probe produces, so it passed throughout. Drive the real get_torch_index_url against a real rocminfo shape instead, and assert first that the probe really is multi-line so a future single-hit probe cannot turn that into a vacuous pass. * Demote an already-installed ROCm torch on a miscomputing AMD arch Returning None from _amd_arch_index_url only stops a gfx1033 host from being GIVEN ROCm wheels. A venv that already holds them -- installed before the gate existed, and carried through the legacy-venv migration untouched because its forward-only validation passes on this arch -- was never demoted: install.sh resolves UNSLOTH_TORCH_BACKEND=cpu, which returns _ensure_rocm_torch at its first line; _ensure_cpu_torch fired only for an EXPLICIT pin; and the base update does not reinstall an already-satisfied torch. Upgrading therefore left in place exactly the build the gate exists to remove, on the machines that most need it removed. Treat the arch itself as CPU authority so _ensure_cpu_torch does the demotion. Scoped tightly: every AMD arch on the host must be a measured-bad one (a healthy dGPU beside the APU keeps ROCm), an explicit UNSLOTH_TORCH_INDEX_URL / _FAMILY still wins, and the disk label is read before any probe so hosts with no ROCm torch pay nothing. The arch probe ignores HSA_OVERRIDE_GFX_VERSION, which is the usual Van Gogh workaround and would otherwise make rocminfo answer gfx1030 and hide the arch being judged. * Honour an AMD visibility mask before auto-selecting Vulkan on Linux ROCR_VISIBLE_DEVICES filters the HSA runtime's agent list, so it removes GPUs from rocminfo's output too. A masked-out AMD device on a ROCm host therefore leaves the ROCm probe empty and reads as a driver-only box, and the new DRM sysfs pass set has_amd_gpu_without_rocm anyway. Vulkan honours none of the HIP masks -- it selects through GGML_VK_VISIBLE_DEVICES -- so the install would hand llama.cpp the exact GPU the caller hid, which _should_auto_vulkan_for_amd_windows already refuses on Windows and the has_physical_nvidia gate already refuses for CUDA_VISIBLE_DEVICES. Conjoin the mask check with a ROCm probe actually being installed rather than reusing _hip_visible_device_mask_set alone: the driver-only AMD host this branch exists for ships no rocminfo or amd-smi, and measuring it showed a bare exported CUDA_VISIBLE_DEVICES would otherwise cost a Steam Deck the Vulkan bundle. Intel is not addressed by HIP masks and keeps its plain detection. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Judge the whole AMD inventory, and prove the uv cache is writable * Restore the read-only rc warning main's tests assert * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Decline the ROCm route on a miscomputing target, and honour ROCR isolation on its own * Read the physical arch through an HSA spoof and a visibility mask * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Say why ARM64 keeps the CPU archive * Gate the CPU torch 2.11 floor on glibc 2.28 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Judge the gfx1033 gate on the visible AMD targets * Set the colocated uv cache on the standalone update path too * Resolve HIP-layer ordinals before classifying the visible gfx set * Resolve both visibility masks in the gate, not in the probe environment * Create the uv cache write probe with mktemp * Decline ROCm when a visibility mask or declared arch cannot be trusted * Gate gfx1033 on presence, not on which device the runtime picks * Ask the kernel before falling back to a spoofable probe * Disqualify the shared wheel family when gfx1033 is in the inventory * Carry the physical gfx1033 veto into the reroute and refuse an unverifiable spoof * Refuse the ROCm route before the inferred install, not after it * Demote ROCm torch when the selected target is a miscomputing arch The gfx1033 gate declined to INSTALL ROCm when the selected runtime target is a miscomputing arch, but it did not remove a ROCm build the venv already held. On a mixed host, a gfx1033 APU beside a healthy dGPU, _rocm_miscomputing_host() is False by design (it requires every physical AMD arch to be miscomputing), so _ensure_cpu_torch() had no authority to demote either. A device mask selecting the APU therefore printed "keeping CPU torch" while ROCm torch stayed installed, and the selected APU kept producing incorrect gradients under it. The message was wrong at the same time as the state. The two gates answer different questions and only the first could demote: _rocm_miscomputing_host() reads the inventory, while this branch reads the selected target, which is the one that decides what actually runs. Recorded the selected-target verdict and taught _ensure_cpu_torch to treat it as CPU authority, which is the path the inventory gate already used. Scoped to the case: with no such verdict a mixed host is untouched, so a machine whose selected target is the healthy dGPU keeps ROCm. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Revert the selected-target ROCm demotion I accepted a review item asking for this and was wrong: it contradicts a deliberate invariant this file already documents and tests. test_a_mask_cannot_shrink_the_host_to_its_bad_gpu states it directly. "Every arch is bad" is a question about the HOST, so it is asked of the whole host: ROCR_VISIBLE_DEVICES hiding a healthy dGPU would otherwise leave gfx1033 as the only arch and demote a working ROCm install to CPU. The demotion path calls _detect_amd_gfx_codes(ignore_hsa_override = True, ignore_visible_masks = True) for that reason, and install.sh's _probe_amd_gfx_arch unsets both masks too. Keying demotion off the selected runtime target is exactly the bypass that guards against, one door over from the HSA_OVERRIDE spoof the same tests cover. It went in green only because the demotion tests call _ensure_cpu_torch() directly, so the flag set inside _ensure_rocm_torch() was never exercised. What remains true is narrower and not worth this risk: on a masked mixed host the branch prints "keeping CPU torch" while a ROCm build stays installed, so the wording is inaccurate even though keeping it is the intended outcome. * Say what the selected-target branch actually does It printed "keeping CPU torch", which it cannot promise: whether an already-installed ROCm build is removed is _ensure_cpu_torch's call, and that asks about the whole host with the visible-device masks stripped, so a mixed host with a healthy dGPU keeps ROCm on purpose. The branch only declines to install ROCm for this target, so it now says that. The whole-host message above is unchanged, since there the demotion really does follow. * Tighten the comments added by this PR Compress the explanatory blocks to their load-bearing content: 584 added comment lines down to 313. No behaviour change; every test suite this PR touches still passes. * Make the arch-gate scenarios about the simulated host, not the test machine * Shorten the comments added by this PR Same content, fewer lines. Comments and docstrings only; no code changed. --------- Co-authored-by: Thomas Eric <thombelcar@gmail.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com> | 11 天前 | |
Revert "Studio: auto-generate an admin password for headless public launches (#7392)" This reverts commit 6c07765aaf5e5391cd87de9cdb6773062486a5a6. | 17 天前 | |
Final cleanup | 6 个月前 | |
Studio: stop the macOS update re-probing binaries and resolving 16 releases (#11237) * Studio: stop the macOS update re-probing binaries and resolving 16 releases * Hash the macOS runtime payload so the probe skip covers the load graph * refactor(studio): simplify the lazy release walk and harden the macOS probe skip Re-raise the last release failure once the lazy plan iteration ends instead of looking ahead through LazyReleasePlans.has_index and _has_release_plan, and write iter_release_plans with yield from. Build the ROCm attempts inside _with_rocm_behind_vulkan's try: the plans resolve lazily, so a later release's listing failure escaped it and abandoned the Vulkan install (or failed --resolve-prebuilt hard) instead of dropping the CPU tail. Keep the dyld probe when the macOS version is unknown. An unparseable platform.mac_ver() records macos_version as None on both sides, so the host profiles compared equal while the minos check was also skipped, leaving no load check at all. Let the macOS planner keep the download-host fast path on and pass only continue_after_fast_path, fold the record-hash check into _macos_load_record_is_current, and tighten comments, including ones that still said macOS turns the download host off. Adapt the probe-record and lazy release-plan tests to iterate plans through iter_release_plans, and add coverage for the ROCm fallback and an unknown macOS version. * Record that the macOS load probe ran, and compare the full macOS version The probe skip trusted two things it had not established. An inconclusive probe was remembered as a pass. macos_dyld_load_issues fails open on purpose: a timeout or a refusal to spawn is logged and returns no issue, so a loaded machine cannot lose a healthy bundle. The runtime record was then written immediately after, carrying no trace of whether the probe had actually run, and every later update skipped the probe on the strength of it. Before this PR that cost one retry per update and healed itself; with the skip it never retried. The reported cold probe of 24 to 42 seconds against a 60 second timeout makes that reachable rather than theoretical. preflight_macos_installed_binaries now reports whether dyld really resolved every binary, and only then does the marker gain macos_load_probe. A marker without it takes the probe, which is what every update did before the skip existed, so no existing install is invalidated and the key is not fingerprinted. host_profile could not see a macOS point release. It records parse_macos_version, which is (major, minor), so 15.5 and 15.5.1 compare equal. Since Big Sur the system libraries are a dyld shared cache blob that Apple replaces in point releases and in Rapid Security Responses, which is precisely the case of unchanged bundle bytes that stop loading, and the case the probe was added for in #9843. The recorded evidence now carries the full product version, so a patch update probes again while an unchanged one still skips. Two smaller ones in the release walk: - A pinned tag resolved twice under continue_after_fast_path. The download host had already yielded it, and the pinned block below resolved the same tag again through api.github.com. A pin names one release and has nothing to walk back to, so the fast path returns. - LazyReleasePlans forgot why it stopped. A generator that raised is closed, so a second traversal reported a short, clean sequence and len() disagreed with the call that had raised. The failure is kept and re-raised. Tests: an install whose probe never ran still probes; a timed out probe reports no pass while still not rejecting the bundle; a patch update probes again and an identical one does not; a pin resolves once with the walk-back on and still reaches the API when the download host is unavailable; a failed walk reports the same failure every time. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Persist a reuse-path load probe that passed The probe skip was unreachable for anyone not born with the record. Only a fresh install wrote macos_load_probe, and the three reuse paths discarded what their own preflight had just learned, so a marker written before the record existed, an install whose first probe timed out, and every install after a macOS point release re-ran the expensive dyld probe on EVERY later update and never stopped. Each reuse path now records a probe that actually ran and loaded every binary. A legacy marker whose payload entries carry sizes but no digests is re-recorded at the same moment, since that shape can never satisfy the skip either. Both are safe here and nowhere else: the minos check, whatever digests the marker did carry, and dyld itself all passed on these exact bytes moments earlier, which is the same evidence the install path records on. A probe that could not run still records nothing, so a fail-open timeout does not become a pass. Measured on a marker written by the pre-change installer: update 1 probes once and upgrades the record from 6 of 9 files hashed to 9 of 9, updates 2 and 3 probe zero times. Before this, every update probed. Tests: a passing reuse probe is remembered and the next update skips; a legacy size-only marker is upgraded by it; a probe that could not run is not remembered. The spawn-counting fixture now reports which binaries loaded, because a stub that only returned no issues exercised the no-evidence path rather than the pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Catch a Rapid Security Response in the recorded macOS identity platform.mac_ver() reads ProductVersion, and an RSR does not move it. macOS 13.3.1 (a) reports ProductVersion 13.3.1 with the "(a)" in ProductVersionExtra and the build at 22E772610a instead of 22E261, so the probe skip went on accepting a pre-RSR result across exactly the event the comment claimed it caught. Reading SystemVersion.plist has the same blind spot and is additionally stale for an RSR (osquery/osquery#8008). The recorded identity now comes from sw_vers, joining --productVersion, --productVersionExtra and --buildVersion, so a point release, an RSR and a plain build bump each invalidate it. --productVersionExtra exits non-zero when no RSR is installed and did not exist before macOS 13, so absent is recorded as absent rather than treated as a failure; if sw_vers cannot be run at all the product version alone is kept, which still catches a point release. Tests: an RSR probes again; the recorded string carries all three fields in order; a host with no RSR still yields a version. * Skip the macOS probe tests that start a binary on Windows The fixture writes shell scripts as stand-in binaries, so on Windows _binary_image_runs correctly reports a bad exe format and the install reads as unusable: the six tests that let _existing_install_runs start them assert a True they cannot get there. _pr10648_helpers.POSIX_ONLY already exists for this class. The tests that skip the probe, or stub the spawn, keep running on Windows. * Ask sw_vers with no arguments instead of guessing a flag spelling sw_vers documents single-dash options (-productVersion), so the double-dash forms this used are at best undocumented, and a flag that errors would have silently cost the ProductVersionExtra and BuildVersion fields that were the entire point: the function would have fallen back to platform.mac_ver() and gone on accepting a pre-RSR probe. Bare sw_vers prints every field as documented tab-aligned Key: Value lines, ProductVersionExtra among them when a Rapid Security Response is installed. One spawn instead of three, and nothing to spell wrong. An absent ProductVersionExtra still reads as no RSR, and an unavailable sw_vers still falls back to the product version, which catches a point release. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Record the load probe only once every image has started _existing_install_runs recorded straight after the dyld probe, then went on to start the root copies. The root entrypoint can be a separate wrapper rather than a symlink, so a corrupt one was hashed into the digest record and given probe evidence before it was ever started; the call still returned False, but the NEXT one read that record as current, returned before the wrapper was reached, and accepted an unusable entrypoint. For a pre-change marker the same write also upgraded its size-only payload entries on the strength of a check that had not finished. Recording now happens after every image has started, and in existing_install_matches_choice after the fingerprint and field comparisons rather than before them, so no tree that is about to be rejected gets its payload re-hashed. existing_install_current_without_plan already recorded on its success path. Test: a root wrapper that will not start leaves no probe evidence and no upgraded record, and fails with the previous ordering. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Record the load probe only from the path that starts every image The record covers the root copies as well as build/bin, and a later run skips starting them on the strength of it. Only a caller that has started them all can honestly write it, and the two reuse fast paths cannot: both probe build/bin alone and neither reaches a root llama-server, which can be a separate wrapper rather than a symlink. Persisting from there recorded and hashed a wrapper nothing had started, and _existing_install_runs then read the record as current and returned before it would have started the wrapper either. Persistence now happens only in _existing_install_runs, after every image has run. existing_install_matches_choice and existing_install_current_without_plan still probe and still reuse; they just no longer write evidence they did not gather. Test: neither fast path leaves probe evidence, even though both probed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim three comments that had started repeating each other The persist_macos_load_probe docstring and its one call site both spelled out the root-wrapper ordering at length; the docstring now states the precondition and the call site keeps the reason it comes last. macos_product_version keeps why sw_vers is asked bare, in a clause rather than a paragraph. --------- Co-authored-by: Lyxot <longyixing331@gmail.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 11 小时前 | |
Windows on ARM: install the native ARM64 CUDA stack on NVIDIA hosts (#10282) * Windows on ARM: install the native ARM64 CUDA stack on NVIDIA hosts NVIDIA's Windows-on-ARM parts (GB10 / N1X, the RTX Spark laptops) run CUDA natively, but the installer treated every ARM64 Windows host as "no GPU wheels here" and fell back to x64 Python under emulation. That fallback looks like it works: torch loads and sees the GPU. It then fails on the first kernel, because triton-windows' bundled ptxas is built for the x64 CUDA 12.8 toolkit and cannot target the sm_121 these parts report, so every Unsloth kernel fails to compile. llama.cpp separately picked a CPU-only bundle regardless of the GPU. Detection is evidence based rather than arch based: the installer asks the wheel indexes what actually exists for this interpreter, and only takes the native path when both a win_arm64 CUDA torch and an obtainable pyarrow are found. Anything else keeps the historical x64 path, so a host that cannot be served natively is never left with a half configured venv. The torch index is probed in order: download.pytorch.org first, so this special case ends by itself the day PyTorch publishes these wheels, then NVIDIA's GA channel, then its nightly. Requirement pins with no win_arm64 wheel are split on platform_machine == "ARM64" rather than loosened. Every other platform (linux x86_64 and aarch64, win x64, macOS arm64 and x86_64) evaluates to the pins it had before, checked by evaluating the markers per platform as well as by the test suite. Also: * llama.cpp prebuilt selection learns a windows-arm64-cuda install kind and falls through to upstream's win-cuda-13.4-arm64 asset, so these hosts get a CUDA bundle instead of the CPU one. When neither is hash gated the fallback is still taken, but says so in the log rather than silently. * setup.ps1 keeps an installed cu134 torch on win_arm64 instead of "repairing" it to a cu130 that has no wheel for this platform, and asks the chosen index whether it publishes torchaudio instead of assuming it does not. * install.ps1 checks before the studio handoff that the installed package understands this host, and stops with an explanation if it does not. The two halves ship through different channels (the script is fetched from unsloth.ai, the package comes from PyPI), and an older setup.ps1 handed a working +cu134 torch will reinstall a cu130 that does not exist here, leaving the environment with no torch at all. UNSLOTH_WOA_NATIVE=0 opts back out to the x64 path. Verified on an RTX Spark N1X laptop: clean install into an empty UNSLOTH_STUDIO_HOME ends EXIT=0 with torch 2.14.0+cu134, torchvision 0.29.0, torchaudio 2.11.0, triton-windows 3.8.0.post28 and the llama-b10715-bin-win-cuda-13.4-arm64 bundle; LoRA and QLoRA both train; Studio serves and reports CUDA; Qwen3.8-27B-GGUF UD-Q4_K_XL answers at 8.1 tok/s with the GPU at 94-95 percent through generation. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: key the new filters on the interpreter, not the machine Review of the branch turned up one real regression and several sharp edges. All of them are on paths this PR added; none change what a host did before it. The regression. `_is_windows_arm64()` answers for the MACHINE, and says yes even under an emulated x64 Python -- its docstring says so, since that is what it is for. But the requirement markers this PR adds key on `platform_machine == "ARM64"`, which is the INTERPRETER's arch, and every Windows-on-ARM install predating native support is running an emulated x64 interpreter, because install.ps1 deliberately fetched one there. So on exactly those existing installs the two mechanisms disagreed: the marker rows correctly selected the x64 pins while the new skip list dropped ten packages (xformers, tiktoken, tensorboard, librosa, openai-whisper, hf_transfer, sqlite-vec, mecab and the rest) that have working win_amd64 wheels and install fine today. Anyone taking the new UNSLOTH_WOA_NATIVE=0 escape hatch would have hit it too. `_is_win_arm64_interpreter()` answers the other question, from `sysconfig.get_platform()` -- the same authority pip and uv tag wheels with -- and the two call sites this PR added now use it. The pre-existing uses of the machine predicate are left alone, so nothing outside this PR shifts. The rest: * setup.ps1 answers `Test-WinArm64Venv` from the machine arch before launching any interpreter. The callers sit in the stale-venv path, which on x64 previously executed nothing at all; probing a venv's python there to learn something already known gave a corrupt or lock-blocked interpreter a chance to hang the install. * `$expectedKinds` gains windows-arm64-cuda only on the arch that can carry it. Accepting it on x64 would keep an install that machine cannot run, where today it is removed as mismatched. * The wheelhouse reuses a staged wheel only if it opens as a zip. An interrupted run leaves a truncated file, and skipping on mere existence handed the resolver that same corpse on every retry. Both downloads also take a timeout now. * UV_OVERRIDE / UV_FIND_LINKS / PIP_FIND_LINKS are cleared when they point into this StudioHome's own woa directory, so a second `irm | iex` in one shell cannot inherit a previous run's wheelhouse. Matched by path rather than cleared outright: an inherited UV_FIND_LINKS that a user set for their own wheelhouse is left alone, since clearing it would break air-gapped installs on every Windows host. * hf_transfer in no-torch-runtime.txt and cryptography in studio.txt (and its mirror in pyproject) get the same marker split as the other rows. That file installs --no-deps and the extra is installable directly, so neither was covered by the constraints file. * `windows_cuda_arch_for_host` tests is_windows as well as is_arm64. Every caller is already inside a Windows branch, but `is_arm64` is also true on macOS arm64 and Linux aarch64, so the answer should not depend on the caller remembering that. * The published legacy-minor scan uses the arch being resolved instead of a hardcoded x64, which was harmless only while no arm64 CUDA artifact existed. * No `windows_cuda_selection` log line on x64, where the arch is the unspoken default and the line would be new output in every existing install log. Verified: requirement markers resolve identically to origin/main across 65 environments (linux x86_64/aarch64/arm64/armv7l/ppc64le/s390x, win32 AMD64/x86/ARM64EC, darwin arm64/x86_64, freebsd, python 3.10 to 3.14), differing only on win32/ARM64; the studio-extra mirroring guard passes; both PowerShell files parse; and a simulated interpreter matrix confirms the win_arm64 skip list applies on a native ARM64 interpreter and on nothing else, including the emulated x64 case that regressed. * Keep the cross-platform parity greps matching Two parity tests read install.ps1 and setup.ps1 as text, and both changes in this PR moved the strings they look for. The behaviour they guard is intact in each case, so the fix is on this side rather than in the assertions. test_install_ps1_bounds_unknown_leaf_pinned_torch pins the literal "@_torchSpecs --default-index $TorchIndexUrl", which is how it checks that the bounded companion specs actually reach uv. The Windows on ARM branch inserted @_torchExtraArgs between the two halves. Those arguments are all flags and their values, never positional packages, so uv accepts them anywhere on the line; moving them after the index restores the literal and changes nothing about the resolve. test_setup_ps1_scrub_covers_pip_fallback slices a fixed 2500 character window from "function Fast-Install" and requires the pip fallback to appear before the finally that restores the scrub. The flag stripping loop added here is sixteen lines and pushed the finally out of that window: the fallback is still inside the try, but the test could no longer see it. Lifting the loop into Remove-UvOnlyResolverFlags puts Fast-Install back near its original size, with the finally at offset 1695 against a 1629 baseline on main. The helper was exercised against the inlined version it replaces on all six shapes: no flags, --index-strategy in both spellings, --prerelease in both spellings, and an empty list. Output is identical, including the empty-in empty-out case that every caller in the tree takes today. * Exempt the Windows on ARM drop list from the xFormers pin check test_installer_never_installs_an_unpinned_xformers flags every quoted xformers literal in install.ps1 that is not the exact pin. The Windows on ARM block added one, but it is a removal list: those names are written into the requirement overrides so the package vanishes from the resolve, since win_arm64 has no xFormers wheel and no buildable sdist. A version pin there would be meaningless, and the name has to match the requirement line exactly for the override to bind. The test already carves out wheel filenames on the same grounds, that a filename is not a spec. This adds the drop list beside it rather than loosening the assertion. Checked that it stays narrow: a bare "xformers" appended at end of file is still caught, and so is a loose "xformers>=0" placed on the line immediately above the drop list, while the real file is clean. * Windows on ARM: fix six ways the native stack picks the wrong thing Six defects found in review, all on the new WoA path and none reachable on any other host. Probe the interpreter that was actually selected. Initialize-WoaNativeCudaTorch answered for $PythonVersion, but Find-CompatiblePython falls back to another supported minor whenever the requested one is not installed. Every native decision is keyed to an interpreter tag, so a 3.13 answer carried into a 3.12 venv staged a cp313 pyarrow wheel and then pinned pyarrow== to it, forcing the Arrow sdist this path exists to avoid. Re-probe once the interpreter is settled, key the staged tags on the venv's own minor, and clear every script-scope answer on entry so a second probe cannot inherit the first one's. Redact the selected torch index. The status line printed $script:WoaTorchIndexUrl verbatim, and it can be UNSLOTH_TORCH_INDEX_URL with userinfo or a token query. It was the only index line in the file that did not redact. Remove-IndexUrlCredentials moves up to the other helpers, since PowerShell resolves a function only once its definition has executed. Hand uv a space-free override path. uv reads UV_OVERRIDE as a space-separated list of files, which this script splits on \s+ itself, and the default StudioHome sits under %USERPROFILE%. An overrides file under a path with a space was read as two paths, neither of which exists, and every later uv call failed with "File not found". Same defect and same remedy as backend/utils/uv_path_safety.py (issue #6503), which the Python half already applies. pip splits its repeatable options the same way; UV_FIND_LINKS is comma-separated and is untouched. Do not enable the native stack for an x64 venv. The migration branches keep a healthy legacy environment as it is, and on this host those are x64 by design. Get-TorchIndexUrl returns the win_arm64 index on the flag alone while the spec lift is gated on the venv's platform tag, so a migrated venv was asked for the bounded x64 specs from an ARM64-only index and aborted with no fallback. Ask the venv the same question the spec branch asks. Accept every spelling of a local wheelhouse. UNSLOTH_WOA_WHEELHOUSE is documented as taking a directory or a base URL, but only drive-plus-backslash and UNC counted as local, so C:/wheels went to Invoke-RestMethod and the native path was dropped on a host whose wheels were present. Ask the filesystem instead. Count a wheelhouse wheel only for the interpreter it was built for. Staging copies every win_arm64 wheel, cp311 through cp314, but both availability checks keyed on the distribution name alone. A cp311 tiktoken in a cp313 venv cleared its skip and its requirement override, and the resolve fell to an unbuildable sdist. Filter on PEP 425 tags on both sides. WINDOWS_ARM64_SKIP_UNBLOCKED_BY takes the whole blocker set rather than one name, since librosa needs numba as well as llvmlite and whisper needs tiktoken too; its openai-whisper key was also never matched, being looked up canonically. Let an explicit CPU pin through the CUDA preservation guard. It ran ahead of every pin check on both sides, so UNSLOTH_TORCH_INDEX_URL=.../cpu was dropped even though download.pytorch.org publishes win_arm64 /cpu torch and torchvision. In setup.ps1 it also left PinChangedForceReinstall false, which skips the dependency pass entirely, so the run kept the CUDA build and reported success. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin that an emulated x64 interpreter still gets the torch flavor repair The CUDA preservation shortcut reads the interpreter, not the machine, and nothing in the suite held it to that. Swapping _is_win_arm64_interpreter for _is_windows_arm64 at the guard leaves 223 of 224 tests here passing, which is the whole margin between a correct guard and one that strands every Windows on ARM install predating native support. Those run an emulated x64 python against ordinary win_amd64 wheels from download.pytorch.org, so the repair has somewhere to go and must still reach them. The fixture drove both predicates from one flag, which is right for every case already here, since a native ARM64 venv is the only place both are true. They separate on an ARM64 machine running an x64 interpreter, so win_arm64_interpreter overrides just that half and defaults to the existing flag. Verified by mutation: with the guard reading the machine instead, the new test is the only one of the 224 that fails. * Windows on ARM: pick the retained llama.cpp kind from the venv arch $expectedKinds asked Get-HostMachineArch, which answers for the machine. A Windows-on-ARM host on the x64 fallback runs the prebuilt helper under an emulated x64 python and installs windows-cuda, so the machine's answer would call that bundle mismatched and discard a working runtime. Test-WinArm64Venv is the same predicate the torch path uses, and it short-circuits free on x64. The block stays inert either way: write_prebuilt_metadata persists no install_kind, so $existingKind is always null. * Windows on ARM: pin the marker split, drop a write-only variable Cross-platform verification of the requirement splits, plus one dead variable the earlier fix left behind. WoaPythonMinor was written in three places and read in none. It was the variable the staging tag was meant to use; that moved to WoaVenvMinor, which is keyed on the venv's own interpreter. Leaving a write-only "the minor we probed" next to the real one only invites a future reader to take the stale answer. test_windows_arm64_requirement_markers pins the mechanism the whole split rests on. Every package needing a different version on win_arm64 is two rows whose markers are exact complements, so exactly one is live everywhere. Both ways of breaking that are silent: an overlap makes pip intersect two specifiers and can render the row unsatisfiable, and a gap drops the package on a platform nobody tested. The tests evaluate every row of every requirements file and every pyproject extra across 60 environments. Two of the checks exist because a scope-limited test skips itself when the bug appears. Spell the machine "arm64" rather than "ARM64" and the conjunction with sys_platform == "win32" becomes unsatisfiable, the split stops existing, and any check that looks for a split finds nothing to look at; Windows on ARM quietly takes the x64 pins and source-builds. That is caught semantically, by asserting no row is dead in every environment, rather than by matching marker text -- torchcodec legitimately pairs 'arm64' with sys_platform == 'darwin' in a separate disjunct. And the expected rows are asserted per source file, because studio.txt and pyproject[studio] mirror each other, so a global check stays green when only one of them loses a row. Verified by mutation: overlapping rows, a lowercase arm64 in either file, aarch64 for ARM64, a deleted WoA row, and either deliberate exclusion (xformers, hf_transfer) losing its marker are each caught, and each by a different assertion. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: close seven gaps the last round left open All seven are on code the previous rounds added, which is where a fresh pass should look. The resolver paths have to be readable by uv or the native stack is not viable. uv splits UV_OVERRIDE on whitespace and accepts no quoting of any kind -- bare, "quoted", 'quoted' and back\ slashed all fail identically on uv 0.10.7 -- so the 8.3 short form was the whole mitigation, and 8.3 generation can be disabled on the volume. A spaced StudioHome then has no space-free spelling and every later uv call dies on a truncated path. Decide that up front and take the x64 path, which never sets these variables, rather than staging wheels into a venv that cannot resolve. Same rule the pyarrow probe already applies. That short form then has to be recognised on the way back in. The block that drops a previous run's resolver variables matched only the long spelling, so after a native install under a spaced home it no longer recognised its own values and the next run in that shell inherited them. Both spellings are checked; a wheelhouse the user set for themselves is still left alone. The probe follows the interpreter that ends up owning the venv. Re-probing for the minor Find-CompatiblePython settled on was not enough: the ARM64 bootstrap below it installs $PythonVersion, which can be a third minor again, and the index and pyarrow source would both have been confirmed for one no longer in play. setup.ps1 is told which index install.ps1 used. It otherwise derives the CUDA index from the driver, and download.pytorch.org publishes no win_arm64 CUDA wheel at all, so a repair or a missing companion on a native venv resolved against an index that cannot serve it. Passed as UNSLOTH_WOA_SELECTED_TORCH_INDEX rather than UNSLOTH_WOA_TORCH_INDEX_URL, which is the user's input override: a second run in the same shell would otherwise read this run's choice as a pin. A wheel's ABI has to agree, not just its interpreter tag. cp313-cp313t is the free-threaded build and uv rejects it on a regular cp313, so counting it as available dropped the package's override and sent the resolve to the sdist the override existed to avoid. The Python half already checked this; the PowerShell half did not. Blockers decide even when the package's own wheel is hosted. tensorboard and librosa publish py3-none-any wheels that the staging step copies, so a wheelhouse can hold the package and still lack grpcio or numba; unskipping it there walks straight into the blocker's sdist. UNSLOTH_LLAMA_ARM64_CUDA=0 is honoured on the direct-upstream path too. It was checked only in the fork resolver, so the documented escape hatch silently did nothing with --published-repo ggml-org/llama.cpp. Each fix is mutation-tested: reverting either Python-side change fails a specific new test, and the PowerShell changes are asserted structurally and by executing the extracted helpers. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: build probe URLs with Join-UrlPath A tokenized mirror is a supported form for UNSLOTH_TORCH_INDEX_URL and for the wheelhouse, and "$base/$leaf" puts the leaf inside the token value: https://mirror/whl?token=abc became .../whl?token=abc/torch/, so the preflight read the mirror root or an error and disabled the native stack on a host whose wheels were there. Join-UrlPath already solves this for the xFormers URLs; it moves up next to the other Windows-on-ARM helpers, since PowerShell resolves a function only once its definition has executed and these probes run far above its old position. Applied to the torch/torchaudio preflight and to all three wheelhouse URLs, which had the same shape. * Windows on ARM: abandon the native route before building, and carry the index across shells Two review findings on the previous head. Abandon the native route earlier. The check for a StudioHome uv cannot read -- a path containing a space on a volume with 8.3 short names disabled, which uv splits UV_OVERRIDE on -- sat next to the resolver files it guards, which is after the ARM64 venv has been created. The install therefore paid for a venv it then tore down. Test-WoaResolverPathsUsable now runs inside Initialize-WoaNativeCudaTorch, immediately after the GPU probe, so the route is never chosen in the first place and every re-probe honours it. The verdict is cached, so a user with several interpreters reads the explanation once. Carry the chosen index across shells. install.ps1 exports the index it probed, but a later `unsloth studio update` runs in a fresh shell where that variable is gone, and Windows on ARM is the one platform whose CUDA wheels are not on download.pytorch.org at all, so the index cannot be re-derived from the driver. The manifest now records it, and setup.ps1 falls back to it when the handover variable is absent. That is a deliberate exception to the rule install_manifest.py documents for itself -- the flavor, never the index URL it came from, since a pinned index can carry a token -- so it is narrowed to the case that motivated it. Only credential-free https://pypi.nvidia.com URLs are written, with no userinfo, query or fragment, and setup.ps1 re-checks the same thing on read so a hand-edited manifest cannot redirect a torch install to another host. A mirror the user pinned is deliberately not persisted; they supply it through their own environment as before. tests/studio/install/test_woa_torch_index_persistence.py covers both halves of the guard, the Python that writes and the PowerShell that reads, including the older manifests that carry no such key and the truncated ones a killed installer leaves behind. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: restore the generated requirement overrides on a fresh-shell update The torch index was not the only thing install.ps1 hands over through a process-scoped variable. It also writes StudioHome\woa\overrides.txt, stages a win_arm64 wheelhouse beside it, and exports both through UV_OVERRIDE / UV_FIND_LINKS / PIP_FIND_LINKS. A direct `unsloth studio update` runs in a fresh shell where all three are gone, so the dependency pass resolved without them. That matters because studio.txt pulls ddgs, which requires httpx[brotli,http2,socks], whose brotli extra is Brotli on CPython, and neither Brotli nor brotlicffi publishes a win_arm64 wheel. Confirmed against the published metadata and against the resolver: uv pip compile for httpx[brotli]==0.28.1 targeting aarch64-pc-windows-msvc resolves 8 packages including brotli==1.2.0, and 7 with the overrides applied. With no wheel to install, those 8 mean an sdist build of a C extension -- the exact failure this path exists to avoid. Only reachable since this PR: an emulated x64 venv has a win_amd64 brotli wheel. Restore-WoaResolverEnvironment sits next to the torch-index recovery so both halves of the lost handover are put back in one place, before the torch installs and the dependency pass, which is where install.ps1 has them set for its own run. It restores rather than re-derives: both files persist under StudioHome\woa\, and the generated file is the only accurate record of what was dropped, since the drops depend on what the wheelhouse turned out to hold. No-op unless Test-WinArm64Venv, and it returns untouched if any of the three variables is already set, so install.ps1 driving the resolve in the same process is never clobbered. A native venv whose overrides file was deleted warns and points at install.ps1 rather than guessing the list. Get-UvSafePath is duplicated from install.ps1 because neither script can dot-source the other; a test asserts the two copies stay identical, since a copy that drifts would hand uv a path install.ps1 had already ruled unreadable. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: re-export the recovered index, restore each resolver variable, and cover no-torch Three follow-ups on the fresh-shell recovery. Re-export the recovered index. install_python_stack.py rewrites the manifest at the end of every run from UNSLOTH_WOA_SELECTED_TORCH_INDEX, so recovering the index into a local variable only meant the first fresh-shell update wrote a manifest without it -- erasing the one thing the manifest exists to carry, and making the whole mechanism work exactly once. It is now put back in the environment right after it is recovered, guarded so an empty recovery exports nothing. The value is always one install.ps1 chose or one write_manifest's credential-free allowlist already accepted, so re-exporting widens nothing. Restore each resolver variable independently. Returning when ANY of the three was set was aimed at the install.ps1 case, where all three arrive together, but it also fired for a shell carrying an unrelated UV_FIND_LINKS or PIP_FIND_LINKS -- a corporate wheel mirror has nothing to do with the win_arm64 drop list, and should not cost the user the brotli exclusions. Each is now restored on its own and a caller's value is still never overwritten, so the install.ps1 path is unchanged: every branch no-ops there. Cover no-torch mode. Both recoveries sat inside `if (-not $NoTorchMode)`, and neither is about torch. install_python_stack.py installs studio.txt at function scope with no NO_TORCH gate, and ddgs is not in NO_TORCH_SKIP_PACKAGES, so UNSLOTH_NO_TORCH=1 resolves the same brotli chain; the manifest is rewritten in that mode too. Both have moved above the guard. The torch-index USES stay inside it, where they belong. The new tests assert the placement rather than the text, and one asserts the studio.txt pass is still unconditional so this reasoning cannot go stale unnoticed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: read the manifest before dropping it, publish the index torch came from Three follow-ups, the first of which made the persistence added earlier a no-op. Read the index before the manifest is deleted. setup.ps1 drops unsloth_install_manifest.json before it replaces pip, torch and triton, so a run killed in those leaves the venv marked half-built rather than behind a marker that verifies. The recovery was placed below that drop and read a file that no longer existed, so it returned empty every time -- on precisely the fresh-shell path the persisted index exists to serve, and silently, since empty is also the correct answer for an older install that recorded nothing. The recovery now runs as soon as the dependency guard opens, before the drop. It stays inside that guard: a run that installs nothing has nothing to recover for. Publish the index torch was actually installed from. The native path resolves from the channel install.ps1 probed, while $TorchInstallIndexUrl still names the driver-derived family, so the handoff sent install_python_stack.py's repair to an index with no win_arm64 CUDA wheel and named the flavor cu130 when the installed wheel is +cu134 -- a repair could have tried to correct a working venv to a build that does not exist for this platform. $_effectiveTorchIndexUrl is initialised to $TorchInstallIndexUrl and overwritten only by the branch that installs torch, so every other path publishes exactly what it published before. On the NVIDIA channel the leaf is nvtorch_oot, not a CUDA family name, so no flavor tag is published at all -- the honest answer, and what the block already documents for an unknown leaf. Gate the llama.cpp ARM64 CUDA branch on the opt-out. resolve_upstream_asset_choice is reached through resolve_asset_choice on the fallback paths, so leaving it ungated meant UNSLOTH_LLAMA_ARM64_CUDA=0 worked in direct_upstream_release_plan and silently did not work here. The same gate is already on the equivalent branches at lines 1126 and 3897. The new tests assert ordering and wiring rather than the function in isolation, which is what let the first of these through. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: rebuild a migrated x64 venv as ARM64, and widen the llama.cpp CUDA opt-out Rebuild a migrated legacy environment. The migration branches keep a healthy ~/.unsloth/studio/.venv exactly as it is, and on a Windows-on-ARM host those are x64 by design: every install predating the native stack deliberately bootstrapped an emulated x64 interpreter. The venv-platform guard then saw win-amd64 and stood down to that same stack, so an upgrade left an NVIDIA user on a Triton that cannot compile for sm_121. Only a SECOND installer run recovered, because migrating makes the layout new and the new-layout branch already does preserve-and-recreate. Do that in the first run instead. Just above the venv-creation block, a migrated environment that is not win-arm64 is preserved with the same Start-StudioVenvRollback and the existing creation path builds an ARM64 venv. Scoped to a venv THIS run migrated: a new-layout venv was already moved aside, and one created moments ago came from the interpreter this run chose. $_Migrated is cleared on success. It gates an upgrade-in-place far below that installs unsloth with --no-deps --reinstall-package; left set, that would run against a freshly created empty venv and leave an unsloth with no dependencies. If the rollback throws, the environment is kept and the existing guard disables native exactly as before -- a user's environment is not worth trading for a native stack, so the platform guard keeps the final say. Widen UNSLOTH_LLAMA_ARM64_CUDA to every ARM64 CUDA bundle. This is a deliberate scope change, not only a bug fix. The helper documented itself as governing the upstream bundle when the fork publishes no checksum, but what the flag promises the user is the CPU bundle, and gating only the unverified tail meant the escape hatch would switch itself off the day the fork published an approved windows-arm64-cuda artifact, since the published branch returns first. The docstring now says what the code does. The inner check inside that branch is unreachable once the branch is gated, so it is gone rather than left reading as a fallback that no longer exists. The opt-out tests walk the AST rather than grepping, because the same attribute guards the x64, Linux and macOS paths, which must not be gated: they assert both that every Windows-ARM64 NVIDIA branch is gated and that no other one is. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: gate the pandas 3 row on Python 3.11, and keep the CPU llama bundle when CUDA is opted out pandas 3 needs Python 3.11. Every pandas 3.0.x is requires-python >=3.11 and its win_arm64 wheels start at cp311, which is why the Windows-on-ARM row exists at all -- but the row carried no python_version bound while requires-python still admits 3.9 and 3.10, so `pip install unsloth[studio]` was unsatisfiable on an ARM64 3.9 or 3.10 interpreter. Split on python_version as well as platform, which keeps the partition exact: below 3.11 an ARM64 host takes 2.3.3 and source-builds, slow but resolvable, as it did before win_arm64 wheels existed. Narrowing requires-python instead would drop 3.9/3.10 everywhere to fix one platform. The same two rows appear in studio.txt and single-env/constraints.txt with the same defect, so all three move together. The 603-case marker suite passed throughout this bug's life: the partition was exact and every environment had exactly one live row -- that row just could not install anywhere. So the suite gains the axis it was missing, asserting that a live row is installable on the interpreter it was selected for, checked against the package's own requires-python, plus the complement that no ARM64 Python is left with no pandas row at all. Keep the ARM64 CPU llama.cpp bundle when CUDA is opted out. With UNSLOTH_LLAMA_ARM64_CUDA=0 the selector installs a windows-arm64 CPU bundle on an NVIDIA Windows-on-ARM host, but the mismatch check expected only windows-arm64-cuda there, so it deleted a correct install on every setup and update -- and an update that cannot download then leaves no llama.cpp at all. The previous commit made this reachable in practice by widening the opt-out to the published artifact. The expected kind now follows the flag, INSTEAD of the CUDA kind rather than as well as it: accepting both would keep a bundle installed before the flag was set, so the opt-out would appear not to work on exactly the machine that already had CUDA. Falsy spellings are _upstream_arm64_cuda_allowed's, with a test asserting the two stay identical, since this is now one contract in two languages. The x64 arm is untouched and covered as a negative control. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: pair torchaudio with torch, prove wheels are CUDA, gate prereleases on nightly Take torchaudio only as a matched pair. The GA channel serves torch 2.14.0+cu134 beside torchaudio 2.11.0+cu134, torchaudio 2.11 dropped the exact torch== pin that 2.10 still carried, and the native specs are torch>=2.4 / torchaudio>=2.4 -- so nothing stopped the resolver installing that pair and leaving torchaudio's extension to load against a libtorch three minors newer. install.ps1 already names this hazard on the bounded-trio branch; the native branch was not covered by it. The probe now reads versions rather than existence and enables torchaudio only when its major.minor pairs with torch's, the relationship the old pin encoded. No matching pair means no torchaudio, which is what every Windows-on-ARM install did before this channel published one; it lights up on its own the day NVIDIA ships a matching pair. Establish CUDA positively. The probe rejected a literal "+cpu", which misses two things: PEP 503 hrefs encode "+" as %2B, and PyPI's own win_arm64 torch wheels carry no local version at all, so they are not "+cpu" and were read as CUDA -- sending the host native on CPU-only torch, which is worse than staying emulated because the GPU then goes unused with no fallback. Filenames are unescaped and must carry a +cuNNN local version. A private index whose CUDA wheels carry no +cu tag now reads as unknown and takes the x64 path: a behaviour change, and the safe direction, since that path works. Gate --prerelease=allow on the nightly channel, as install.ps1 already does. setup.ps1 passed it unconditionally and its comment ("the wheels are nightlies") predated the GA channel. "allow" means every prerelease, and it rode on a command carrying unsafe-best-match and an explicit PyPI extra index, so a prerelease of torch or of any shared dependency could outrank the stable GA build on every setup and update. The other two flags stay unconditional; the GA channel needs them. Verified against both live NVIDIA channels (temp/sim10282/probe_test.ps1) and covered offline with stubbed index pages for the parsing and pairing rules. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: five resolver and validation fixes Reject abi3 wheels on free-threaded interpreters. The exact-minor branch accepted "abi3" unconditionally, shadowing the guarded branch below it, so cp313-abi3 was installable on 3.13t while cp311-abi3 was correctly refused. Free-threaded builds do not implement the stable ABI (CPython #111506, PEP 703), so this marked a blocker available, dropped its skip and sent the resolver at a wheel it cannot use. PEP 803's abi3t is 3.15 and outside requires-python, so it is documented rather than matched. The test simulates both builds instead of reading whichever interpreter runs the suite. Validate UNSLOTH_PYARROW_WHEEL. Every other branch of Get-WoaPyarrowSource checks the interpreter and platform tags; this one took any file that existed, so an x64 wheel, a wheel for another minor or a truncated download selected the native path and then failed at resolution, having already given up the working x64 stack. Same checks as the siblings plus the zip signature, read two bytes off a stream rather than loading tens of megabytes. Keep the caller's resolver configuration. The purge above deliberately preserves anything that is not this StudioHome's own, and the assignments then overwrote it. Folded, not appended: uv combines override files rather than letting a later one win, and two files naming one package without distinguishing markers is an error -- and this file declares torch and torchvision. Same shape as New-UnslothTorchOverridesFile. Find-links have no such conflict semantics, so those are appended with the caller's kept and ours first. install_python_stack.py split find-links on os.pathsep alone, which would have read an appended "dirA,dirB" as one unusable path; it now splits on comma, whitespace and os.pathsep. The generated file is UTF-8 now that a caller's line can be non-ASCII. Accept the automatic ARM64 CPU fallback. resolve_asset_choice falls through to the published windows-arm64 bundle when no ARM64 CUDA asset exists, so demanding the CUDA kind deleted a healthy fallback every run. Widening strands nobody: the installer's already-satisfied check is per candidate and CUDA is attempted first, which a test now pins. The opt-out arm stays exclusive. Make the manifest writer and reader accept the same URLs. urlsplit().hostname strips the port, so https://pypi.nvidia.com:443/... was persisted and then refused by setup.ps1, losing the index on the next fresh-shell update. The writer now requires netloc == hostname, admitting exactly what the reader admits and dropping userinfo with it. Declining is preferred to silently rewriting a caller's URL; the default channels carry no port. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: probe and stage the interpreter's ABI tag, not just its minor A free-threaded build installs cp313-cp313t wheels, but every probe here derived its tag from the minor alone and searched for cp313-cp313. The two halves then failed in opposite directions: the index probe found the ordinary GIL wheels and enabled the native stack, while the wheelhouse staging kept exactly those wheels and discarded the cp313t ones the venv could have installed -- all after the x64 fallback had been given up, so the run failed at resolution with nowhere to go. Reachable: Find-CompatiblePython's python3/python PATH scan and its `py -0p` enumeration both accept any interpreter whose --version matches, which a free-threaded build does, and the native path ranks ARM64 builds first. Test-PythonFreeThreaded asks the selected interpreter for Py_GIL_DISABLED and Get-WoaAbiTag turns the answer into cp313 or cp313t, threaded through the torch, torchaudio and pyarrow probes and through the staging scan, which also records the venv's own ABI. A free-threaded host therefore declines the native path today, because no index publishes those wheels, and starts working by itself the day one does -- better than refusing such interpreters outright. Unknown answers GIL, the historical assumption, so nothing moves for a GIL host: there the two tags are the same string. The staging scan also stops accepting abi3 on a free-threaded venv, the rule already applied to _wheel_matches_interpreter, so the two now agree. Verified against the live GA channel: a GIL 3.13 still resolves torch 2.14.0+cu134 and a free-threaded 3.13 finds nothing. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: re-probe on ABI change, merge caller overrides, honour an explicit pin Re-probe when the ABI changes, not only the minor. The previous round threaded the interpreter's ABI through every probe but left the re-probe guard keyed on the minor alone -- so the one case where the minor cannot change, a 3.13t chosen for the default 3.13 request, was exactly the case that never reached the call passing -FreeThreaded. The initial probe's GIL assumption stood, native mode was enabled on cp313 wheels, and the venv was then built from an interpreter that installs cp313t. $WoaProbedFreeThreaded is tracked beside $WoaProbedMinor and either changing re-probes, including after Install-PythonFromPythonOrg, where the ABI can change while the minor does not. Detection is scoped to an ARM64 host so no other Windows host spends a subprocess on an answer that cannot matter there. Merge caller overrides on an update instead of standing down. install.ps1 folds; setup.ps1 skipped the generated file entirely whenever UV_OVERRIDE was set, which keeps the caller's file and loses the win_arm64 drop list -- so ddgs -> httpx[brotli] -> Brotli went back to an sdist with no win_arm64 wheel. uv combines override files rather than letting one win, and errors on a duplicate package, so the two cases differ: disjoint files are both handed over, needing no rewriting and leaving each file's relative references resolving against its own directory, and only an actual conflict is merged, with this platform's entries winning because they name what it cannot build. Get-RequirementName(s) do PEP 503 normalisation so Brotli and brotli_cffi compare as one name, and a merge that cannot be written falls back to the caller's file with a warning. Let an explicit pin outrank the persisted index. UNSLOTH_TORCH_INDEX_URL and UNSLOTH_TORCH_INDEX_FAMILY both land in $PinnedTorchIndexUrl, and the recovered WoA index was winning over both -- so a user switching to another CUDA mirror was silently still served by the previously recorded channel. The recovery is a memory of what install.ps1 chose, not a decision the user made, so it is now consulted only when nothing is pinned. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: keep caller resolver entries, survive a bad handoff URL, drop a stale torchaudio, check wheelhouse versions - install.ps1: the resolver purge removes only the entries pointing into this StudioHome's woa directory instead of the whole variable, so a caller's mirror appended behind ours survives a second run in one shell. - install_manifest.py: parse the WoA index URL inside a try/except ValueError; a malformed authority no longer aborts the whole manifest write. - setup.ps1: on a native ARM64 install that leaves torchaudio out of the trio, uninstall an already-installed torchaudio whose major.minor no longer matches torch. - install_python_stack.py: the find-links scan records versions, and a hosted wheel clears a win_arm64 skip only when one of its versions satisfies the pin in the requirements file being installed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: match wheel tags as fields, evaluate markers, bound the purge, version the drop list - install.ps1: one Test-WoaWheelTags parses the PEP 425 fields and expands each dot-separated tag set, replacing seven substring probes. "*cp313-cp313*" also matched cp313-cp313t, so a free-threaded wheel could select and then be staged for a GIL interpreter, failing only after the ARM64 venv was committed to. - install.ps1: the resolver purge treats only the prefix itself or a descendant past a path boundary as owned, so <StudioHome>\woa-mirror survives. - install.ps1: the override drop list records wheel versions and consults the floor the released metadata puts on the name (xformers>=0.0.22.post7) before removing a drop; a lower hosted version keeps it. - install_python_stack.py: requirement markers are evaluated for the running interpreter, so the inactive MeCab row no longer overwrites the active one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: honour any explicit pin, preflight PyAV, keep caller override files in place - setup.ps1: the CUDA-preservation shortcut exempts any explicit torch index pin, not only a /cpu one. It distrusts the inferred host-family comparison; a pin at a cu129 mirror is a stated instruction, and the narrower test skipped the dependency pass and kept cu134 while reporting success. - install.ps1: on a free-threaded interpreter, native mode requires an installable win_arm64 av (PyAV) wheel. The wheel constraints.txt asks for is cp311-abi3, which no free-threaded build can use; PyAV publishes cp314-cp314t but no cp313t, so the index is asked rather than assumed. GIL builds are unchanged and never run the probe. - install.ps1: a caller UV_OVERRIDE file that declares none of our packages is passed to uv where it sits, so its relative -r and wheel paths keep resolving. Only a conflicting file is folded, and its relative references are rebased onto the directory they were written for. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: read the machine arch from the registry, not from the process Fresh install of this branch on the GB10 laptop went down the x64 path: plain "NVIDIA GPU detected" and cu130 from download.pytorch.org, which publishes no win_arm64 wheel, with no line saying why. Running the probe's gates one at a time found it: Get-HostMachineArch answered x86_64, so Initialize-WoaNativeCudaTorch returned on its first check before printing anything, and every later gate, nvidia-smi, the NVIDIA index, the pyarrow wheel, would have passed. Every signal it trusted follows the process, and the install ran from an x64 emulated shell. There PROCESSOR_ARCHITECTURE is AMD64, PROCESSOR_ARCHITEW6432 is empty because it is a WOW64 variable and x64 emulation on ARM64 is not WOW64, and .NET Framework's OSArchitecture reports X64, a bug fixed only in .NET 5. VS Code's integrated terminal is often x64 on these machines, so this is a real user path, not a test artefact. The machine-scope PROCESSOR_ARCHITECTURE from the registry is the one value emulation cannot misreport, and it now leads the signal list in install.ps1, in setup.ps1, and in setup.ps1's Get-UvHostArch so the uv an update picks matches the one install.ps1 chose. The per-process signals stay as fallbacks; on x64 hardware every answer is unchanged. setup.ps1 is the one that made this urgent. Test-WinArm64Venv short-circuits on the same function, so from an x64 terminal an update of a native ARM64 venv would have skipped the CUDA preservation guard and repaired a working cu134 torch to a cu130 that cannot install there. The python side had the identical hole: _is_windows_arm64 read the same three signals and its docstring promised ARCHITEW6432 would be set under emulation, which it is not. It reads the registry first now. Two of its three callers drop torchaudio on the grounds that no win_arm64 wheel exists, which is a property of the interpreter, not the machine: an emulated x64 venv on an ARM64 box installs win_amd64 wheels and download.pytorch.org publishes torchaudio for those. Both move to _is_win_arm64_interpreter. The third caller prints the Vulkan note, a machine fact, and stays. Verified with the real functions lifted from install.ps1 and run under both an x64 emulated and a native ARM64 PowerShell: both now answer arm64 and select the nvtorch_oot index. Under the x64 and the ARM64 CPython on the same box the machine predicate is True for both and the interpreter predicate is True only for the ARM64 one, which is the disagreement the wheel filters rely on. Tests: the arch-probe cases patch the registry helper so they answer the same on an ARM64 host as on x64 CI, and gain the emulated case with every per-process signal saying AMD64. The Vulkan-advice fixture takes the host as a parameter instead of reading the box it runs on; three of its tests had failed on every ARM64 dev box for that reason. * Make the marker-evaluation test independent of the host running it test_pins_are_read_canonically_and_markers_evaluated wrote a row under `sys_platform != 'win32'` and asserted the pin survived because "its marker holds on this host". It holds on a Linux runner and on no Windows machine, which is every machine this file is about; on the GB10 laptop it fails with a KeyError. The reader answers True, False or None for a marker, and only False drops the row, so the test now uses one marker that holds everywhere and one that holds nowhere, and asserts both directions. Same coverage, no dependence on the box. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: prepend the managed wheelhouse, rebase merged overrides, tolerate a self-copy - setup.ps1: the resolver recovery prepends $StudioHome\woa\wheels to an existing UV_FIND_LINKS / PIP_FIND_LINKS instead of standing down. find-links are additional search locations, so skipping ours because a caller had set a mirror left the staged win_arm64 wheels out of the search and sent pyarrow to an sdist. - setup.ps1: Resolve-WoaOverrideLine is carried over from install.ps1 and every line folded into overrides.merged.txt is rebased against the caller file's directory, so a relative -r, -c, -f or wheel path keeps resolving. - install.ps1: both wheel staging copies compare normalised paths first. A wheelhouse pointed at the managed wheel directory made Copy-Item overwrite an item with itself, which under ErrorActionPreference Stop aborted the install. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: open the supplied wheel, and honour a configured win_arm64 mirror - install.ps1: UNSLOTH_PYARROW_WHEEL is validated with Test-ZipArchiveReadable rather than a two-byte PK sniff. An interrupted download still carries that signature, and accepting one selected the native path, staged the broken file and failed the pyarrow resolve with the x64 stack already given up. - setup.ps1: UNSLOTH_WOA_TORCH_INDEX_URL is read ahead of the handover variable and the manifest. write_manifest persists only NVIDIA's own channels, since any other URL could carry a credential, so a corporate win_arm64 mirror survives a fresh shell nowhere else; without this a direct update fell back to the driver-derived index, which publishes no win_arm64 CUDA wheel. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: guard the third staging copy against a self-copy UNSLOTH_PYARROW_WHEEL may point at the already-cached wheel under $StudioHome\woa\wheels, which is how an offline run reuses the managed cache. Copy-Item refuses to overwrite an item with itself; here the throw is caught, so instead of aborting it disabled native mode after the ARM64 interpreter and venv had already been chosen, leaving the run on a CUDA index with no win_arm64 torch. Test-WoaSamePath now guards all three staging copies, and a counting test keeps a fourth from being added without one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the new marker and path tests independent of the host running them Same defect as the pin-reading test: a fixture marker of sys_platform != 'darwin' or python_version < '3.14' picks the other row on a macOS 3.14 box, and literal POSIX expectations for Resolve-WoaOverrideLine are wrong on Windows, where GetFullPath returns a drive-qualified backslash path. The marker fixtures now use one marker that holds everywhere and one that holds nowhere, and the rewriter expectations are derived with os.path.abspath. * Windows on ARM: trim the commentary Comment-only pass over the branch: collapse wrapped explanations, drop restatements of the line below them, and shorten test docstrings that carried more than their first sentence. Every fact a comment was carrying is kept. The comment-only property is checked two ways: comment_tools.py for the Python files, and a PowerShell tokeniser comparison for install.ps1 and setup.ps1 (32293 and 29539 non-comment tokens, identical before and after). One test changes: test_no_staging_copy_is_left_unguarded anchored its search on a comment, so a comment pass could break it. It now anchors on the code the region starts with, and still fails when a guard is removed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: six review fixes - _version_satisfies now answers with packaging when it is importable, with prereleases off unless the specifier asks for one, which is the policy uv resolves under. A wheelhouse tiktoken-0.13.0rc1 reduced to (0, 13, 0) and read as satisfying ==0.13.0, so tiktoken was unskipped and the resolve fell to the ARM64 sdist. The numeric fallback now refuses a version it cannot model rather than guessing. - Every pyarrow candidate is floored at the ARM64 constraint (>=21.0.0). A tag-compatible 19.x in the wheelhouse selected the native path, and staging then wrote an exact pyarrow== override that the constraint rejects, after the ARM64 venv had been built. A test keeps the two numbers in step. - --prerelease=allow is decided by the version of the wheel the probe selected, not by whether the index URL says nightly. A mirror of NVIDIA's prerelease channel need not say so, and without the flag uv takes the stable win_arm64 CPU torch from the PyPI extra index instead of the CUDA build. install.ps1 hands the answer to setup.ps1, which cannot probe. - The ARM64 CUDA-preservation shortcut in install_python_stack.py exempts every explicit pin, not only a /cpu one. A user asking for cu129 by URL or family was silently left on the cu134 build. setup.ps1 already did this. - Both requirement scanners follow a nested -r. uv reads those includes, so a conflicting package one level down made two override files look disjoint; handing uv both is an error, and the caller's capped torch could win. The fold flattens the include, rebasing each line onto the file it was read from. - install.ps1's torchaudio and prerelease flags apply only while the index they were measured on is still in force. Change UNSLOTH_TORCH_INDEX_URL and re-run an update in the same shell and the pin outranks the handover for the install, while a stale 1 asked the new index for a torchaudio wheel it does not publish and aborted the whole torch update. Each fix is mutation-tested. Suite: 5902 passed, four pre-existing failures unrelated to this branch (managed node runtime, tokenizers negative control). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: a version floor has to order prereleases Test-WoaVersionAtLeast compared the numeric release and then .postN, so 21.0.0rc1 cleared a 21.0.0 floor. Staging writes an exact pyarrow== override from whichever candidate it picks, and pyarrow>=21.0.0 rejects that rc, which is the failure the floor was added to prevent. A marker attached to the release now sorts below it. A larger release is unaffected, so a wheelhouse nightly still clears an older floor: the pyarrow 24.0.0.dev260 an end-to-end GB10 run staged is named in a test, since a floor that rejected it would break a verified install. The eleven-row ordering table is cross-checked against packaging in the same class rather than asserted from memory, because the PowerShell cannot import it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * GPU driver: an all-[N/A] compute-apps listing is "cannot attribute", not "nothing appeared" Ran the Kaggle GPU payload against a fresh native install on the GB10 laptop. Every workflow passed except cli_run, which said `unsloth run` served from the CPU because no process appeared on the card holding more than 0 MiB. It had: with the exact flags the CLI passes (-ngl -1 --threads 2) the same llama-server generates at 7.7 to 8.3 ms per token, inside the --gpu-layers 99 band (6.5 to 7.1) and far from a two-thread CPU control (18.4). llama-server --list-devices shows CUDA0 with 46 GB. The verdict was blind, not wrong about the rule. On Windows (WDDM) and on unified-memory parts nvidia-smi lists every CUDA process with used_gpu_memory [N/A], and parse_compute_apps drops those rows, so a server that did appear during the window looked like nothing appeared and the device-delta fallback never engaged, because that branch only runs when nvidia-smi cannot enumerate at all. Listing alone is not evidence either: a -ngl 0 server is listed too, since a CUDA build creates a context regardless. Measured here: the device counter reads 132 MiB idle, 272 MiB with that bare context, 560 MiB with the 270M model offloaded, so the existing 200 MiB fallback threshold separates the two. count_listed_pids tells listed-but-unattributed apart from empty, and nvidia_compute_apps returns None for it, which is what the verdict already treats as "judge on the device delta". Readable listings are unchanged, and parse_compute_apps keeps dropping [N/A] rows, so its tests stand. Tests added for the count, for the None, and for the verdict on the numbers measured on this box. test_a_box_without_nvidia_smi_reports_no_gpu_rather_than_crashing fails on this host on unmodified main too, since a real nvidia-smi leaks through its simulation; not touched here. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: sqlite-vec cannot resolve in the studio extra pyproject pinned sqlite-vec==0.1.9 unconditionally, so a native win_arm64 interpreter running pip install "unsloth[studio]" hit a row it cannot satisfy: sqlite-vec publishes win_amd64 wheels only and no sdist at all, so there is nothing to build and no version to floor to. The runtime skip list already covers the name, but that filtering never runs for a direct extra install, which resolves pyproject's rows. Dropped on win_arm64 with the same negative marker cryptography and pymupdf use, mirrored into studio.txt so the two stay in step. Nothing regresses: rag_db.py guards the import and the router mounts either way, reporting RAG unavailable. Guarded generally for the studio extra: no skip-list package may be left live on win_arm64, and a marker that drops one must drop it nowhere else. Scoped to studio on purpose, since the other extras are x64 recipes pinning win_amd64 wheel URLs and were never a claim about this platform. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: three review fixes - The resolver purge split every variable on [,\s] and rejoined each with its own separator. uv reads UV_FIND_LINKS comma-separated, so a caller path like C:\private wheels is ONE directory there: the shared split tore it into two fragments, dropped the managed entry, and rejoined the pieces with commas, quietly losing an air-gapped mirror. Each variable is now split the way the tool that reads it splits it, in install.ps1 and in _find_links_wheel_versions, where the same class of split made wheels in a space-containing directory invisible to the skip list. - The native ARM64 index arguments were gated on $UseUv, so when uv could not be obtained at all the pip fallback got only --index-url pointing at a channel that publishes just the torch trio, leaving their shared dependencies unresolvable. Remove-UvOnlyResolverFlags already exists to make that list safe for pip, dropping --index-strategy and rewriting --prerelease=allow as --pre, so the arguments are built either way. - The manifest was the only durable record of the WoA index, and the dependency pass deletes it before rebuilding it. A run that died in that window left the next update with no handover and no manifest, so it fell to the driver-derived cu130 and failed on an index with no win_arm64 wheel, on every retry. A marker beside overrides.txt, written before the manifest is dropped, now carries it. It persists exactly what the manifest persists, checked on write as well as on read, so a mirror pinned with a token is not written to disk and a hand-edited file cannot redirect a torch install. Seven mutations, all killed. The first attempt at the marker tests passed with the write-side guard removed, since the read guard alone defends a round-trip; a test that inspects the file on disk covers it now. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: two review fixes - New-UnslothTorchOverridesFile kept relative references working by writing the merge into the caller's override directory, which only helped when every override file shared one. Round 19 made two the normal case on the native path, since a non-conflicting caller file is now kept where it sits, so the merge fell through to %TEMP% and every relative include or wheel path resolved against nothing. Each folded line is rebased against the file it came from instead, and nested includes are flattened for the same reason, so where the merge lands no longer changes its meaning. - The dependency pass fell back to pip whenever uv failed or was missing. On the native stack that does not recover: the overrides lift the released torch cap, which no win_arm64 CUDA wheel satisfies, and drop the packages with no win_arm64 build, and pip has no override mechanism to translate them into. Constraints cannot stand in, they narrow a requirement rather than replace one. Both fallback routes now refuse with the reason and name the remedy instead of resolving the wrong stack. Five mutations, all killed. Restored one behaviour the new scanner had changed: comment lines are carried through a fold again, which test_unsloth_torch_override.ps1 was right to be checking. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: four review fixes - An include's inline comment is not part of its path. "-r nested.txt # corporate pins" is a valid line, and capturing the comment meant the file never opened, so a torch conflict inside it went unseen and the two override files were handed to uv as disjoint. Stripped only where whitespace precedes the hash, so "-r a#b.txt" keeps its filename. - An index the marker may not record now CLEARS it rather than leaving the last one in place. A host that moved from NVIDIA's channel to a credentialed corporate mirror kept a marker naming the old public channel, and since the manifest does not record the mirror either, the next fresh-shell update read the marker and put torch back on a source the user had left. An unrecordable index inherits nothing. - The local wheelhouse mirror counted a wheel on its filename, which is all _find_links_wheel_versions reads, so a truncated archive took its package off the ARM64 skip list and uv failed the whole dependency pass on it. It is opened before it is copied now, the same check the remote branch already applied. - The GPU harness read a mixed nvidia-smi listing as proof of CPU: a readable row on one card keeps the mapping nonempty, so the all-[N/A] guard stays quiet and the newly launched server, reported as [N/A] on a WDDM or unified-memory card, is simply missing from it. A pid that appeared but carries no figure now defers to the device-wide delta, as the all-[N/A] case does. Both samples come from one nvidia-smi call, so the attributed mapping and the listed pids describe the same moment. Seven mutations, all killed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: two review fixes - The local wheelhouse pyarrow was selected and staged on its filename. Last round's archive check covered the OPTIONAL mirror, where a bad wheel costs one feature; this is the mandatory one, and staging writes an exact pyarrow== override from whichever file it picks, so a truncated wheel chose the native route and then failed the resolve with x64 already given up. The probe and the staging now apply the same filter, tags and readability, so they cannot pick different files. - The device-wide VRAM total is only a measurement of this process when this process owns the card. Under --studio-concurrent a training leg shares it and both allocates and frees inside the window, which is how one run read the delta as -182.0 MiB while the server held 2.6 GB, so accepting a rise there would pass a CPU-served run on somebody else's memory. Both fallbacks to the total now refuse when anything was on the card before the launch. Decided on evidence rather than configuration: the payload is not told which half it is, but a pid already present says so, and an empty card keeps the fallback the [N/A] parts need. Six mutations, all killed. Two tests from last round asserted the mixed listing case with a co-tenant present, which is exactly the scenario that must not pass; they now use an owned card, and the shared variants are their own cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: quote a rebased override path that needs it Resolve-WoaOverrideLine stripped the quotes off an -r/-c/-f target and wrote the absolute path back bare. These options take one file argument, so a space truncated it and the dependency pass failed opening a path cut at its first space. Two ways in, and only one is about quoting. A caller who quoted the value had the quotes discarded. A caller who had no reason to quote a plain relative name gets a space anyway when it rebases onto a directory that has one, which no amount of preserving would have caught. The rebased value is quoted whenever it contains whitespace, so both are covered, and setup.ps1's parity copy is kept identical by a test. The first version of this had the option token disappear: -match on the rebased value replaces , so reading the groups afterwards returned a bare path where -c <file> had been. The file already carries a comment warning about exactly that. The groups are copied out first now, and a test asserts the option survives. Four mutations, all killed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: four review fixes - The marker recorded the WoA chain, which never consults the generic UNSLOTH_TORCH_INDEX_URL pin the torch steps actually prefer. A run pinned elsewhere installed from the pin and then recorded an NVIDIA channel it had not used, and the next fresh shell went back to it. The pin decides what is recorded now, and an unrecordable one clears the marker. - _find_links_wheel_versions read PIP_FIND_LINKS as well as UV_FIND_LINKS. uv does not consume it, so a wheel hosted only there was counted as available, dropped off the skip list, and then invisible to the uv resolve that followed. pip cannot be the resolver on this path either, since pip_install refuses the fallback once the overrides are in force. - A transitive blocker had no direct row to satisfy, so any hosted version counted. tensorboard 2.21.0 requires grpcio>=1.74.0 and librosa 0.11.0 requires numba>=0.51.0: a wheelhouse holding an older one lifted the skip, and the optional package's own metadata then failed the whole extras pass. The floors are recorded with the release they were read from, and a test fails if extras.txt moves off that pin. llvmlite has no entry deliberately: nothing states a floor for it. - The PyPI pyarrow route cleared native mode on a wheel it then forgot, so no exact override was written. With only pyarrow>=21.0.0 in force, uv takes the newest release, and one that ships an sdist for this interpreter builds Arrow from source. The matched wheel is pinned now, and the probe clears the name on entry so a re-probe cannot inherit it. Six mutations, all killed. The first attempt at the second fix keyed on USE_UV, which is False until the bootstrap runs, so it read the wrong variable everywhere else; the list is unconditional now, which is correct because uv is the only resolver that reaches it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: open the downloaded pyarrow wheel before keeping native mode A mirror can serve a truncated body with a 200, and Invoke-WebRequest reports that as success. This is the mandatory wheel: the exact pyarrow== override is written from it, so an unreadable download kept the native route and then failed uv on that override, after x64 had been given up. The optional remote wheels below already opened what they kept; this one was the last route that did not. It throws into the existing catch, so the failure falls back to x64 rather than continuing, and the bad file is removed first -- left in the managed directory it would be read by the resolver on every later run, and by _find_links_wheel_versions as proof the package is available. Three mutations, all killed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Record the effective torch index in the manifest, not just the marker install_python_stack.py writes UNSLOTH_WOA_SELECTED_TORCH_INDEX into the manifest as woa_torch_index, and the read chain in setup.ps1 prefers the manifest over the marker. Exporting the WoA chain while saving the pin left the manifest shadowing a marker that had already been corrected, so the next fresh shell went back to the index the run had not installed from. Resolve the value once from Get-PinnedTorchIndexUrl, falling back to the WoA chain, and write both records from it. The torchaudio and prerelease flags install.ps1 leaves in the shell describe the index it probed, so drop them when the resolved index differs from that one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop the generated requirement snapshots that reached the branch _filter_requirements writes its skip-filtered copy beside the source so relative -r/-c includes still resolve, and pip_install unlinks them in a finally. A test that calls the helper directly, or an install killed mid-run, leaves them in the checkout, where a git add -A picks them up. Nine were committed that way. They are a snapshot of a file already in the tree, so a stale one reads as a second, silently divergent copy of the pins. Remove them, ignore the pattern, and add two tests: one that no tracked file matches the generated shape, one that the ignore rule covers it. The shape is matched exactly rather than by a leading dot, so a real hidden requirements file such as .constraints.txt stays in scope. * Persist an explicit pin, install hosted optionals, scope the GPU samples Three separate holes. setup.ps1 ran the index persistence block only when the WoA chain had something in it. A venv installed through a credentialed mirror has nothing to recover, so a later run pinned at an NVIDIA channel installed from the pin and recorded neither record, and the next fresh shell fell back to the driver-derived index that publishes no win_arm64 CUDA wheel. Either record now opens the block. install.ps1 reported that a hosted optional wheel was keeping its package, but only declined to emit the removal override. hf_transfer and xformers are excluded on win_arm64 by marker, so no requirement survived for the wheel to satisfy, and torchcodec's only requirement line was filtered out here unconditionally. Make that filter conditional on a hosted wheel, and install the distributions the metadata cannot ask for, --no-deps and best effort. The Kaggle GPU harness sampled every physical card while the payload was pinned to one, so an unrelated process on a hidden card could supply the memory that carried a CPU-served run past the assertion. Both samplers now pass CUDA_VISIBLE_DEVICES to nvidia-smi -i. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin the probed CUDA trio, and validate the wheelhouse xformers uv documents --index-strategy unsafe-best-match as selecting the best version from the combined candidate set of every index, and the PyPI extra index is there because NVIDIA's channel publishes only torch, torchvision and torchaudio. So torch>=2.4 takes PyPI's stable win_arm64 CPU wheel the moment it is one release ahead of the selected channel, and the native GPU path is replaced by a CPU build that imports perfectly. Probe torchvision as well as torch and torchaudio, keep all three versions, and install them pinned exactly, local +cu tag included. An unreadable version keeps the floor it had before. The optional wheelhouse installs added last commit took the bare name, so any tag-compatible wheel landed: below the declared xformers floor, or built for another torch and CUDA pair, whose extension then fails to load while the step reports success. Select the newest hosted version that clears the floor, install that version, and for xformers compare cpp_lib.json against the resident torch, removing it on a mismatch the way the post-repair resync already does. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin the repair path too, pair the companion wheels, fix two availability rules The forced-repair path in setup.ps1 installs the trio with the same unsafe-best-match and public PyPI extra index as the install path, so open-ended specs there had the defect the install path was just fixed for. Re-probe the effective index at repair time and pin what it publishes; a repair can happen in a fresh shell long after the install, so a remembered version would be stale. The probe is a parity copy of install.ps1's, compared body for body by a test. Pinning both torch and torchvision exactly made the pair mandatory, and NVIDIA's nightly channel publishes each project on its own schedule while nightly torchvision metadata pins its exact torch. Select the companions as builds of the torch that was chosen, matching on the dev stamp and the local tag, and fall back to the floor when the index carries no companion from that build. Test-WoaVersionAtLeast read .dev as a pre-release of the release, so 0.0.22.post7.dev0 came out at or above 0.0.22.post7. PEP 440 hangs .devN off whatever precedes it. Track it separately and as an ordinal. The win_arm64 skip list was decided from the local wheelhouse alone, so a native CPython 3.14 host dropped librosa even though llvmlite and numba publish cp314 win_arm64 wheels. Record what the public index resolves for this interpreter. Also removes a file named - from the root: a workspace simulation ran uv pip compile -o -, which writes a file rather than to stdout. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the Windows on ARM comments Post-convergence comment pass over the three files that carry the added prose: collapse the multi-line rationales to the fact each one is there to state, and delete the ones that restate the line below them. 782 added comment lines to 569. Comments, docstrings and whitespace only, verified with comment_tools. * Stop the override file from undoing the CUDA pins, and two probe fixes uv's --overrides replace a requirement's version even when it is named directly on the command line: verified on 0.10.7, an override of packaging>=20 beat a CLI packaging==24.0. The generated file carries torch>=2.4 and torchvision>=0.19, so it discarded the exact CUDA pins the probe selected and best-match then took PyPI's newer CPU wheel. Build the value for that one command without the trio, passing every other file through, and restore it in a finally so the later unsloth resolve still gets the drop list. nvidia-smi failing to answer is not evidence the GPU is gone. A transient failure during a direct update dropped windows-arm64-cuda from the expected kinds, deleted the working llama.cpp tree and installed the CPU bundle. Accept a win_arm64 venv on a persisted WoA CUDA index as evidence too; only NVIDIA's own channels are ever persisted, so a cpu pin cannot claim it. The public-index wheel table is only availability if the resolve looks at PyPI. Offline, or pointed at an exclusive corporate index, unblocking librosa dropped the skip and then failed the extras pass on an unavailable numba chain. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Take a wheel from PyPI when PyPI builds it, not from the wheelhouse The Windows on ARM wheelhouse exists for distributions PyPI has no win_arm64 build of. regex is not one of them: PyPI has published win_arm64 regex since 2025.7.29, and the wheelhouse copy is the same version. The staging directory is first in UV_FIND_LINKS, so ours won the tie and the venv got our binary of a wheel the project releases itself. Nothing needed it there either: regex is in no skip list, no drop list and no constraint, so it unblocked nothing. Staging now asks PyPI first and skips the copy when PyPI publishes that project at or above the version being staged. Version-aware in both directions, because the reverse is a downgrade: an upstream release BEHIND the wheelhouse leaves ours in place. The probe is PyPI-only on purpose. Test-WoaWheelAvailable falls back to the wheelhouse, and a wheel is by definition present in the wheelhouse it is being staged from, so reusing it would answer $true every time and the guard would never fire. Its PyPI leg became Test-WoaPyPIWheel and it now calls that, so there is still one place that knows how to read the simple index. Tag matching is relaxed for this caller alone. hf_transfer ships cp38-abi3 and sqlite_vec ships py3-none, both of which import on cp313; the exact-tag test would call them foreign and go on shipping ours the day upstream publishes one. Free-threaded venvs keep the exact test, which is what it was written for. A wheel this venv could not have used is never judged: a cp312 wheel is not made redundant by a cp313 wheel. pyarrow keeps its own path. Get-WoaPyarrowSource already probes PyPI before the wheelhouse and reports which one it used, which is why the generic loop skips it. Two comments were wrong and are corrected. pyarrow has never published a win_arm64 wheel at any version, so the constraints floor is the floor of the wheels we stage rather than of anything upstream ships, and Get-WoaPyarrowSource checks an explicitly supplied UNSLOTH_PYARROW_WHEEL before PyPI, not after. * Fix uninstall.ps1 Split-Path calls that throw on Windows PowerShell 5.1 Split-Path puts -LiteralPath in its own parameter set, which carries only -Resolve and -Credential. -Parent belongs to the -Path set, so naming both resolves no parameter set and the call throws AmbiguousParameterSet. It throws at runtime rather than at parse time, which is how eight of these shipped in scripts/uninstall.ps1. Two of them sat outside a try/catch, so a real uninstall printed a red error and left the derived path null: the legacy sibling build at <parent>\stable-diffusion.cpp was never removed, and the empty path then tripped the deny list into "refusing to remove unsafe path:" with nothing after the colon. The other six were swallowed by their surrounding try/catch and failed silently. That left _RootFromConf returning nothing, so studio.conf-based root discovery never worked, and it disabled the parent-of-USERPROFILE rule in _IsUnsafeRoot, so C:\Users read as a safe recursive-delete target. Drop -Parent rather than switching to -Path. -LiteralPath on its own already returns the parent, and -Path globs, so an install root containing [ ] would be read as a wildcard. Verified on this box under Windows PowerShell 5.1.28000.2704 against a real env-mode install: the root, its studio.db, auth, outputs and exports all go, an Unsloth-owned sibling stable-diffusion.cpp now goes with them, and an unowned checkout at the same path is still kept. The new test is static because CI has no Windows PowerShell 5.1 to run the scripts under. It covers install.ps1 and studio/setup.ps1 too, which are already clean. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Give the uninstaller's remove retries a long enough budget Uninstalling a Studio that had actually been used left the whole install tree behind. The stop pass kills the server, but torch inductor's compile workers keep handles on the .py files they wrote under <root>\TORCHINDUCTOR_CACHE_DIR for several seconds after that. Those are plain data handles on files the worker never loaded as a module, so neither the ExecutablePath pass nor the loaded-module pass in _StopProcessesLockingRoots has a process to kill, and the only thing left to do is wait. _RemovePath waited 700ms three times, so it gave up after about 2.1s and reported 'could not remove', leaving the tree and studio.db on disk with a summary telling the user to delete them by hand. Replace the flat sleep with an escalating backoff that tops out near 20s, which covers the teardown with room to spare and still bounds the run. * Say that a Windows on ARM desktop build needs clang Building studio/src-tauri for aarch64-pc-windows-msvc with only MSVC BuildTools installed fails with ring@0.17.14: failed to find tool "clang": program not found because ring and aws-lc-sys assemble their aarch64 sources through cc-rs, which calls clang rather than cl.exe on that target. The error names the crate rather than the missing toolchain, and the prerequisite was written down nowhere: not in the workflows, not in CONTRIBUTING.md, not in the manifest. Recorded in build.rs, which is the file whose job this is and the one a reader reaches for when the build fails. No behaviour change. Not a hypothetical: the desktop app was built and launched on a Windows ARM64 box for this branch, and this was the only thing standing in the way. It builds and runs natively there once clang is present, PE machine 0xAA64, window up and the frontend rendering off tauri.localhost. * Fix the two CI reds: a dead import and a pid-file race Import hoist lint: run_studio_gpu.py still imported count_listed_pids after the listing helpers grew up around it. nvidia_compute_apps_listing now returns the pids themselves via listed_pids, because a MIXED listing needs names rather than a count, so the count import had no caller left and only survived in a comment. Dropped both. count_listed_pids itself stays: gpu_assert still exports it, its tests still cover it, and listed_pids documents itself against it. Tauri rust tests: stop_update_kills_descendants_after_the_group_leader_exits panicked with ParseIntError { kind: Empty }. The test child runs `echo $! > "$1"`, and the redirection creates that file before writing to it, so waiting on is_file() can win the race and read an empty string. It is a real flake rather than anything about this branch, and it does not reproduce reliably: the same test passed on main minutes earlier. It surfaced here only because the Tauri workflow is path filtered on studio/src-tauri and the clang note is the first change this branch makes there, so this was its first run. Wait for a pid that parses instead of for a path that exists, over the same kind of budget. A child that never writes one now fails with a sentence rather than an unwrap panic. * Close three holes in the PyPI-first wheelhouse guard Review of the guard I added in 7fe37c6 turned up three ways it could do harm, one of them worse than the duplication it was written to fix. The bad one: skipping a wheel made it stop counting as available. $WoaWheelNames is rebuilt by scanning the staging directory, and a $WoaDropCandidates name that is missing from it gets emitted as `name ; platform_machine == "AMD64"`, which EXCLUDES the package on ARM64. So the day PyPI published a win_arm64 hf_transfer or brotli, the guard would have turned "installed from our wheelhouse" into "not installed at all" -- the opposite of preferring upstream, firing on exactly the event the guard exists for. A skipped wheel is now recorded in $script:WoaPyPIProvided with the version PyPI matched, and that is merged into $WoaWheelNames, so availability reads the same whether the wheel came from the wheelhouse or from PyPI. The xformers floor still applies to the PyPI version, so a release below it keeps the drop. abi3 was treated as universally compatible. It is forward compatible from the version it was built against, so a cp314-abi3 wheel does not import on cp313, and calling ours redundant against one would have left the package uninstallable. Test-WoaWheelTagsUsable now applies the same minimum rule the staging scan already used for abi3 and py3N, and free-threaded venvs keep the exact-tag test, which is what it was written for. PyPI publishing a wheel is only availability if the resolve will look at PyPI. UV_INDEX_URL and UV_DEFAULT_INDEX REPLACE the default index, and offline flags remove it, so on a corporate mirror the guard was dropping our wheel and leaving it obtainable from nowhere. Test-WoaResolveReachesPyPI mirrors _public_pypi_is_reachable in studio/install_python_stack.py, which reasons about the same five variables for the same reason. An unreachable pypi.org already failed safe; reachable but unused did not. The new tests execute the helpers under pwsh instead of reading install.ps1 as text, because two of these three were wired correctly and answered wrongly, which is precisely what a source-shape test cannot see. Hermetic: the live PyPI leg is exercised on hardware, not in CI. * Don't recursively delete ~/.unsloth/studio unless it is ours The custom-root branch refuses a path that carries none of the four ownership sentinels ("refusing to remove non-Unsloth path"), and then the default root a few lines down is removed with no check at all. So a bare run, the documented one-liner with no UNSLOTH_STUDIO_HOME set, recursively deletes whatever happens to sit at ~/.unsloth/studio, and the empty-dir prune takes ~/.unsloth with it. That path is not far-fetched to own. On a machine where Studio was only ever installed in env mode, ~/.unsloth/studio is an ordinary place for someone to keep notes or a checkout, and nothing warns them that running the uninstaller for their custom install will also clear it out. Hit it here for real: a run against a disposable env-mode install removed an unrelated directory that held only a log file, and reported nothing unusual while doing it. Gate it on _IsStudioRoot, the same test the custom root gets. An interrupted install that lost every sentinel is now left on disk with a message instead of removed, which is the failure direction that does not destroy data. Both scripts had it; uninstall.sh line 588 was the same bare call. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pair stable companions by release line, honour uv config files, and clean up the override swap Stable torch, torchvision and torchaudio releases all carry an empty dev stamp, so the CUDA local tag alone paired a companion from any release the index still served and the exact-pin install then asked for a pair that does not exist. Test-WoaWheelPairsWithTorch now applies the release mapping for stable builds: torchvision 0.(M+15) pairs with torch 2.M and torchaudio agrees on major.minor. Nightlies still pair by dev stamp. The setup.ps1 parity copy is identical. Only environment variables were consulted before assuming a resolve reaches public PyPI. uv also discovers uv.toml and pyproject [tool.uv] from the current directory and its parents, then the user and system files, so a no-index or an exclusive default-index set there still answered "PyPI". Both the PowerShell probe (Test-WoaResolveReachesPyPI, now also gating the pyarrow source probe) and the Python one (_public_pypi_is_reachable) read that configuration, honour UV_NO_CONFIG and UV_CONFIG_FILE, let an index in the environment decide alone, and resolve doubt to "not PyPI". The filtered torch-step override was written with GetTempFileName(), which follows %TEMP% and so could produce a quoted, uv-rejected path, and the copies were never deleted. New-WoaTorchStepOverrideValue now writes under the already validated WoA directory, passes every path through Get-UvSafePath, and reports the copies so the caller removes them in finally. setup.ps1 never performed this swap around its own CUDA trio install, so the restored overrides.txt floors undid its exact pins too; it now swaps the same way. On the manifest fast path the dependency block is skipped, so the llama.cpp NVIDIA-evidence check read an unset index and a transient nvidia-smi failure deleted a working CUDA bundle. The check now reads the pin, manifest or marker itself when the pass did not run. sqlite-vec joins the explicit ARM64 optional install map: its requirement is marker-excluded on win_arm64 everywhere the torch path applies, so a staged wheel was ignored. The Kaggle harness stops the Studio server before the CLI baseline regardless of skip_ui, so its own llama-server is not counted as a co-tenant. Tests cover each change and the free-threaded probe test inlines the helpers Test-WoaWheelAvailable now routes through. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Decide "public PyPI" by host, not by substring An exclusive index whose URL merely contains the name, such as https://pypi.org.corp.example/simple or .../api/pypi/pypi.org/simple, passed the substring test in both the PowerShell and the Python reachability probes, so the pyarrow route preferred a wheel the resolve could never fetch. Both now parse the URL and require the host to be exactly pypi.org. TestPyPI, which does not carry these packages, is therefore not public PyPI either. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rank uv's [pip] index settings above the top-level ones, and drop a redundant wheel's managed copy uv pip gives [pip].no-index and [pip].index-url precedence over their top-level twins, and an [[index]] entry with default = true beats [pip].index-url (verified on uv 0.10.7 with dry-run resolves). Both readers assigned whichever value came first in the file, so a uv.toml that set the top-level key ahead of the [pip] one could report public PyPI as reachable while the real resolve went to an exclusive index. Each value is now collected and ranked at the end, in both install.ps1 and install_python_stack.py. A wheelhouse wheel that PyPI now publishes was skipped at staging time but a copy already in the managed directory stayed there, and that directory is first in UV_FIND_LINKS, so it still won the tie: the offline-cache mode points the wheelhouse at that very directory, and a URL wheelhouse leaves the copy an earlier install staged. The managed copy is removed in both modes; an external wheelhouse file is never touched. test_woa_wheelhouse_guard_behaviour inlines every helper the reachability probe now calls, so a missing one no longer answers True from inside a non-terminating error. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Run the two uninstaller tests that no workflow ran cross-platform-parity-ci already triggered on uninstall*.ps1 and scripts/uninstall*.ps1, so editing the uninstaller queued a run that then asserted nothing about it. test_uninstall_prebuilt_parity.ps1 and test_uninstall_reparse_stop_roots.ps1 were referenced by no workflow at all. Both are hermetic -- they parse scripts/uninstall.ps1 with the AST and execute only the helper under test -- so they cost a few seconds on a row that is already running. Worth being precise about what this does not buy, because the gap is wider than the wiring. Neither test executes the uninstaller BODY, and neither does any other: every uninstaller test we have asserts source shape. A runtime parameter-binding fault is therefore invisible to all of them, which is exactly how `Split-Path -LiteralPath $p -Parent` reached users. It binds to no parameter set and throws on every uninstall, on 5.1 and on 7 alike, but it parses, so the board stayed green. The reparse test also could not run unprivileged. It created its home with New-Item -ItemType SymbolicLink, which needs elevation or Developer Mode, so it died on NewItemSymbolicLinkElevationRequired before the first subtree check. It now runs over both reparse kinds. A junction needs no privilege and is the shape a user is likelier to have -- mklink /J is what gets reached for when moving a studio home to another volume -- and _ManagedPathsUnderReparseTargets reads nothing but .Target, which both kinds supply rooted. A kind that cannot be created is reported as SKIP rather than passed over, and a final check fails the run if neither kind was exercised, so an environment that can make no links cannot report a clean pass having asserted nothing. * Read a uv config the way TOML is actually written Test-WoaResolveReachesPyPI decides whether PyPI is in the resolve at all, and a wrong "yes" is the expensive answer: it lets the redundancy guard delete the wheelhouse copy of a wheel nothing can then fetch, and makes Get-WoaPyarrowSource answer "pypi" and skip a usable local pyarrow, which gates the whole native path. The subset parser behind it answered "yes" for six shapes of config that had replaced or disabled PyPI. All six failed in that direction. Held against tomllib over 41 fixtures, the parser and a correct reading now agree everywhere; before this they disagreed on exactly these six: no-index = true# offline lab a comment needs no space in front index-url = "https://corp/simple"#corp same, on the value side "index-url" = "https://corp/simple" a quoted key is the same key pip.no-index = true a dotted key is [pip]'s no-index pip.index-url = "https://corp/simple" same [tool.uv] + pip.no-index = true same, in pyproject.toml The comment cut was `(^|\s)#`, which misses a `#` with nothing before it. That cannot be widened to a bare `#`, though, because the same line may carry one inside a string: `index-url = "https://host/simple#frag"` is a fragment, not a comment, and the old pattern got that right only by accident. Both halves need a scan that knows where the quotes are, so Remove-WoaTomlComment does that, and Split-WoaTomlKey does the matching job for keys -- unquoting them and splitting on dots, so `pip.no-index` is routed into the [pip] scope instead of being compared, whole, against the literal `no-index` and never matching. The two new helpers are added to the behaviour test's HELPERS tuple in the same commit. Leaving them out would have been the bug that tuple exists to prevent: a helper the prelude does not extract is a command-not-found inside Test-WoaResolveReachesPyPI, which aborts the statement, skips the branch, and answers True. Coverage was the reason these survived. The PowerShell side had no config-file fixtures at all -- test_pypi_counts_only_when_the_resolve_would_reach_it drives environment variables only -- while the Python twin has had file fixtures and a real tomllib all along. Sixteen cases now cover both directions of the scan, the [pip] precedence both ways, a lookalike host, and the shapes that must keep answering True so this cannot be mistaken for a blanket "not reachable". * Put the uv user config where uv would look for it on Windows The user-file row of test_a_configured_exclusive_source_is_not_pypi failed on Windows and always would have. The fixture deletes APPDATA and then points only XDG_CONFIG_HOME at a directory, but _uv_config_files reads %APPDATA%\uv\uv.toml on Windows and $XDG_CONFIG_HOME/uv/uv.toml elsewhere, so on Windows the file was written somewhere uv never looks, nothing was discovered, and the assertion that a user-level no-index takes PyPI out of the resolve could not hold. It stayed green because tests/python runs on the Linux row, where XDG is the right answer. Both names now point at one directory, so the case is real on every platform. test_project_outranks_user_for_a_scalar needed the same treatment for a sharper reason: it wrote the user file to the old xdg path, which after this change is not a config location anywhere, so it would have passed on the project file alone while asserting nothing about precedence. It now writes to the same place and first asserts the file is one _uv_config_files discovers, because a precedence test whose lower-ranked file was never found is a test that cannot fail. * Don't strand an old install behind the new ownership gate bcc0ffd stopped the uninstaller recursively deleting ~/.unsloth/studio unless one of install.ps1's sentinels says the directory is ours. That was right, and the list it used was too short. On Windows share\studio.conf is never written -- only install.sh writes it -- so the three sentinels that actually decide a Windows root all postdate the bin\ shim dir and the unsloth_studio rename. An install from before those has neither: the venv lived at <root>\.venv, the venv's Scripts dir went on PATH in place of a shim dir, and .unsloth-studio-owned did not exist yet. install.ps1 still migrates exactly that layout, at "found legacy Unsloth environment", so it is not hypothetical. Uninstalling one refused, printed the user's own install as a non-Unsloth path, and left the tree and studio.db on disk. tests/sh/test_uninstall_arg_guard.sh has been asserting this all along -- its fixture is a pre-marker unsloth_studio venv and "no arguments removes the install" fails at eda3801. So accept two more things Unsloth put there: the owner marker inside the legacy .venv, and either venv dir carrying the unsloth console script pip generates for the CLI. That is the property the gate is testing. A bare .venv, a venv merely named unsloth_studio and a hand-made studio directory still have none of them and are still refused. (cherry picked from commit 05c6cdf9a23273bb7a5e9fe4900b59dbe6cda3a1) * Recognise the abi3 pyarrow upstream is going to publish pyarrow is the mandatory gate for the whole native path: staging turns the chosen wheel into an exact `pyarrow==` override, so whichever file we select is what the resolve gets. Today that is our own 24.0.0.dev260, a pre-release, while upstream stable is 25.0.1 -- acceptable only because PyPI publishes no win_arm64 pyarrow at all yet. It will. apache/arrow#48539 adds the wheel and is held behind apache/arrow#50398, whose plan is an abi3 floor of 3.11, so the first one on PyPI will be named pyarrow-<v>-cp311-abi3-win_arm64.whl. Every pyarrow candidate went through Test-WoaPyarrowWheelUsable, which gated on Test-WoaWheelTags -- exact PEP 425 fields -- and an exact test answers false for cp311-abi3 on every interpreter we build. The day upstream shipped, we would have gone on selecting our own dev build and pinning it, and nothing anywhere would have said so. Silent, permanent, and pointing the wrong way for a PR whose whole aim is that a new user on this hardware installs from upstream wheels. Test-WoaWheelTagsUsable is the predicate that already answers this correctly, and it keeps both properties exactness was buying: an abi3 wheel built against a NEWER interpreter than the venv is still refused, because abi3 reaches forward from its build version and not backward, and abi3 is refused outright on free-threaded builds, which have no stable ABI (CPython #111506). One function changed, because all six call sites already funnel through it -- the supplied wheel, the PyPI scan, the wheelhouse directory and index, and the two in staging. test_every_pyarrow_candidate_goes_through_the_floor counts them and still reads 7. Four cases cover the shape upstream will ship and the two directions it must not loosen, and a fifth asserts the free-threaded refusal on its own. The preludes that lift Test-WoaPyarrowWheelUsable out of install.ps1 gained Test-WoaWheelTagsUsable in the same commit, for the reason that keeps recurring in this file: a helper the prelude does not lift is a command-not-found, which aborts the statement rather than answering, and the caller then reports success. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: make the purge and merge tests speak the host's path separator These tests are gated on pwsh being present, not on the platform, so on a Windows box with PowerShell installed they run and 11 of them fail. None of it is the product: the purge builds its owned prefix with Join-Path, which uses the host separator, and the tests hand it a hardcoded POSIX home, so "C:\...\unsloth\woa" was compared against ".../unsloth/woa" and never matched. In a real run both sides come from the same Join-Path, so the purge is correct either way. Build the fake home and the entries the way the host does. The same cases now cover the separator the machine actually uses, and on POSIX the values are byte-identical to before. The two merge tests failed for a second reason: their fakepython stub is a shebang script, which Windows cannot execute, so the merge got no pins back and the failure was an empty path rather than anything about rebasing. Write a .cmd stub there instead; PowerShell's & runs it and the stub ignores -c regardless. 473 passed, 0 skipped on win-arm64 with PowerShell 5.1. (cherry picked from commit 8dc13a7692c325408a9cb56eb94b612e71f45a2f) * Test the Windows on ARM install path on Windows on ARM Everything this repo knows about win_arm64 has been tested only on x64 runners: ubuntu-latest and windows-latest. That is the wrong machine for all of it, and it is why several real bugs sat behind a green board. Three of them could not have failed anywhere else. The purge builds its owned prefix with Join-Path, which uses the HOST separator, so a test hardcoding a POSIX home compared "C:\...\woa" against ".../woa" and never matched -- eleven tests passed on Linux and failed on Windows for a reason that had nothing to do with the purge. The uv config parser, which exists because Windows PowerShell 5.1 ships no TOML reader, read six shapes of config wrongly, every one of them answering "PyPI is reachable" for a config that had taken PyPI out of the resolve. And the uninstaller's ownership gate refused genuinely old installs, leaving the user's tree and studio.db on disk while calling their own install a non-Unsloth path. windows-11-arm has been free for public repositories since 2025-08-07, and this leg is cheap because none of this needs a GPU, CUDA or a compiler: it is arithmetic over wheel tags, path handling and PowerShell parsing. A full install or a Tauri build needs much more and is deliberately not attempted here. Two rows, because the shells are not interchangeable and each is what something real uses. pwsh 7 is what CI has always run. Windows PowerShell 5.1 is what the CLI launches setup.ps1 with, and it is the only shell on a stock Windows box, so a developer on this hardware runs 5.1 whether or not anyone planned for it. cross-platform-parity-ci already keeps a 5.1 row for #8335 on the same reasoning. The suites locate their shell with shutil.which("pwsh"), which 5.1 does not provide, so that row puts a copy of powershell.exe on PATH under that name -- without it the row would SKIP everything and report green having run nothing. A copy rather than a .cmd shim, because CreateProcess will not launch a .cmd without a shell, and Python is what starts them. Both architectures are asserted rather than assumed. A leg that silently ran on x64 would be worse than no leg, since it would report green for precisely the platform it does not cover, and setup-python needs an explicit architecture: arm64 because the image ships an x64 Python that runs under emulation and reports win-amd64 from sysconfig -- half of these assertions are about wheel tags, so an emulated interpreter would quietly test the wrong platform. A third check pins that the shell resolved for the row is really the major version that row is for. Dry run of this exact selection on win_arm64 under PowerShell 5.1: 671 passed, 0 skipped. One note recorded in the workflow for later: GitHub moves the windows-11-arm image to Visual Studio 2026 between 2026-09-21 and 2026-09-30. This job invokes no compiler so it cannot be affected, but a leg that builds studio/src-tauri would be, and would also need clang on PATH for ring and aws-lc-sys. * Only ask for a junction where junctions exist My own regression, caught by the parity job the same commit wired these tests into: parity (ubuntu-latest) went red on FAIL Junction : a linked root yields the sd.cpp tree under its physical target FAIL Junction : ... and the venv under it A junction is a Windows-only reparse type, and New-Item -ItemType Junction does not throw on Linux pwsh. It quietly produces a plain directory with no .Target, so the SKIP guard -- which only catches a failure -- never fired, and the row ran and failed every positive assertion. The negative ones passed for the wrong reason: _ManagedPathsUnderReparseTargets returns nothing for a path with no target, which is also what "the bare physical target is NOT in scope" wants. Choose the kinds by platform instead of by catching an exception that never comes, and treat $IsWindows being undefined as Windows, since that is Windows PowerShell 5.1 and 5.1 runs nowhere else. Also verify the link after creating it. Created is not the same as usable: the helper reads nothing but .Target, so a link without one cannot exercise anything, and reporting that as a failure blames the helper for the filesystem's answer. A kind that produces no target is now a SKIP, and the existing check that at least one kind ran still fails a run that exercised nothing. Windows unprivileged: Junction all pass, SymbolicLink SKIP (needs elevation), overall pass. Linux keeps the SymbolicLink row, which passed on that job already. * Assert the uninstaller's ownership gate on POSIX too The gate that decides whether ~/.unsloth/studio is recursively deleted exists in both uninstallers, and when it regressed it regressed in both. Verified on real Linux (aarch64, bash 5.2.21): against scripts/uninstall.sh before the fix, three layouts Unsloth itself created were refused -- FAIL: legacy .venv carrying the owner marker (got foreign, want own) FAIL: pre-marker unsloth_studio venv (got foreign, want own) FAIL: pre-marker legacy .venv (got foreign, want own) -- and against the current one all thirteen checks pass. So this was never a Windows problem: a Linux or macOS user with an install predating the unsloth_studio rename was told their own install was a non-Unsloth path and left with the tree and studio.db on disk. There was no POSIX test for it. tests/studio/test_uninstall_legacy_layout_gate.ps1 covers the Windows side; this is its twin, following the extract-via-sed pattern of test_uninstall_shared_icon.sh so the uninstaller body, which kills processes and deletes trees, is never executed. Both directions are asserted, because a gate is only as good as what it refuses. The negative cases include a venv that merely carries an unrelated console script, so accepting bin/unsloth cannot quietly become "any venv with anything in it", and a bare .venv, which is the case the gate was added for and which stays refused because it is genuinely indistinguishable from a user's own project venv. Wired into the parity workflow's Linux row. Worth noting how it went unnoticed: the shell uninstaller tests are named individually in workflows and four of them are named in none, while consolidated-tests-ci excludes tests/sh outright. A test nobody runs is indistinguishable from a test that passes. * Cut the comment volume in the Windows on ARM installer * Cut the comment volume in the studio setup script * Cut the comment volume in the python installers and the CI workflows * Cut the comment volume in the uninstaller and its tests * Cut the comment volume in the tests, requirements and tauri sources * Second comment reduction sweep across the Windows on ARM changes * Third comment reduction sweep over the Windows on ARM changes * Rebase editable and bare local-directory override lines too New-UnslothTorchOverridesFile used to write its merge beside the caller's UV_OVERRIDE file when there was exactly one, so relative references in it kept resolving. The merge moved to %TEMP% with per-line rebasing, which also fixes the multi-directory case that was always broken, but Resolve-WoaOverrideLine did not know -e/--editable or a bare ./pkg directory requirement, so those two forms started pointing at nothing. Reached every Windows host with UV_OVERRIDE set, not only Windows on ARM. * Fold 64 copies of the PowerShell invocation into one helper Every test that runs a lifted PowerShell function spelled out the same five-line subprocess.run and the same `assert done.returncode == 0, done.stderr`, 64 times. _ps and _ps_ok replace them, and the timeout stays a parameter because five sites need 180 and one needs 60. 369 lines out of the file, no test added or removed: 502 collected before and after. Mechanically substituted, then checked the tests still catch what they are for, by breaking install.ps1 three ways and comparing which tests fail. The failing node ID sets are identical before and after (27 for a Split-WoaTomlKey that never splits, 8 for a Test-WoaWheelTagsUsable that always says yes), so the tests still execute PowerShell rather than passing vacuously. * Test the default wheelhouse URL, which nothing tested Found by mutation: changing the default to a working but wrong host left all 1492 tests passing. It is load bearing twice over. It is the only source for the pyarrow that gates the native path, so a typo silently sends every Windows on ARM host back to the emulated x64 stack with no error anywhere, and it is fetched over the network, so a wrong host is a wrong download. Pins the literal, that it is an https resolve/main URL under our own org rather than a repo page that would serve HTML the staging code saves as a .whl, and runs the assignment for eight override shapes: unset, empty, a mirror, one and several trailing slashes, surrounding whitespace, and a local directory whose trailing backslash TrimEnd('/') must not touch. The same mutation now fails 3 tests instead of 0. * Keep windows-vulkan in the ARM64 kind lists main just widened main added windows-vulkan to every branch of the llama.cpp kind check, because any x64 Windows host can land on a Vulkan bundle and a guard without it deletes a working install on every setup run. That reasoning holds for the ARM64 arms this branch adds, so it goes in $_nvidiaKinds rather than being appended at the one call site: the opted-out arm needs it as much as the CUDA one. Test expectations and the source-slice anchors move with it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move the uninstaller fixes out to their own PR The ownership gate, the ~/.unsloth/studio guard, the Split-Path call that throws and the remove-retry budget are not about Windows on ARM, and two of them change behaviour on Linux and macOS. Reviewing them as a footnote to a Windows installer feature was the wrong shape. They are now #10471, against main, with the parity CI wiring that runs them. These files go back to main exactly. Also drops this workflow's references to test_uninstall_legacy_layout_gate.ps1, which now lives in that PR; the runner loop already skips a test file it cannot find, so it can be listed again once #10471 lands. * Move the Kaggle GPU harness fix out to its own PR An all-[N/A] compute-apps listing means nvidia-smi cannot attribute memory, not that the card is idle, which is how WDDM and unified-memory parts report. That is a GPU attribution fix in the test harness and touches no installer code, so it reviews better on its own: #10472. These files go back to main exactly. * Read the two installer scripts once, not 117 times 62 tests re-read install.ps1 and 55 re-read setup.ps1, each on its own line, and none of them mutate what they get back. Two module constants replace all of it, and _ps_function serves from them rather than opening the file again on each of its 60 calls. The wheelhouse-tag tests get the same treatment for sys.version_info and the cp tag derived from it, restated in nearly every test. 130 lines out, no test added or removed: 1502 collected before and after. Substituted on NAME tokens through tokenize, never on raw text, so an occurrence of "text" inside a PowerShell string literal could not be caught by it. Verified by mutation, not by the suite going green: breaking install.ps1 three ways fails exactly the same node IDs as before, 3 for the default wheelhouse URL, 27 for a Split-WoaTomlKey that never splits, 8 for a Test-WoaWheelTagsUsable that always says yes. Formatted with scripts/run_ruff_format.py and confirmed idempotent. * Lower the win_arm64 floors to the first release that actually has a wheel Three floors were set one release too high, and each one excludes a wheel that exists. scikit-learn is the one that bites. 1.8.0 publishes six win_arm64 wheels including cp313-cp313t; 1.9.0 publishes five and dropped the free-threaded one. So >=1.9.0 leaves a free-threaded 3.13 with no binary candidate and an sdist to compile, which is what the ARM64 rows exist to avoid. The comment two lines up already said "from 1.8 only" and contradicted its own pin. av first ships win_arm64 at 17.0.0 (cp311-abi3, plus a cp314t), not 17.1. No release has a 3.13t wheel, so that half of the comment now says so. bitsandbytes is a comment fix only: the wheel starts at 0.50.0, and the pin was already below both. tests/studio/install/test_diffusers_pin.py pins all three against the index rather than against an audit, and rejects a second ARM64 row for the same project so a future edit cannot leave two floors disagreeing. It fails on each of the three old floors. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Translate every spelling of --prerelease for the pip fallback, not one Remove-UvOnlyResolverFlags handled --prerelease two ways, and both were wrong away from the single spelling the two call sites use. The space-separated form dropped the flag and its value and emitted nothing, so a pip fallback given `--prerelease allow` resolved without prereleases and could not see the torch build the run had just asked for. uv accepts that form everywhere it accepts the = form. The = form emitted --pre for any value. Of uv's five, only allow means what pip's --pre means: disallow is pip's default and if-necessary, explicit and if-necessary-or-explicit have no pip flag at all. So --prerelease=disallow became --pre and inverted the caller's intent. Both are latent today, since install.ps1 and setup.ps1 both pass --prerelease=allow, which is the one case the old code got right. The helper takes a caller-supplied argument list, so the grammar it accepts should be uv's. The test walks ten argument lists through the real function: both spellings of both flags, four --prerelease values, a bare value that must not be swallowed by a flag it does not belong to, and the two flags combined. Four fail on the old implementation. * Stop the llama.cpp gate deleting what the selector just installed on ARM64 setup.ps1 deletes an existing llama.cpp tree whose recorded install_kind is not in a set it computes for the host, then reinstalls via install_llama_prebuilt.py. Nothing made those two agree, and where they do not the disagreement is permanent: every update deletes the tree and refetches the identical bundle, forever, on a machine that is working. Walking the product of what either side branches on finds two disagreements on main, both only reachable from Windows ARM64, which is why they lasted: arm64 + nvidia selector installs windows-arm64, gate expected windows-cuda arm64 + rocm selector installs windows-arm64, gate expected windows-rocm The first is every Windows ARM64 machine with an NVIDIA GPU, and this branch already fixed it by widening the gate to the ARM64 kinds. The second is fixed here. No ROCm bundle exists for Windows ARM64 at all: upstream ships hip-radeon-x64 and we publish none, so the selector falls through to the ARM64 CPU bundle while the gate went on expecting windows-rocm or windows-hip. The two ROCm kinds stay in the set for the day one is published. tests/studio/install/test_llama_kind_gate_matches_selector.py asserts the only relationship that matters between the two sets, over the twelve host combinations, rather than the text of either. Offline: direct_upstream_release_plan takes a release dict, and the gate's own block is lifted out of setup.ps1 with its inputs replaced. It fails 3 checks against the gate before this commit. While confirming this, the x64-fallback churn loop I had noted earlier turned out not to exist. An emulated x64 Python on an ARM64 box reports AMD64, so that venv is an x64 host to the selector, it installs windows-cuda, and the gate expects windows-cuda. All four x64 rows agree, before and after. * Fold the repeated script and source boilerplate in the WoA persistence tests Four whole installer and stack files were still being re-read per test; they are read once now. The 62 copies of the join-a-list-of-lines idiom and the 48 run-then-take-the-last-line pairs become _script and _ps_last, and the three copies of the parity normaliser become one _ps_copies helper. * Give the wheelhouse tag tests one cache fixture and one read of each source The find-links listing was cleared by hand in eleven try/finally blocks and a dozen bookend pairs; an autouse fixture now empties it before and after every test, and the clears that sit between two states inside a test stay. install.ps1, install_python_stack.py and extras.txt are read once, and the wheelhouse builder becomes a fixture rather than a helper taking three fixtures per call. * Read each source file once in the marker and guard behaviour tests install_python_stack.py was exec'd twice in the marker tests and install.ps1 was re-read and re-scanned for ten helper bodies on every one of the guard tests' thirty-five pwsh calls. * Use the hoisted source constants directly rather than aliasing them Sixteen tests opened with a one line rebinding of a module constant to a shorter local name; the constant is now named at the point of use. * Let the prerelease gate test tell producing the flag from comparing to it Remove-UvOnlyResolverFlags now names --prerelease=allow to translate it into pip's --pre. That is not a call site adding the flag, it runs after the gate has already allowed it, so the array-shape rule the gate test enforces does not apply to it. * Verify the Windows ARM64 CUDA bundle against GitHub's asset digest This was the one archive the installer would install unchecked. Every other prebuilt goes through apply_approved_hashes, which drops an attempt our own checksum manifest does not cover. The ARM64 CUDA branch could not: we publish no windows-arm64-cuda artifact yet, so nothing covers upstream's zip, and refusing outright would mean no CUDA llama.cpp on this hardware at all. So it logged that it was installing without a hash, and did. GitHub's release API now reports a per-asset digest. Checked against ggml-org/llama.cpp b10853: 27 of 27 assets carry one, including llama-b10853-bin-win-cuda-13.4-arm64.zip and the cudart archive it pairs with. That is weaker than our manifest, which we compute ourselves rather than read from the same host that serves the bytes, and the comment says so. It still pins the download to what the API listed, which is enough for this branch to stop being the exception. An asset GitHub states no digest for is now refused and the install falls through to the ARM64 CPU bundle, so no path installs an unverified archive. Three things kept deliberately narrow: * A separate github_release_asset_digests rather than a second return value from github_release_assets. That one is mocked in a dozen suites and used on every platform; this is read on one branch. * Any failure fetching the digests is an empty mapping, which that branch reads as refuse. A rate limit costs the CUDA bundle, not the install, and cannot be mistaken for "no digest needed". * release_asset_digests requires the sha256: prefix rather than stripping it for convenience. normalize_sha256_digest accepts a bare 64-hex string, and an unprefixed digest is one whose algorithm GitHub did not state. tests/studio/install/test_arm64_cuda_digest_verification.py covers the digest parser over seven rejected shapes, the drop and unpair rules, the fail-closed fetcher, and asserts against the source that every return in the resolver is hash gated. That last one fails on the branch before this commit, on the literal `return upstream_arm64_cuda`. 143 failures before and after across the four llama selection suites, identical sets, so nothing here changes any other platform's routing. * Bound the early GPU probes, gate the wheel on the driver, and tighten the resolver and index checks install.ps1 - Test-WoaNvidiaPresent and the new Get-WoaDriverCudaVersion run nvidia-smi through the bounded runner. - A CUDA wheel whose major exceeds the driver's falls back to the x64 stack with a driver-update message. - The av probe is gated on the resolver reaching PyPI; public PyPI supplied as an extra index (env or uv config) keeps the probe in play. - A managed wheel is redundant only when PyPI has the exact version, and the managed scan lifts win_arm64 or any wheels only. - On an equal numeric release a final build outranks a .dev one; later stamps win among dev builds. - The exact CUDA trio pin ignores UV_EXCLUDE_NEWER for that one install, since the index carries no upload dates. - A pyarrow staging failure after the venv exists is a hard stop with the opt-out named. - Rebased file: references become file URIs, so spaces are encoded. studio/setup.ps1 - The merged override file is recorded, removed at the end of the run and on failure, and any stale copy is removed first. - WinArm64NoAudio follows the effective-index probe; retention pairs torchaudio with the new torch by both predicates. - Same tie-break, file URI and exclude-newer handling as the installer. studio/install_python_stack.py, install_manifest.py, install_llama_prebuilt.py - Extra indexes that are public PyPI count; the resident xformers check runs after a failed refresh. - The NVIDIA index is persisted with scheme and host normalised. - Legacy bundle ordering synthesises names for the arch being resolved and passes it through. .github/workflows/windows-arm64-ci.yml - The ARM job fails when a required test is reported as skipped. Tests cover each change, including a real Exit-SetupFailure run under UNSLOTH_TAURI_MODE. * Make a paired torchvision part of the native gate, and read a supplied wheel's name from its archive install.ps1 - An index qualifies for the native path only when it publishes a torchvision paired with its torch; otherwise the next candidate is tried, and with none pairing the x64 stack is kept with the reason logged. The vision pin is the version the gate accepted. - UNSLOTH_PYARROW_WHEEL saved under another name (.bin, no extension) has its wheel name read from the archive before the project and tag checks, as staging already does. studio/setup.ps1 - When the effective index pairs no torchvision with its newest torch, the fresh-shell probe keeps the installed torch and torchvision pair instead of pinning a torch whose torchvision would resolve against another build. Tests cover the gate order, the x64 fallback, the kept pair, and archives named .bin or without an extension. * Rank prereleases in PEP 440 order, probe a reselected interpreter, and put the caller's resolver variables back install.ps1 - Within one numeric release the wheel pick follows PEP 440 order in full: dev below a below b below rc below the final build, later numbers winning within a kind. Same in setup.ps1's copy. - After the re-probe flips native mode, the reselected interpreter is probed before it is taken; one with no stack of its own is declined and the accepted interpreter keeps its answer. - UV_OVERRIDE, UV_FIND_LINKS and PIP_FIND_LINKS are snapshotted before the first assignment and restored in the script-level finally, so an `irm | iex` session does not keep resolving with Studio's overrides and wheelhouse. studio/backend/plugins/data-designer-unstructured-seed - The plugin admits pandas 3, which constraints.txt installs on Windows on ARM; it only reads and writes parquet. Tests cover the ordering, the reselection, the restore, and the plugin range against the constraints. * Format the three files this branch owns with the ruff the hooks actually pin .pre-commit-config.yaml pins ruff v0.15.18. I had been running scripts/run_ruff_format.py against a local ruff 0.6.9, and the two disagree about where an assert's message goes: 0.6.9 assert ( cond ), "message" 0.15.18 assert cond, ( "message" ) So every local format pass wrote the older shape and pre-commit.ci rewrote it on push. This puts the three files that are ours at the pinned formatter's fixed point instead. Verified rather than assumed: with 0.15.18, running the wrapper twice leaves every file byte-identical, so the wrapper is idempotent and it was only the version that was wrong. Four more files still differ under 0.15.18 and are left alone, because they differ identically on origin/main and so are not this branch's drift: studio/prebuilt_core.py, tests/studio/install/test_cuda_repair.py, tests/studio/install/test_rdna1_unsupported_message_8529.py and tests/studio/install/test_windows_torch_flavor_invariant.py. 855 tests pass across the three. * Let the dependency index follow the resolver policy, widen the ARM CI filters, and survive an upstream listing failure install.ps1 and studio/setup.ps1 - torch's shared dependencies resolve from the index the caller's resolver policy names (environment, then uv config), from public PyPI when it names none, and from the find-links wheelhouse alone under no-index. Invoke-InstallCommand clears the inherited index settings whenever --default-index is passed, so the policy is restated on the command line. setup.ps1 carries the readers as parity copies. .github/workflows/windows-arm64-ci.yml - The path filters name pyproject.toml and the whole requirements tree the ARM tests read. studio/install_llama_prebuilt.py - A failure listing the upstream release assets on a Windows ARM64 NVIDIA host costs the CUDA bundle only; the planner falls through to the published ARM64 CPU bundle. Tests cover the policy readers in both scripts, the filters, and the fall-through. * Judge index reachability and the dependency index for the resolver that runs the pass uv reads UV_* and its configuration files and ignores PIP_*; pip reads PIP_* alone. Reading both sets together reported public PyPI reachable from a PIP_EXTRA_INDEX_URL that uv, the resolver in use, never consults, and then dropped the librosa and tensorboard skip on wheels the resolve could not fetch. - studio/install_python_stack.py: the probe branches on USE_UV; the uv path also honours UV_NO_INDEX. - install.ps1: the probe reads uv's policy only, since this script resolves with uv; Get-WoaDependencyIndexArgs takes the resolver and reads that resolver's variables, uv's configuration files for uv alone. - studio/setup.ps1: the parity copy, with the resolver it will use passed in. Tests cover the cross-resolver case in all three places and the pip fallback's own policy. * Keep markers out of rebased file references, clean the merged overrides on every exit, and stop on an unwritable wheel directory install.ps1 and studio/setup.ps1 - A PEP 508 marker on a `pkg @ file:...` override is kept aside while the path is rebased and re-attached after the URI. - setup.ps1 traps a terminating error after the resolver restore, removes the merged override file, and rethrows. install.ps1 - A wheel directory that cannot be created after the ARM64 venv exists is a stop with the reason, not a stand-down into an index with no win_arm64 wheel. The directory is verified after New-Item, which can return without creating it when a file is in the way. - The trio command names the staged wheelhouse with --find-links, since the inherited UV_FIND_LINKS is cleared for a pinned index; UV_NO_INDEX yields for that one command and is restored after it. - The two probe warnings that print an index URL redact its credentials. studio/backend/requirements/no-torch-runtime.txt - sqlite-vec carries the negative Windows ARM64 marker, as it does in studio.txt. Tests cover each change; the marker table claims the new row. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the five Windows-on-ARM tests actually run on Windows All five assert Windows-on-ARM behaviour and none of them could execute their own fake on Windows, so they failed on the one platform they are about while passing on Linux. windows-arm64-ci.yml runs this file on a win-arm64 runner under both shells and asserts sysconfig.get_platform() first, so these were red, not skipped. Test-WoaNvidiaPresent. The fake was an extensionless file containing #!/bin/sh. Windows has no shebang handling and PATHEXT covers no extensionless name, so the probe found nothing at all. Now written as nvidia-smi.cmd on Windows, which is the idiom the rest of this suite already uses, and the sh script elsewhere. The hung-probe case shared the same fake and so was a false pass: it asserted the call returns, and a probe that finds no executable returns immediately. It now actually blocks, via ping rather than timeout, since timeout /t needs a console. The installed-pair probe. The block runs <VenvDir>/Scripts/python.exe by that exact name, and the fake was a shell script called python.exe, which Windows will not execute because it is not a PE. All three cases therefore asserted the floor rather than the branch they were written for. Replaced with a real venv carrying real .dist-info metadata, so importlib.metadata answers the way it would after a real install and the interpreter answers the tag query itself. Confirmed it now bites: breaking the installed-pair read fails test_the_installed_pair_is_kept, which it could not do before. The file: URL row. Its expectation was built by pasting a rebased path after "file://". A rebased Windows path is C:\opt\corp\ov\dist\a.whl, and "file://" plus that is not a file URL: the authority would be C: and the separators are wrong. The rewriter emits file:///C:/opt/corp/ov/dist/a.whl, which is correct, so the code was right and the expectation was not. Split into its own test that builds the URL with pathlib as_uri instead of concatenating. 26 pass here on native Windows ARM64 under PowerShell 5.1. * Add soxr to librosa's unblock set: it has no win_arm64 wheel either librosa is on the win_arm64 skip list and comes off it once its blockers are installable. The blocker set was llvmlite and numba, but librosa 0.11.0, which is what extras.txt pins, requires soxr>=0.3.2 as well, and soxr has published no win_arm64 wheel in any release up to 1.1.0. Verified against the PyPI JSON API for the pinned version rather than for latest, since only the pinned release's metadata is the one that applies. So hosting cp313 numba and llvmlite would have un-skipped librosa, and the extras pass would then have gone looking for soxr and found only an sdist. Building that needs the toolchain the whole skip list exists to avoid. Nobody has hit it because numba and llvmlite publish win_arm64 for cp314 only, so librosa has never actually un-skipped on a cp313 host, which is what kept it latent. soxr also gets a floor entry, read from librosa 0.11.0's own metadata, matching the provenance convention the other two floors already follow. openai-whisper is left alone deliberately: its metadata asks for numba and tiktoken and does not mention soxr. Upstream is dofuuz/python-soxr PR 36, "Add Windows ARM64 wheels", still open. The three existing tests that drove librosa off the skip list now stub the index answer for soxr, so they stay about what they were written for: the interpreter tag deciding availability, the empty-wheelhouse early return, and losing the index offline. The new truth gets its own test, which fails when soxr is taken back out of the tuple. 165 pass. * Name the wheelhouse on the setup.ps1 trio command, read pip's config files, evict a stale xformers without a hosted wheel for PR #10282 setup.ps1: Fast-Install clears UV_FIND_LINKS and PIP_FIND_LINKS beside the other inherited index settings whenever --index-url is given and leaves UV_NO_INDEX alone, so a native CUDA update under --no-index saw neither the staged wheelhouse nor the CUDA index. $WinArm64IndexArgs now carries --find-links for the wheelhouse when the directory exists, and UV_NO_INDEX yields for the trio command through the existing cutoff save and restore, as install.ps1 already does. install_python_stack.py: the pip path of the PyPI reachability check read PIP_* only. It now consults `pip config list` when the environment is silent: an `[install]` or `[global]` no-index or exclusive index-url set in pip's files means PyPI is not in play, a PyPI extra-index-url keeps it in play, and a pip config that cannot be read keeps the skip. :env: rows are skipped since the variables were already read. install_python_stack.py: the resident xformers compatibility check ran only after a hosted install attempt, so a copy an earlier run installed against another torch stayed resident whenever the wheelhouse stopped offering a usable wheel. The check is now a helper that also runs when nothing usable is hosted. Tests: setup.ps1 twin of the no-index trio class (find-links on the command line, UV_NO_INDEX yield and restore, off-ARM64 untouched), the pip fallback rows carry --find-links through the pip translation, a pip configuration class with stubbed and real `pip config list` output, and the wheelhouse optional rows for eviction without a hosted wheel. Twelve mutants on the three changes are killed. * Search every supported nvidia-smi location for the driver version Test-WoaNvidiaPresent searched PATH, System32 and the NVSMI folder, but Get-WoaDriverCudaVersion searched PATH and System32 only. On a host that carries nvidia-smi.exe under %ProgramFiles%\NVIDIA Corporation\NVSMI alone, the presence probe reported a GPU and the version probe returned nothing, so Initialize-WoaNativeCudaTorch skipped its CUDA-major compatibility guard and could select a cu13x ARM64 wheel against a driver that only advertises CUDA 12. Factor the candidate list into Get-WoaNvidiaSmiPath and have both probes call it, so the two cannot disagree. Same two locations the main GPU detection already searches. * Inject Get-WoaNvidiaSmiPath into the scripts that paste the probes' real bodies The pwsh tests compose real function bodies into a bare script. Routing both nvidia-smi probes through the shared lookup gave those bodies a call the compositions did not inject, leaving it unresolved. One of the two would have failed loudly. The other passes for the wrong reason: PowerShell writes the unresolved-command error and continues with $null, so Get-WoaDriverCudaVersion returns $null before it ever calls nvidia-smi and the '[]' assertion still holds, with the timeout the test exists to bound never exercised. Both are marked requires_pwsh, so on a host without pwsh they skip and the suite is green either way. Add a guard that needs no pwsh: it fails if a composition injects a body calling Get-WoaNvidiaSmiPath without also injecting the helper. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Read uv's inline index spelling, and stop guessing when it cannot be read Read-WoaUvTomlIndexKeys returned $null on sight of an `index` key, so `index = [{ url = "...", default = true }]` -- valid, documented uv config -- read as Unreadable. Get-WoaDependencyIndexArgs never looked at Unreadable and fell through to its https://pypi.org/simple default, silently replacing whatever mirror the file configured. Test-WoaResolveReachesPyPI had honoured the same flag all along, so the two consumers of one signal disagreed, and the unsafe one built the install command. Parse the inline form, conservatively: a single-line, brace-balanced array of flat inline tables only. A multi-line array, a nested value, a bare entry beside a table, a missing url, a non-boolean default or an explicit index all still return $null and stay Unreadable, because guessing is the only outcome worse than not parsing. Then make the consumers agree, per call site, since they genuinely differ. With no env default and an unreadable policy, name no dependency index rather than substituting PyPI. install.ps1's trio step calls uv directly, so uv reads the file itself and the empty answer is strictly better than today: no substitution and no failure. setup.ps1's runs under Fast-Install with --index-url, which sets UV_NO_CONFIG and scrubs UV_*, so uv cannot read it and an empty answer would leave the trio index as the only source; that one stops with a message naming the file, the spelling, and both ways out. Get-WoaUvConfigIndexPolicy now records which file it could not read so the message can name it. Blast radius, traced before writing: a previously-$null read now returns a value, which moves Test-WoaResolveReachesPyPI and therefore wheel pruning. A mirror URL still gives false and still disables pruning, so that case is unmoved. A genuine pypi.org URL now gives true and enables it, which is correct and was previously suppressed by a parse failure rather than by a decision. Verified against pwsh 7.4.6 rather than skipped: the inline reader is exercised on five accepted shapes and six ambiguous ones. Running them for real caught a bare string entry beside a table being accepted, because the scanner entered string mode before testing depth. Full tests/studio/install plus the wheelhouse tags suite gives an identical failure set to HEAD, 173 either side, none new. * Apply the pinned formatter's kwarg spacing to the new inline-index cases The ruff-format-with-kwargs hook pins ruff==0.6.9, which wraps the new parametrize tuples one element per line. Formatting only; the 733 tests in the two files are unchanged and still pass, and a second run of the hook is a no-op. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Default the Windows on ARM wheelhouse to the published one for PR #10282 The default named unsloth/windows-arm64-wheels, which does not exist, so no host took the native path without UNSLOTH_WOA_WHEELHOUSE set. The wheelhouse now lives under danielhanchen/unsloth-blackwell-docker: pyarrow 25.0.1 for cp311 through cp314, built from the Arrow release tag with apache/arrow#48539's build script on a windows-11-arm runner, and sqlite-vec 0.1.9 repacked with an ARM64 DLL built from its release tag. Every wheel was smoke tested on the ARM64 runner, every native image inside is ARM64, and install.ps1 ran its native path against the published URL end to end (pyarrow 25.0.1, torch 2.14.0+cu134, sqlite_vec 0.1.9 all importing in the win-arm64 venv). The test that pins the URL follows. * Share the PowerShell test harness and parametrize the Windows on ARM persistence tests for PR #10282 * Shorten the comments the Windows on ARM installer added for PR #10282 * Parametrize and share fixtures across the Windows on ARM wheelhouse tests for PR #10282 * Keep explicit uv indexes out of the dependency index, prune wheels an earlier wheelhouse left for PR #10282 Explicit indexes: uv serves an index marked explicit = true only to packages pinned to it via [tool.uv.sources], so it is neither the default nor an extra for the trio's shared dependencies. The block form [[index]] reader in install.ps1 and setup.ps1 read url and default only and flushed such an entry as a general extra, which Get-WoaDependencyIndexArgs then passed as --extra-index-url to the native torch resolve; the inline table reader refused the key outright, which turned the documented PyTorch pattern into an unreadable policy. Both readers and the Python reader now skip an explicit entry; explicit together with default = true also removes PyPI as the default, which is not modelled, so that combination stays doubt. Stale wheels: the managed woa\wheels directory persists across runs, so after UNSLOTH_WOA_WHEELHOUSE changed a wheel the earlier wheelhouse staged stayed in it, the hosted scan read it as current and UV_FIND_LINKS installed it from a source no longer configured. Staging now reconciles the directory against the wheelhouse listing it just read, keeping the listed wheels and the selected pyarrow wheel; an unreadable listing (offline) keeps the staged copies, and the directory used as its own wheelhouse lists everything it holds. Tests: block-form and inline rows on both scripts plus the Python reader, the dependency args never naming an explicit index, and the reconciliation across a local wheelhouse, a URL wheelhouse, an offline listing and the self-sourced directory. Ten mutants on the change are killed. * Shorten the comments the explicit-index and wheelhouse reconciliation changes added for PR #10282 * Hand PyPI-provided wheels to the skip list, install a hosted torchcodec, order xformers after the final torch repair, and key the pip refusal to the generated overrides for PR #10282 * Keep folded caller overrides in a per-run file rather than the persistent WoA overrides for PR #10282 * Backtrack to the newest paired torch and keep inline comments when rebasing override lines for PR #10282 * Form the rebase test's expected paths per host and let the ARM64 CI print its pytest output for PR #10282 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add a signed Windows on ARM wheelhouse workflow that builds, Authenticode signs and publishes the pyarrow and sqlite-vec win_arm64 wheels for PR #10282 * Publish the Windows on ARM wheelhouse under the Windows-ARM64 release tag for PR #10282 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> | 2 天前 | |
Installer: recover from and explain an unreadable llama.cpp cache on Windows (#10533) * Installer: recover from and explain an unreadable llama.cpp cache on Windows * Mirror the preflight into setup.ps1, name the antivirus, cover the Node cache The first version changed only install.ps1, and the two Windows entrypoints carry byte-identical copies of these helpers: the parity suite failed on both edited functions, and `unsloth studio setup` and `update` kept the old dead end. scripts/sync_shared_ps1_helpers.py copies the shared block now, so the two cannot drift by hand again, and the parity failure names it. Three changes on top of that, each from a real report. - The guidance named Controlled folder access but never checked it, and never looked at anything else. It now reads the Defender mode, and when Defender is not the cause it names whichever antivirus is registered in SecurityCenter2. A user whose Bitdefender is holding the folder cannot act on advice that only says antivirus can do this, and takeown and icacls will not clear it. - A denied cache is only moved aside when it is ours to move. A link there is something the user arranged, so moving it would change which tree they run without touching the one they were protecting, and unreadable attributes are treated as a link rather than guessed at. - The same denial reaches the Node cache, where install_node_prebuilt.py called it an unexpected error and setup.ps1 followed with install Node yourself or check your network. Neither is the fix. It exits 4 now and setup.ps1 gives it the same guidance the llama.cpp cache gets. The behaviour suite gained the cases this needs: a cache that can be renamed is recovered, one that cannot still reports, and a link is never moved. The entrypoint fixtures block the rename so they keep exercising the guidance path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the denied-path diagnostics and what the recovery may touch --------- Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com> | 1 天前 | |
Use Unsloth branding in remaining user-facing text (#11254) | 18 小时前 | |
Reduce comment volume across the installers, CLI, unsloth package and backend utilities (#10504) * Reduce comment volume across installers, CLI, unsloth and studio backend utilities Deletes restatements, section narration and duplicated prose, and collapses multi-line comment and docstring paragraphs to one line where every fact is kept. Comments, docstrings and whitespace only: no code, no test files. Functional directives (noqa, type: ignore, shellcheck, novermin, #Requires, coverage pragmas, shebangs, SPDX headers), installer section banners that the installer tests locate by literal, and user-visible --help text are preserved. * Apply the same comment standard to upstream comments landing in the rewritten regions * Restore the comment literals the install and kaggle tests anchor on * Restore comment anchors that tests slice source files on The trees read install.sh, install.ps1, studio/setup.ps1, unsloth/models/vision.py and the kaggle leg registry and cut them on exact comment text, so a reworded or moved marker changes behaviour. Restore those markers verbatim at their original positions. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim the env-mode venv guard comment after the merge The merge folded main's _claim_sentinel explanation into this branch's condensed form, which left some of it saying the same thing twice. Keep the parts that carry a reason and drop the restatement. The leading phrase stays put: tests/sh/test_uninstall_legacy_layout_gate.sh slices install.sh from 'why: matching guard to the .venv branch below' to 'Move it aside or choose an empty', so that text is a test anchor rather than prose. * Keep the two argparse descriptions that are public help output scripts/lint_exec_literals.py and scripts/profile_startup.py pass __doc__ as the argparse description, so those module docstrings are CLI output rather than comments. profile_startup.py uses RawDescriptionHelpFormatter, so its layout is rendered verbatim and collapsing it destroyed an intentional indented table. Both --help outputs are byte-identical to base again. The executable AST is unchanged either way. Left as-is: .github/scripts/interrupted_install_probe.py, whose help also changed but for the better. Base ran the verdict names into the surrounding prose once argparse re-wrapped it, and the parenthesised form reads correctly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com> | 8 天前 | |
Studio: answer current llama.cpp, whisper.cpp and Node installs from their markers (#10648) * Studio: do not re-probe an isolated Node install that already answered existing_install_matches spawned `node -v` and `npm --version` on every installer run to re-derive an answer that cannot have changed while the binaries have not: two interpreter starts of a 110 MB runtime, per run, on every platform. The marker now records the size, mtime and digest of the node binary and the npm cli beside the version and npm major those exact bytes reported, written after the swap (_ensure_npm_floor rewrites npm inside the staged tree, so a record taken there describes bytes about to be replaced). A run whose records still describe what is on disk believes them; anything else spawns as before and then records the result, so an install predating this pays it once. Version and pinned digest still decide on their own -- the short-circuit is only about the two spawns. * Studio: answer an already current llama.cpp or whisper.cpp install from its marker * Studio: hold the llama.cpp marker skip to the hardware and the payload The no-network re-check compared only the recorded backend_request and force_cpu, and backend_request is "auto" on every automatic install. Since the release tag does not move when hardware does, an auto install kept its CPU bundle after a GPU was added, kept its CUDA bundle after the card or the driver went away, and kept a coverage bundle after a card outside its supported_sms replaced the old one, in each case until the fork happened to publish a new release. So record what detection actually said. write_prebuilt_metadata now stores a host_profile (usable NVIDIA, driver CUDA version, sorted compute caps, ROCm and its gfx targets, Intel GPU, AMD without ROCm, macOS version) taken from the routed HostInfo the selection was made from, and the re-check re-derives the host through route_backend_request, the same no-network routing _select uses, with the same overrides and the same CPU mechanism. A marker with no host_profile cannot answer and takes the full path. The route is computed once in install_prebuilt and handed to both, so an update that does find a new release does not run nvidia-smi twice. Two smaller holes in the same check: * the payload half only globbed for existence, so a truncated libggml-cuda.so, ggml-cuda.dll or cudart DLL passed it -- and those libraries are where most of a CUDA bundle's bytes live, which is exactly what a full disk or an interrupted extract leaves behind. runtime_files now records size and mtime_ns for every file the bundle's own copy allowlist matches, stat only, and sizes are compared; the three binaries keep their sha256. mtime is recorded but never compared, since a restore or an rsync rewrites it without changing a byte. * a marker's backend and backend_request must be a pair persisted_marker_backend_request could have written, and the backend must name an install kind this platform has. "latest" keeps one documented lag: /releases/latest resolves by make_latest/created_at while the macOS path orders by published_at, so a re-published release can leave this reporting "current" for one update. That is now written down next to the check, an explicit UNSLOTH_LLAMA_DISABLE_DOWNLOAD_HOST_RESOLVE gets the API's published_at answer instead of a flat refusal, and a releases payload this process already fetched is compared for free. The reuse path backfills runtime_sha256, runtime_files and host_profile onto a marker written before these existed, so a pre-existing install pays the full path once rather than on every update. * Studio: record a slim whisper install's paired ggml tree when it is reused existing_install_current_without_plan refuses a slim install whose marker cannot say which llama ggml runtime it hardlinks, and only installs made after that key existed carry it. So every whisper install made before this branch fetched the release, its manifest and its checksum index on every update rather than once. The reuse path is the only place that re-examines a slim install without reinstalling it, and it gets there having just confirmed the fingerprint and the wiring, so that is where the tree is recorded. Added, never corrected: a marker that already names a tree was written by the run that installed against it, and a llama runtime that moved since is exactly what the pre-check exists to notice. The write goes through the same atomic, never-raising marker writer the llama installer uses, so a read-only or full disk warns rather than failing setup. * Studio: write the isolated Node marker atomically record_runtime_verification read the marker, added the verification records and wrote it back with write_text. load_metadata reads a truncated or unparseable file as "no install", so a crash or a full disk partway through that write retires an install that was fine and buys a ~110 MB refetch -- and unlike the install-time write, this one is rewriting a file that already describes a good tree, so a torn write is strictly worse than not writing at all. Both marker writes now go through one helper: a sibling temp file, flushed and fsynced, then the module's existing atomic_replace_from_tempfile, with the temp file removed on every failure path so a stranded .tmp- sibling cannot be carried into the live tree by _swap_into_place. The bytes are unchanged, including the platform newline, because the setup fast path and the idempotency harness both compare this file byte for byte. record_runtime_verification still never raises; write_metadata still does, since it writes into a staging tree that is discarded. * Studio: hold a cached Node install to the execute bit and to npm's health The recorded-runtime shortcut accepted two installs that do not work. chmod -x moves ctime only, so the recorded size and mtime_ns both survive it and the record still matched a node binary nobody can execute. The spawn the record stands in for would have failed and the install would have been repaired, so re-derive that one answer with os.access rather than trust bytes that are no longer reachable. Not asked of npm-cli.js, which node reads rather than executes, and not asked on Windows, which has no execute bit. The npm record covers npm-cli.js, a launcher that bootstraps thousands of files under npm/lib: deleting npm/lib/cli.js leaves the recorded launcher byte identical while `npm --version` fails, and the shortcut kept it. No cheap record of the launcher can show the tree behind it is intact, so the npm probe is paid again on every run. That still saves one interpreter start of a 110 MB runtime out of two, and a damaged npm is rejected instead of kept. The docstrings now say what the record does and does not prove. * Studio: keep an intact whisper.cpp install when the lookup cannot answer On a strictly offline update, with UV_OFFLINE set and a proxy refusing every connection, llama.cpp reported "update unavailable, existing prebuilt kept" and moved on while whisper.cpp printed "prebuilt install failed; curated whisper.cpp dictation is unavailable" over a healthy install it had never touched. Nothing was wrong with the install; the release lookup simply had no answer to give. whisper now mirrors llama's rule for the identical outcome. The checks that do not depend on knowing which release is newest are split out of the pre-check into _existing_install_is_intact, so the keep path makes exactly those demands and no others rather than carrying a second copy of them: the marker was written by this installer for this repo and this backend, a slim bundle still hardlinks the llama ggml tree it was wired against, and the tree on disk is the shape the marker describes. That helper runs before the "latest release" HEAD now, so a damaged tree no longer pays a round trip to learn it must reinstall anyway. ReleaseCompatibilityError is re-raised ahead of the handler: there the lookup did answer, and the answer is real release skew that setup reports from exit 2. So is anything this run asked for that keeping the tree would silently ignore, which is --force, --cpu-fallback, and any pinned tag. --has-rocm and --rocm-gfx are not on that list, for llama's reason: both entrypoints forward detected hardware on every AMD host, including the one this was observed on. The kept line carries both tokens the shells need, and both setup scripts gain the whisper arm their llama one already had, so exit 0 after a lookup that could not answer no longer reads as "prebuilt installed". * studio: keep an intact whisper.cpp install when the release fetch itself is unreachable The keep path added for an offline update only reads PrebuiltFallback, but the shared release fetch raises the network error as is (URLError from a refused connection or a proxy answering 403), so a strictly offline update still printed "unexpected error" and "prebuilt install failed" over a healthy install. Wrap those at this module's fetch seam with the wording the keep path already reports, and test through the seam rather than a stand-in that spoke PrebuiltFallback already. Reproduced and confirmed in a CUDA sandbox with a dead proxy. * Close the prebuilt pre-check gaps: fallback bundles, release pins, cpu architecture, rate limits The no-network re-check answered current for four installs the full path would have changed: - a bundle kept only because the preferred one could not be fetched (prebuilt_fallback_used) was never retried - a pinned UNSLOTH_LLAMA_RELEASE_TAG ignored a concrete UNSLOTH_LLAMA_TAG - host_profile carried no cpu architecture, so a home directory moved between an x86_64 and an arm64 box kept a bundle the loader cannot run; the whisper intact check now also requires the recorded asset to carry this host's os and arch tokens - a stale host_profile was never refreshed by the reuse path, so a hardware change sent every later update down the full path forever The whisper keep path also reads the RuntimeError fetch_json raises on an HTTP 403/429 from api.github.com, not only URLError, so a rate-limited update keeps an intact install instead of failing. * Answer custom repos and whisper upstream pins the way the selector would iter_resolved_published_releases takes the download-host fast path for the default repo only and orders any other repo's releases by published_at, so the llama pre-check now asks the API for that ordering on a custom repo instead of following /releases/latest, which a custom repo can leave pointing at the installed release forever. The whisper fork publishes several packaging revisions of one upstream tag and _release_plan_for_host takes the newest that matches, so a whisper upstream pin is now current only when the installed release is also the one the download host HEAD names; the marker's upstream_tag still refuses a wrong pin outright. * Prebuilt pre-check: profile the NVIDIA adapter, hold pins and kept trees to what they asked, and write marker refreshes under the lock The host profile gains has_physical_nvidia: the Intel and ROCm-less AMD routes go to Vulkan only when no NVIDIA adapter is present at all, so an adapter that appeared or went without becoming usable changed the selection with every recorded field equal. whisper's shortcut checks an upstream pin whether or not the release is pinned too, as the full path does, and --cpu-fallback no longer counts as an explicit release request: it is a backend request the intact check already compares, so a kept CPU tree honours it as --backend cpu does and an offline update with it is recoverable. The two marker refreshes that ran before the install lock (node's runtime record, whisper's slim pairing backfill) now write under it and only over the marker they read; the node marker keeps its mode across a refresh instead of NamedTemporaryFile's 0600. The llama full path holds a marker to the runtime sizes and digests it recorded, so a truncated llama-server.exe is repaired on Windows, which has no image preflight. * Prebuilt pre-check: the macOS floor, a normalised upstream pin, the listing as a lookup, the selector's page depth, and the post-install record under the lock whisper's intact check applies the bundle's recorded min_os on macOS, as the selector does, so an install restored onto an older Mac is not reported current. An upstream pin is compared normalised (v1.9.2 and 1.9.2 are one tag). A failing compatibility listing (an API limit or a network error) is a lookup that could not answer and takes the keep-existing path instead of failing an intact install. llama's API fallback scans the pages the full selector scans. Node's post-install runtime record takes the install lock again and writes only over the marker this install wrote. * Node pre-check: a marker that changed hands under the lock is not a match _record_runtime_verification_under_lock now reports whether the marker was still the one it read, and the pre-lock check answers False when it was not: the tree another installer swapped in while this one waited is not the version that was verified, and the locked re-check decides about it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Prebuilt pre-check: three review follow-ups The whisper intact check reads the macOS floor where write_prebuilt_metadata records it, the marker's top-level min_os, and still accepts one nested under coverage. A pre-lock keep whose re-check under the lock finds another install falls through to the locked validation and install path instead of reporting the replaced release as installed. For latest on the upstream repo on a Mac below the floor, the llama marker check expects the pinned fallback release the selector would choose, so a current pinned install takes the fast path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Prebuilt pre-check: four review follow-ups A pinned upstream build on the fork expects the newest published packaging of that build, as the selector installs, instead of reading the recorded release as current forever. The Node marker refresh preserves owner and group as the llama writer does. UNSLOTH_WHISPER_FORCE_COMPILE counts as an explicit request so a lookup failure still reaches the source build. A CUDA install declines the marker fast path when torch now prefers another CUDA runtime line. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Prebuilt pre-check: two review follow-ups The host profile records the CUDA runtimes on disk, which the CUDA selectors order the bundles by, so a runtime that appeared since the install takes the full path once. The Node marker refresh preserves the group only, which a non-root member can do. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Prebuilt pre-check: two review follow-ups A Node install lock that stays busy for the whole wait is not a verified match, so the locked re-check decides. A torch CUDA preference the selectors cannot act on (no such runtime on disk, or a driver that cannot run it) is not movement, so a host that keeps the line it can run keeps the fast path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Prebuilt pre-check: a torch preference counts on Windows without detected runtime DLLs The Windows selector falls back to every driver-compatible line when no runtime DLL is found, so the movement check does the same there instead of vetoing the preference on an empty set. * Prebuilt pre-check: compare torch's preference with the one the install was chosen under The selectors do not always follow torch's CUDA preference (Blackwell routing, a release without a bundle for that line), so comparing it with the installed line rejected the marker on every update and landed on the same install. The marker now records the preference the choice was made under, written by the full path and backfilled by the reuse path, and only that preference moving counts as movement. Markers without the record keep the old comparison once. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Prebuilt pre-check: a vanished torch preference, an upstream whisper pin and an unreadable binary A torch preference that is gone (torch removed, a CPU build) sends the selectors to the host's own runtime order, so the marker fast path now treats that as movement when the order starts at another line than the install, and never on a Blackwell host, which is routed by its own rule. A whisper upstream pin is answered from the release list, the newest release packaging that tag, instead of the repository-wide latest, which cannot name it once a newer upstream is published. A required binary that stats but cannot be hashed makes the runtime record unusable rather than leaving it at the size-only tier. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Prebuilt pre-check: custom repos ask for the newest packaging, and the node hash is streamed Only the upstream repo publishes one release per build; any other repo is ordered by published_at like the fork and can package one build more than once, so a pinned build there asks the release list for the newest packaging. The node binary record hashes the file in chunks instead of reading 110 MB into memory. * Prebuilt pre-check: stream the binary hashes, retry an empty runtime record, keep the whisper pin shortcut to the fork The runtime record and the fast-path check hash binaries in chunks. An empty runtime record, which is what an unreadable binary leaves, is backfilled like an absent one on the next reuse instead of failing the check closed forever. The whisper upstream-pin shortcut answers by tag name, which only the fork's release convention supports, so a custom repository takes the full path. * expect the exact pinned release from a custom repository, and keep a hyphenated whisper pin whole * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * match whisper packagings by the fork's suffix, and record the ROCm runtime in the host profile * llama prebuilt: a zero-byte executable is not a matching install The reuse predicate checked that llama-server and llama-quantize exist; the Linux and macOS preflights read the executable image, but Windows has none, and a marker written before the runtime record existed carries no sizes, so an empty llama-server.exe was reused as current. Both executables now have to be non-empty on every host. * Prebuilt keep paths reject empty executables and markers without a fingerprint A zero-byte llama-server or whisper-server keeps its mode and its marker, and neither the POSIX execute-bit check nor the Windows existence check saw it, so the pre-check kept a broken install. Both keep paths now require the executables to be non-empty, and the whisper marker-only check requires the install fingerprint the full path holds it to. * Formatting: kwarg spacing per the pre-commit hooks * Kept-install pre-check: a reused bundle records whether it is this run's fallback Attempt ordering moves with the recorded torch runtime preference. When the preferred candidate failed and the existing install was reused as a later one, the reuse refreshed the host profile and preference but left an old prebuilt_fallback_used of False in place, and the next update's no-network pre-check kept the fallback without ever retrying the preferred bundle. Every reuse path now records the flag a fresh install of that choice would have recorded, and a preferred bundle reused clears an old True. * Format the reused-bundle test the way the kwargs hook does * Whisper marker fast path: the marker has to reproduce its own fingerprint The marker-only check accepted any nonempty install_fingerprint. The full path compares the recorded fingerprint against the plan's, so a marker whose release_tag was moved to the current tag over an old binary and its old fingerprint was rejected there and accepted here. The marker now records the one fingerprint input it did not carry whole (fingerprint_coverage), the fast path recomputes the fingerprint from the marker's own fields and requires it to equal the recorded one, and a marker written before the key existed takes the full path once, where a kept install is settled with it under the install lock. * Fingerprint backfill: temp-and-replace over the live marker The settle of a kept install rewrote the marker in place. A write that fails part-way, on a full disk or an interrupted process, would have left a valid marker truncated, and the next run could no longer recognise the install. Written through atomic_write_bytes, as every other live write in this module. * Fingerprint backfill: keep the marker's mode and owner across the rewrite atomic_write_bytes swaps in a NamedTemporaryFile, which is 0600, so the settle of a legacy marker in a group-shared install would have left it readable only by the user who ran the update. write_live_marker restores the original mode and owner on the temp file before the swap, as the llama installer's marker writer does, and removes the temp file if the write fails. * Live marker rewrite: restore the group only, never the owner A non-root member of a group-shared install can hand the temp file to the marker's group but not to its owner, so asking os.chown for both refused the call before the group was applied and the swap installed the member's primary group. uid -1 now, as the Node marker writer does. * Prebuilt pre-check: recognise a deliberate macOS release walk-back On a Mac below the newest bundle's OS floor the planner installs the newest compatible older release. The marker-only re-check asked which release is newest and compared it with the installed one, so every such install took the full path on every update. The planner now records the release it skipped as walked_back_from (marker and selection, outside the fingerprint); the llama and whisper fast paths accept the installed release while the newest is still the recorded one, and a kept install gains or retires the record when the plan changes. Any newer release still takes the full path, which re-decides it. * Prebuilt pre-check: a walk-back records the host macOS version; whisper reads the recorded platform The macOS walk-back record now carries the host version that decided it (walked_back_on_macos beside walked_back_from, prebuilt_core.WalkBack). The marker-only re-check holds the install current only while the newest release is still the one skipped and the host is still that macOS version; an OS upgrade that may satisfy the skipped release's floor takes the full path, which re-decides the walk-back. A marker recording the tag alone is not trusted and is completed by the kept-marker catch-up. The whisper keep check judged the platform by parsing the asset name, which a custom published repository's manifest need not follow, so such installs never took the fast path and the keep-existing path dropped them when the release lookup was unavailable. The marker now records the manifest's os and arch (outside the fingerprint) and the check reads those; the fork's asset naming remains the fallback for markers written before the platform was recorded. * Tighten the comments added by the prebuilt pre-check * Skip the marker ownership tests where os.chown does not exist monkeypatch.setattr(os, "chown", ...) raises AttributeError on Windows rather than skipping, so both tests error there instead of reporting the POSIX-only guard they cover. The production writers already catch the AttributeError; only the tests assumed the attribute. Matches the existing idiom in test_llama_prebuilt_no_space.py. * Marker rewrites keep the group, and Node reads the full-check hatch _write_marker asked os.chown for the owner as well as the group. chown is all-or-nothing, so a non-root member of a group-shared install had the call refused before the group was applied, and os.replace then installed that member's primary group, leaving the marker unreadable to the other members. prebuilt_core.write_live_marker was corrected for this; the llama writer was not, and the slim pairing backfill gave it a new caller. UNSLOTH_PREBUILT_FULL_CHECK turned the no-network shortcut off for llama and whisper only. Node never read it, so the one variable documented as forcing a full revalidation still answered the managed runtime from its marker. Node defines its own reader rather than importing llama's: it bootstraps the Node runtime and must not depend on that module. * Ask the loader before a pre-record marker's bytes become the record existing_install_matches_choice is what decides whether sync_marker_selection may record the installed bytes as runtime_files. A marker written before that record existed carries no digest to check, and Windows gets neither of the loader preflights, so the only integrity test left on that path was the zero-byte guard. A llama-server.exe damaged under the old marker therefore became the reference every later run compares against. The migration run now asks the OS to start the two binaries first, on the one platform with no preflight, and only then are they eligible to be recorded. _binary_image_runs reads a timeout or an ordinary non-zero exit as healthy, so this cannot spend a reinstall on a working install, and it is paid once: every later update reads the digest instead. * Survive a marker that moves under the backfill, and a blocked swap _backfill_fingerprint_inputs reads the marker a second time, after _kept_marker_patch already read it. Another installer swapping the tree in between leaves None and the read called .get on it. There is nothing to catch up at that point, so it now falls out: the run that replaced the tree wrote its own marker. The marker writers moved from an in-place write to temp-and-replace. On Windows a rename over the destination needs DELETE access on it, so a scanner holding the marker open fails the swap outright, where the write it replaced would only have contended for write access. Both swaps now go through the bounded backoff the directory renames already used; a no-op off Windows, and anything that is not a sharing violation still raises at once. * Simulation suites for the prebuilt marker fast path Four suites, 416 cases, covering what the fast path is allowed to skip. Legacy markers: the five released tags v0.1.800 to v0.1.808-beta are extracted read-only and their OWN writers produce the markers, so the corpus is the one users actually have rather than a hand-built approximation. Each new key is dropped in isolation, since the released markers are missing all three at once and on their own cannot show which absence does the work. The paid-once property is asserted in both directions, and forward compatibility is that the released readers still KEEP an install whose marker was written today. Host matrix: twenty reachable cells of [Linux, Windows, WSL, macOS arm64, macOS x86_64] x [CUDA 12, CUDA 13, ROCm, Vulkan, Intel, CPU, Metal], each accepted unchanged and across repeated updates, then seventeen single-axis hardware changes each forced to reinstall. Every rejection is attributed: the test neutralises host_profile alone and watches the same call answer yes, so it shows WHICH guard rejected rather than that something did. Integrity: per-file truncation, a same-size byte flip, deletion, a lost execute bit, a dangling symlink and an unreadable file, plus the marker rewrite as a filesystem operation. The byte-flip case carries its own positive control. Offline: nine lookup failures across an intact, pinned, absent and damaged install, asserting exit code, the token setup.sh greps for, and that nothing was fetched and no tree was touched. A 404 on a pinned release still fails; only an unpinned lookup keeps. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * whisper records the payload it installed, as llama does installed_tree_is_intact checked that whisper-server was a non-empty file and, off Windows, executable. A server truncated to any non-zero length, or a wired library rewritten to a single byte, was therefore reported as a current install by both the reuse path and the no-network re-check. llama has not had this gap since it began recording runtime_files; whisper had no equivalent. The marker now carries size, mtime_ns and sha256 for whisper-server, and size and mtime_ns for a slim install's linked libraries. mtime_ns is recorded and deliberately not compared, as in llama: a restore from backup or a container layer rewrites it without changing a byte, and the answer to a mismatch is a 200-400 MB download. Digests stop at the server for the same reason the llama record stops where it does; truncation and deletion, which is what a full disk or a half-removed llama actually leaves, are caught by the size tier. Written through an optional ops hook, so a component that defines no hook gets no key and prebuilt_core stays component-agnostic. SCHEMA_VERSION is unchanged and runtime_files is not a fingerprint input, both so existing markers stay valid. An install with no record is NOT re-downloaded: the reuse predicate accepts an absent key and only the no-network fast path declines, once, after which settle_kept_install backfills under the install lock. That is the same shape paired_llama_ggml_tree already uses. Three tests in the integrity suite pinned the old boundary -- one asserted the marker held no digests, two asserted a truncated server and a one-byte library were kept. They asserted the gap, so they are inverted rather than removed. * Share the prebuilt suites' module loading and skip markers The six suites were written against the same subject and each re-derived the module loader, the host factories and the POSIX and root skip markers. Those move to _pr10648_helpers.py. The loader takes the sys.modules name as an argument rather than choosing one, so each suite keeps its own key: they monkeypatch module globals, and a shared key would let them clobber each other under xdist. The saving is 71 lines, which is most of what was actually duplicated. The rest of the preamble reads as shared but is not -- the matrix tables, the legacy suite's subprocess driver and the injected-network class are each one suite's scenario machinery, and hoisting them would have added indirection for no reduction. The three HostInfo factories stay separate for the same reason they exist: llama, whisper and node declare different fields. test_a_same_size_byte_flip_is_caught_only_by_the_digest existed in two files covering two different components. Both are kept and now name the component they cover. * Record the ROCm kernel catalogs a slim whisper install links installed_tree_is_intact asks only that each linked runtime directory hold ANY file, so a rocblas/ that lost or truncated one TensileLibrary blob and kept a sibling satisfied it. The payload record stopped at the top-level libraries, which left nothing else to catch the damage, and the keep-existing path this branch adds then reported an install whose kernel catalog no longer loads. The record now walks the linked runtime directories, at the size and mtime_ns tier the ggml libraries already use: rocblas is hundreds of MB and this runs on every update, while truncation and a missing blob -- what a full disk or an interrupted extract actually leaves -- both move the size. The bare-name rule the libraries follow applies here too. rglob on a path taken from the marker would walk out of the install, so a directory naming ../.. is skipped rather than recorded. Found by Codex on the payload record added in 5eebb9b1a. * Pair slim whisper to the llama install, not just its source tree A llama release publishes one ROCm bundle per gfx target, so re-selecting for another GPU swaps the asset while ggml_tree, a source-tree identity, stays put. The slim wiring hardlinks to the inode on purpose so it survives llama's directory swap, which leaves the previous GPU's kernels behind whisper's libraries while the tree id, the library presence check and whisper's own payload digests all still match. Record llama's install_fingerprint alongside the tree and compare it in installed_tree_is_intact, which both the keep path and the no-network re-check delegate to. A marker written before the key cannot say which bundle it was paired against, so it is backfilled on the next kept install rather than re-downloaded; that makes the next swap detectable without charging every existing user a bundle to answer a question about one that already happened. * Tighten the comments this branch adds Multi-line rationales collapsed to the fact they carry, decorative rules and bare section banners dropped. Added comment lines 577 to 459. No code, and the install suite is unchanged at 4613 passed, 21 skipped. * Gate the four new tests that only hold on POSIX A staging Windows run of the whole install suite, differenced against the same run on the merge base, left exactly four head-only failures, all of them tests this branch adds: - two mode-bit corruptions (llama and whisper), where os.chmod cannot clear an execute bit Windows does not have and os.access(X_OK) answers "does this exist", so the damaged tree is correctly still current - the end-to-end setup.sh status check, whose local bash guard let Git Bash on a Windows runner into a block the requires_bash marker already excludes - the About-tab marker rewrite, which monkeypatched os.chown, absent on Windows No pre-existing test changed verdict: the other 45 failures are present on the merge base too and are the bare runner, not this branch. * Honour an explicit release pin, and restore the marker owner when permitted Three follow-ups from an adversarial re-review of this branch. An explicit release pin was satisfied by a walked-back install. The macOS walk-back records why AUTOMATIC selection settled on an older release, and _release_expectation_met consulted it whatever the caller asked for, so a run that named b9999 on a Mac holding b9998 was told "already matches selected release b9999". The full path disables older-release fallback as soon as published_release_tag is supplied; the fast path now does the same. The marker writers asked chown for the group alone. That was right for the non-root member of a group-shared install, whose combined call chown refuses outright, and wrong for root, the one caller that can restore the owner: it left the marker owned by root, so an 0600 marker stopped being readable by the user who owns the install. All three writers now try owner and group first and fall back to the group. Node swallowed a failed chmod and swapped the temp file in anyway, publishing NamedTemporaryFile's 0600 over a marker other users read. It now abandons the replacement, as core and llama already did. * Do not abort a first Node marker write over a refused mode restore The previous commit stopped Node swallowing a failed mode restore, which was right for a REFRESH: swapping the temp file in anyway publishes a marker only its writer can read over one other users already read. It was wrong for the FIRST write, which has no mode to preserve and no other readers, and where write_metadata is not wrapped, so the exception aborts the whole Node install. On Windows that is reachable rather than theoretical: a scanner holding the freshly written temp file is the same sharing violation atomic_replace_from_tempfile already retries for, and it would have taken down an install that has nothing wrong with it. The refresh still abandons and leaves the good marker exactly as it was. Three tests, and each half of the split fails a different one when neutralised. * Tighten the comments added since the last pass The chown rationale was repeated verbatim in all three marker writers. It now lives once, in the shared module the other two mirror, and they point at it. Added comment lines since that pass: 41 to 22. No code. * Document the second deferral of the accepted-lag class A release whose checksum index cannot be fetched is skipped by the iterator and logged, and the planner never sees it, so a macOS walk-back records only that the newest release was unusable. A middle release passed over for a transient failure is then not reconsidered until something newer publishes, where the pre-PR path would have taken it on the next update. Same consequence as the pointer lag documented beside it and left alone for the same reason: both releases are compatible, so it is one deferred update and never a wrong install, and it clears on the next publish. The docstring names the fix if it is ever worth closing. Docstring only. * Do not report an untrustworthy release as an unavailable update The keep-existing paths hold an install and report success because a lookup that could not ANSWER says nothing about the tree on disk. Two refusals are not that: an asset absent from the checksum index, and a manifest digest that disagrees with it. Both mean the release was fetched and found untrustworthy, and both arrived as a plain PrebuiltFallback, so the keep arm swallowed them. Measured over an intact install with a manifest digest conflict: merge base exit 1 prebuilt install failed ... refusing a possibly tampered release before this exit 0 whisper.cpp update unavailable, existing prebuilt kept which also picks the yellow setup message instead of the red one. A tamper signal became a routine offline notice. prebuilt_core now raises ReleaseIntegrityError, a PrebuiltFallback subclass so every existing handler still catches it, and the two keep paths single it out. Whisper's keep arm is new on this branch, so that one is a regression this work introduced; llama's predates it and had the same hole, closed here so the two installers cannot disagree about what a keep is allowed to hide. Offline over an intact install still keeps and exits 0; a tampered release now exits 1 on both sides exactly as the merge base did. Six tests. Neutralising either guard fails one, which the unit tests alone did not: with the whisper re-raise removed the entire install suite stayed green and only an end-to-end run through the CLI noticed, so both tests drive the real entry point. * Verify a slim whisper install's wiring before recording its llama pairing A legacy slim marker carries no pairing identity, so the migration run records the llama runtime that is live at that moment. If llama was replaced after whisper was wired -- a per-gfx ROCm reselection, or another installer swapping llama's directory while this one holds only whisper's lock -- the hardlinks still point at the previous libraries, and recording the live identity writes a pairing that is simply false. The runtime-id check then believes it, so the reselection this key exists to catch becomes permanently invisible rather than merely unnoticed once. The backfill now proves the wiring first. link_ggml_runtime hardlinks, so a shared (st_dev, st_ino) IS proof that whisper's name and llama's name are one file; it falls back to shutil.copy2 across filesystems, where the inodes differ legitimately, so an inode mismatch drops to a size and sha256 comparison rather than a verdict. Anything unverifiable records nothing and logs why: a missing record costs the fast path on the next update, a wrong one costs the install. * Do not let a busy install lock turn a matching Node install into a failed setup The runtime-verification record is written under the install lock, and a lock that was busy was being read as "another installer replaced the tree". It is not: the evidence for a replacement is the marker no longer being the one that was read, and that check answers False by itself. Busy says only that nothing was written. The cost of reading it as a mismatch falls on exactly the installs that have a record to backfill, which is every Node install made before this branch. Beside a running installer, the pre-lock check answered "not matching", the outer install lock then failed against the same holder, and setup.sh treats Node exit 3 as fatal -- so the first update after upgrading could report a failed launch over an install that was complete and running. Before the record existed there was no lock on this path at all and the answer was exit 0, which is what it is again. The wait is now short as well. Blocking a launch for the full 300s install timeout to write a record whose only purpose is to save two subprocess spawns next time is worse than never writing it, so install_lock takes a timeout and the record asks for 5s. * Do not republish a Node record that says exactly what the old one said record_runtime_verification rewrote the marker every time it ran. Under UNSLOTH_PREBUILT_FULL_CHECK that is every run, which re-proves what the record already says and then publishes it again: a new inode, a new mtime, and the mode and owner re-applied, forever, for a file whose bytes do not change. It now returns when the four fields it would write are already the four fields that are there. Also pins, with a test, why the record can afford to compare stat fields and not the digest it stores. npm-cli.js is run BY the node binary and that probe is never skipped, so a node that cannot run is rejected whatever the record says -- the case an update can actually repair is not the case being skipped. The rest is stated in the docstring rather than implied: the record lives in a marker beside the binary, writable by anyone who could rewrite the binary in place, so it is a staleness check and never a tamper defence. Neither was the `node -v` it replaces. * Bound the wait for the metadata catch-up over a kept install The catch-up takes the install lock because it is a read-modify-write of the marker and a concurrent installer can be mid-swap. It was asking for the full 300 second install timeout, which is the wrong price for it: it runs on the FIRST update after an upgrade, for every existing user, and everything it writes only spares the next run some work. Beside a running installer that made an otherwise finished launch sit for five minutes before reporting the install it had already validated. It now asks for five seconds, the same as the Node record, and the give-up path is unchanged: the install is kept, the exit code is 0, and the next run settles it. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 4 天前 | |
Pin isolated Node.js installer to committed sha256 digests (#6625) * Pin isolated Node.js installer to committed sha256 digests The isolated Node installer verified each downloaded archive only against SHASUMS256.txt fetched from the same nodejs.org origin as the archive, so a compromised CDN or TLS path could serve a malicious archive plus a matching checksum and gain code execution when the extracted node is run during the npm floor check and version probe. Anchor trust in studio/node_prebuilt_pins.json, a committed manifest of per-arch sha256 digests, and verify archives against it. The default channel installs the pinned version and never fetches the remote SHASUMS. Unpinned lts, latest, or explicit versions fail closed via UnpinnedNodeRefused unless UNSLOTH_NODE_ALLOW_UNVERIFIED=1, and the refusal is not swallowed by the keep-existing-on-transient-failure path. Ship the manifest in package-data. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review nits on the pinned Node installer - Drop the unused npm_min_major field from node_prebuilt_pins.json; the floor is the NPM_MIN_MAJOR module constant and the dead field could silently drift. - Reword the unpinned-refusal message so it does not tell a user already on the default to install it, and point the "add a pin" hint at the exact asset. - Decode the opt-in SHASUMS body with errors="replace" so a non-UTF8 response yields a clean PrebuiltFallback instead of an uncaught UnicodeDecodeError. - Tests: assert the refusal message (guards the main() catch order, not just the exit code), cover malformed-manifest parsing, and drive the opt-in remote-SHASUMS path end to end through install_prebuilt. * Tighten comments in the pinned Node installer Collapse multi-line rationale comments to single lines, drop docstrings on the obvious internal helpers (load_pins, pinned_sha256), and shorten the manifest note. Comments/docstrings only; verified code-unchanged via AST comparison. * Address Codex review: verify pins on existing installs; tomllib fallback - existing_install_matches now takes an expected_sha and the short-circuit passes the committed pin, so a version-matching but non-pinned or tampered install (e.g. from the old remote-SHASUMS path) is re-verified instead of kept. An unpinned target without opt-in no longer short-circuits on an existing install; it falls through to the UnpinnedNodeRefused fail-closed path. - The package-data test uses pytest.importorskip(tomllib/tomli) so it does not ModuleNotFoundError on the supported 3.9/3.10 interpreters. * Make the transient-failure keep-existing path pin-aware The previous commit added the pinned-digest check to the existing-install short-circuit but not to the post-download-failure fallback, which still kept any runnable same-version install via existing_install_usable(). A same-version install whose recorded sha256 is not the pin could therefore be kept on a transient download failure, the exact artifact the short-circuit rejects. Refuse to keep a same-version pin-mismatched install there too; a different usable version is still kept for offline resilience. * Bump pinned default Node to the current 24 LTS (24.18.0) Node 24 LTS moved to 24.18.0; since the default channel now resolves straight to the manifest, a frozen 24.17.0 would downgrade fresh installs and make UNSLOTH_NODE_VERSION=lts refuse the current LTS as unpinned. Update default_version and all six per-arch digests (verified against the official SHASUMS256.txt), and point the test INDEX/short-circuit fixtures at the new LTS. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 2 个月前 | |
Studio runtime keeps the GPU when nvidia-smi cannot answer, and prefers a GPU-capable llama-server beside a CPU build (#11005) * Runtime NVML memory fallback, GPU-capable llama-server preference, Windows embedding DLL path * Honour uuid CUDA_VISIBLE_DEVICES masks in the NVML fallback and prefer the GPU build this host's vendor runs * Keep a denied pinned candidate ahead of the GPU-capable reorder for PR #11005 * An ambiguous uuid abbreviation hides every GPU; the real-host probe test allows a card with no free memory * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * List MIG slices in the probe so a MIG assignment is honoured, and prefer only backends this process can reach, for PR #11005 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hand a uuid mask back to the child as uuids, keep a known-empty vendor set, void the probe on a missing handle, for PR #11005 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the devices before an invalid mask entry, prefer the GPU build in the legacy in-tree fallback, read the CUDA mask for HIP, for PR #11005 * A MIG parent is its first slice to CUDA: report the slice memory and pin the child to it, for PR #11005 * A MIG slice the mask names stays that slice, for PR #11005 * One MIG slice per card, and the uuid map is reset for every memory query, for PR #11005 * Apply the repository kwarg spacing, for PR #11005 * Read the WSL2 runtimes behind /dev/dxg as the GPU vendor, for PR #11005 * Derive the child's uuid pin from the NVML rows on each call, let a GPU build for another vendor yield, skip the Linux path test on Windows, for PR #11005 * Make the embedding and sibling-build tests independent of the host, for PR #11005 * Tell the probe child to emit utf-8, and pin the embedding test off Apple Silicon, for PR #11005 * A visible NVML row without a memory reading voids the answer, for PR #11005 * The two ROCm visibility masks stack when deciding whether AMD is reachable, for PR #11005 * A full card is a reading; only a missing total voids the NVML answer, for PR #11005 --------- Co-authored-by: danielhanchen <unslothai@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 3 天前 | |
ci: advisory lockfile supply-chain audit (no install-script changes) (#5604) * ci: add advisory lockfile supply-chain audit Adds a fast, focused workflow that scans every checked-in npm and cargo lockfile on PRs touching one. Default behaviour is advisory: only public indicator-of-compromise strings, versions on the public known-malicious list, and structurally broken lockfiles fail the build. Structural anomalies (missing integrity hashes, non-default registry, etc.) surface as ::warning:: annotations without gating merges, so reviewers see the audit result inline on every PR without changing the existing install behaviour. Also commits the two missing npm lockfiles the audit needs: studio/package-lock.json (Tauri CLI holder for desktop release) and studio/backend/core/data_recipe/oxc-validator/package-lock.json (oxc-parser runtime for the data-recipe validator). studio/setup.sh, studio/setup.ps1, build.sh, and pyproject.toml are intentionally left alone so the existing install path keeps working unchanged. Audit script behaviour: default mode -> exits 1 only on blocked-known-malicious, known-ioc-string, malformed-lockfile, missing-lockfile, unreadable-lockfile, or missing-toml-parser --strict -> promotes every finding to blocking (opt-in) Adds a try/except around lockfile reads so a permissions error prints a finding instead of crashing CI with a raw traceback. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test(security): update cargo regression test for advisory mode `scripts/lockfile_supply_chain_audit.py` now classifies `non-registry-cargo-source` as an advisory finding by default (returns exit 0 with a `::warning::` annotation) rather than unconditionally blocking with exit 1. Update the existing `test_malicious_cargo_lockfile_refused` to pass --strict so it keeps verifying the "refuse to install" behavior it is named for, and add a second test that pins the default-mode behavior: advisory finding emitted, exit code 0. * audit: escape Finding for GH Actions annotations `::warning::` and `::error::` workflow commands truncate the annotation message at the first newline unless the message is %-encoded per the workflow-commands spec. Since `Finding.__str__` returns three lines (kind+path, package, detail), the package and detail fields were being dropped from the GitHub Actions UI. Add a `_gha_escape()` helper that applies the spec'd escapes (`%` -> `%25`, then `\r` -> `%0D`, then `\n` -> `%0A`; the `%` replacement must happen first so the subsequent escapes are not double-encoded), wrap every Finding rendered into a workflow command with it, and pin both the helper and the end-to-end single-line emission with two new regression tests. Caught by gemini-code-assist on PR #5604. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 3 个月前 | |
ci: advisory lockfile supply-chain audit (no install-script changes) (#5604) * ci: add advisory lockfile supply-chain audit Adds a fast, focused workflow that scans every checked-in npm and cargo lockfile on PRs touching one. Default behaviour is advisory: only public indicator-of-compromise strings, versions on the public known-malicious list, and structurally broken lockfiles fail the build. Structural anomalies (missing integrity hashes, non-default registry, etc.) surface as ::warning:: annotations without gating merges, so reviewers see the audit result inline on every PR without changing the existing install behaviour. Also commits the two missing npm lockfiles the audit needs: studio/package-lock.json (Tauri CLI holder for desktop release) and studio/backend/core/data_recipe/oxc-validator/package-lock.json (oxc-parser runtime for the data-recipe validator). studio/setup.sh, studio/setup.ps1, build.sh, and pyproject.toml are intentionally left alone so the existing install path keeps working unchanged. Audit script behaviour: default mode -> exits 1 only on blocked-known-malicious, known-ioc-string, malformed-lockfile, missing-lockfile, unreadable-lockfile, or missing-toml-parser --strict -> promotes every finding to blocking (opt-in) Adds a try/except around lockfile reads so a permissions error prints a finding instead of crashing CI with a raw traceback. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test(security): update cargo regression test for advisory mode `scripts/lockfile_supply_chain_audit.py` now classifies `non-registry-cargo-source` as an advisory finding by default (returns exit 0 with a `::warning::` annotation) rather than unconditionally blocking with exit 1. Update the existing `test_malicious_cargo_lockfile_refused` to pass --strict so it keeps verifying the "refuse to install" behavior it is named for, and add a second test that pins the default-mode behavior: advisory finding emitted, exit code 0. * audit: escape Finding for GH Actions annotations `::warning::` and `::error::` workflow commands truncate the annotation message at the first newline unless the message is %-encoded per the workflow-commands spec. Since `Finding.__str__` returns three lines (kind+path, package, detail), the package and detail fields were being dropped from the GitHub Actions UI. Add a `_gha_escape()` helper that applies the spec'd escapes (`%` -> `%25`, then `\r` -> `%0D`, then `\n` -> `%0A`; the `%` replacement must happen first so the subsequent escapes are not double-encoded), wrap every Finding rendered into a workflow command with it, and pin both the helper and the end-to-end single-line emission with two new regression tests. Caught by gemini-code-assist on PR #5604. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 3 个月前 | |
Windows on ARM: install the native ARM64 CUDA stack on NVIDIA hosts (#10282) * Windows on ARM: install the native ARM64 CUDA stack on NVIDIA hosts NVIDIA's Windows-on-ARM parts (GB10 / N1X, the RTX Spark laptops) run CUDA natively, but the installer treated every ARM64 Windows host as "no GPU wheels here" and fell back to x64 Python under emulation. That fallback looks like it works: torch loads and sees the GPU. It then fails on the first kernel, because triton-windows' bundled ptxas is built for the x64 CUDA 12.8 toolkit and cannot target the sm_121 these parts report, so every Unsloth kernel fails to compile. llama.cpp separately picked a CPU-only bundle regardless of the GPU. Detection is evidence based rather than arch based: the installer asks the wheel indexes what actually exists for this interpreter, and only takes the native path when both a win_arm64 CUDA torch and an obtainable pyarrow are found. Anything else keeps the historical x64 path, so a host that cannot be served natively is never left with a half configured venv. The torch index is probed in order: download.pytorch.org first, so this special case ends by itself the day PyTorch publishes these wheels, then NVIDIA's GA channel, then its nightly. Requirement pins with no win_arm64 wheel are split on platform_machine == "ARM64" rather than loosened. Every other platform (linux x86_64 and aarch64, win x64, macOS arm64 and x86_64) evaluates to the pins it had before, checked by evaluating the markers per platform as well as by the test suite. Also: * llama.cpp prebuilt selection learns a windows-arm64-cuda install kind and falls through to upstream's win-cuda-13.4-arm64 asset, so these hosts get a CUDA bundle instead of the CPU one. When neither is hash gated the fallback is still taken, but says so in the log rather than silently. * setup.ps1 keeps an installed cu134 torch on win_arm64 instead of "repairing" it to a cu130 that has no wheel for this platform, and asks the chosen index whether it publishes torchaudio instead of assuming it does not. * install.ps1 checks before the studio handoff that the installed package understands this host, and stops with an explanation if it does not. The two halves ship through different channels (the script is fetched from unsloth.ai, the package comes from PyPI), and an older setup.ps1 handed a working +cu134 torch will reinstall a cu130 that does not exist here, leaving the environment with no torch at all. UNSLOTH_WOA_NATIVE=0 opts back out to the x64 path. Verified on an RTX Spark N1X laptop: clean install into an empty UNSLOTH_STUDIO_HOME ends EXIT=0 with torch 2.14.0+cu134, torchvision 0.29.0, torchaudio 2.11.0, triton-windows 3.8.0.post28 and the llama-b10715-bin-win-cuda-13.4-arm64 bundle; LoRA and QLoRA both train; Studio serves and reports CUDA; Qwen3.8-27B-GGUF UD-Q4_K_XL answers at 8.1 tok/s with the GPU at 94-95 percent through generation. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: key the new filters on the interpreter, not the machine Review of the branch turned up one real regression and several sharp edges. All of them are on paths this PR added; none change what a host did before it. The regression. `_is_windows_arm64()` answers for the MACHINE, and says yes even under an emulated x64 Python -- its docstring says so, since that is what it is for. But the requirement markers this PR adds key on `platform_machine == "ARM64"`, which is the INTERPRETER's arch, and every Windows-on-ARM install predating native support is running an emulated x64 interpreter, because install.ps1 deliberately fetched one there. So on exactly those existing installs the two mechanisms disagreed: the marker rows correctly selected the x64 pins while the new skip list dropped ten packages (xformers, tiktoken, tensorboard, librosa, openai-whisper, hf_transfer, sqlite-vec, mecab and the rest) that have working win_amd64 wheels and install fine today. Anyone taking the new UNSLOTH_WOA_NATIVE=0 escape hatch would have hit it too. `_is_win_arm64_interpreter()` answers the other question, from `sysconfig.get_platform()` -- the same authority pip and uv tag wheels with -- and the two call sites this PR added now use it. The pre-existing uses of the machine predicate are left alone, so nothing outside this PR shifts. The rest: * setup.ps1 answers `Test-WinArm64Venv` from the machine arch before launching any interpreter. The callers sit in the stale-venv path, which on x64 previously executed nothing at all; probing a venv's python there to learn something already known gave a corrupt or lock-blocked interpreter a chance to hang the install. * `$expectedKinds` gains windows-arm64-cuda only on the arch that can carry it. Accepting it on x64 would keep an install that machine cannot run, where today it is removed as mismatched. * The wheelhouse reuses a staged wheel only if it opens as a zip. An interrupted run leaves a truncated file, and skipping on mere existence handed the resolver that same corpse on every retry. Both downloads also take a timeout now. * UV_OVERRIDE / UV_FIND_LINKS / PIP_FIND_LINKS are cleared when they point into this StudioHome's own woa directory, so a second `irm | iex` in one shell cannot inherit a previous run's wheelhouse. Matched by path rather than cleared outright: an inherited UV_FIND_LINKS that a user set for their own wheelhouse is left alone, since clearing it would break air-gapped installs on every Windows host. * hf_transfer in no-torch-runtime.txt and cryptography in studio.txt (and its mirror in pyproject) get the same marker split as the other rows. That file installs --no-deps and the extra is installable directly, so neither was covered by the constraints file. * `windows_cuda_arch_for_host` tests is_windows as well as is_arm64. Every caller is already inside a Windows branch, but `is_arm64` is also true on macOS arm64 and Linux aarch64, so the answer should not depend on the caller remembering that. * The published legacy-minor scan uses the arch being resolved instead of a hardcoded x64, which was harmless only while no arm64 CUDA artifact existed. * No `windows_cuda_selection` log line on x64, where the arch is the unspoken default and the line would be new output in every existing install log. Verified: requirement markers resolve identically to origin/main across 65 environments (linux x86_64/aarch64/arm64/armv7l/ppc64le/s390x, win32 AMD64/x86/ARM64EC, darwin arm64/x86_64, freebsd, python 3.10 to 3.14), differing only on win32/ARM64; the studio-extra mirroring guard passes; both PowerShell files parse; and a simulated interpreter matrix confirms the win_arm64 skip list applies on a native ARM64 interpreter and on nothing else, including the emulated x64 case that regressed. * Keep the cross-platform parity greps matching Two parity tests read install.ps1 and setup.ps1 as text, and both changes in this PR moved the strings they look for. The behaviour they guard is intact in each case, so the fix is on this side rather than in the assertions. test_install_ps1_bounds_unknown_leaf_pinned_torch pins the literal "@_torchSpecs --default-index $TorchIndexUrl", which is how it checks that the bounded companion specs actually reach uv. The Windows on ARM branch inserted @_torchExtraArgs between the two halves. Those arguments are all flags and their values, never positional packages, so uv accepts them anywhere on the line; moving them after the index restores the literal and changes nothing about the resolve. test_setup_ps1_scrub_covers_pip_fallback slices a fixed 2500 character window from "function Fast-Install" and requires the pip fallback to appear before the finally that restores the scrub. The flag stripping loop added here is sixteen lines and pushed the finally out of that window: the fallback is still inside the try, but the test could no longer see it. Lifting the loop into Remove-UvOnlyResolverFlags puts Fast-Install back near its original size, with the finally at offset 1695 against a 1629 baseline on main. The helper was exercised against the inlined version it replaces on all six shapes: no flags, --index-strategy in both spellings, --prerelease in both spellings, and an empty list. Output is identical, including the empty-in empty-out case that every caller in the tree takes today. * Exempt the Windows on ARM drop list from the xFormers pin check test_installer_never_installs_an_unpinned_xformers flags every quoted xformers literal in install.ps1 that is not the exact pin. The Windows on ARM block added one, but it is a removal list: those names are written into the requirement overrides so the package vanishes from the resolve, since win_arm64 has no xFormers wheel and no buildable sdist. A version pin there would be meaningless, and the name has to match the requirement line exactly for the override to bind. The test already carves out wheel filenames on the same grounds, that a filename is not a spec. This adds the drop list beside it rather than loosening the assertion. Checked that it stays narrow: a bare "xformers" appended at end of file is still caught, and so is a loose "xformers>=0" placed on the line immediately above the drop list, while the real file is clean. * Windows on ARM: fix six ways the native stack picks the wrong thing Six defects found in review, all on the new WoA path and none reachable on any other host. Probe the interpreter that was actually selected. Initialize-WoaNativeCudaTorch answered for $PythonVersion, but Find-CompatiblePython falls back to another supported minor whenever the requested one is not installed. Every native decision is keyed to an interpreter tag, so a 3.13 answer carried into a 3.12 venv staged a cp313 pyarrow wheel and then pinned pyarrow== to it, forcing the Arrow sdist this path exists to avoid. Re-probe once the interpreter is settled, key the staged tags on the venv's own minor, and clear every script-scope answer on entry so a second probe cannot inherit the first one's. Redact the selected torch index. The status line printed $script:WoaTorchIndexUrl verbatim, and it can be UNSLOTH_TORCH_INDEX_URL with userinfo or a token query. It was the only index line in the file that did not redact. Remove-IndexUrlCredentials moves up to the other helpers, since PowerShell resolves a function only once its definition has executed. Hand uv a space-free override path. uv reads UV_OVERRIDE as a space-separated list of files, which this script splits on \s+ itself, and the default StudioHome sits under %USERPROFILE%. An overrides file under a path with a space was read as two paths, neither of which exists, and every later uv call failed with "File not found". Same defect and same remedy as backend/utils/uv_path_safety.py (issue #6503), which the Python half already applies. pip splits its repeatable options the same way; UV_FIND_LINKS is comma-separated and is untouched. Do not enable the native stack for an x64 venv. The migration branches keep a healthy legacy environment as it is, and on this host those are x64 by design. Get-TorchIndexUrl returns the win_arm64 index on the flag alone while the spec lift is gated on the venv's platform tag, so a migrated venv was asked for the bounded x64 specs from an ARM64-only index and aborted with no fallback. Ask the venv the same question the spec branch asks. Accept every spelling of a local wheelhouse. UNSLOTH_WOA_WHEELHOUSE is documented as taking a directory or a base URL, but only drive-plus-backslash and UNC counted as local, so C:/wheels went to Invoke-RestMethod and the native path was dropped on a host whose wheels were present. Ask the filesystem instead. Count a wheelhouse wheel only for the interpreter it was built for. Staging copies every win_arm64 wheel, cp311 through cp314, but both availability checks keyed on the distribution name alone. A cp311 tiktoken in a cp313 venv cleared its skip and its requirement override, and the resolve fell to an unbuildable sdist. Filter on PEP 425 tags on both sides. WINDOWS_ARM64_SKIP_UNBLOCKED_BY takes the whole blocker set rather than one name, since librosa needs numba as well as llvmlite and whisper needs tiktoken too; its openai-whisper key was also never matched, being looked up canonically. Let an explicit CPU pin through the CUDA preservation guard. It ran ahead of every pin check on both sides, so UNSLOTH_TORCH_INDEX_URL=.../cpu was dropped even though download.pytorch.org publishes win_arm64 /cpu torch and torchvision. In setup.ps1 it also left PinChangedForceReinstall false, which skips the dependency pass entirely, so the run kept the CUDA build and reported success. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin that an emulated x64 interpreter still gets the torch flavor repair The CUDA preservation shortcut reads the interpreter, not the machine, and nothing in the suite held it to that. Swapping _is_win_arm64_interpreter for _is_windows_arm64 at the guard leaves 223 of 224 tests here passing, which is the whole margin between a correct guard and one that strands every Windows on ARM install predating native support. Those run an emulated x64 python against ordinary win_amd64 wheels from download.pytorch.org, so the repair has somewhere to go and must still reach them. The fixture drove both predicates from one flag, which is right for every case already here, since a native ARM64 venv is the only place both are true. They separate on an ARM64 machine running an x64 interpreter, so win_arm64_interpreter overrides just that half and defaults to the existing flag. Verified by mutation: with the guard reading the machine instead, the new test is the only one of the 224 that fails. * Windows on ARM: pick the retained llama.cpp kind from the venv arch $expectedKinds asked Get-HostMachineArch, which answers for the machine. A Windows-on-ARM host on the x64 fallback runs the prebuilt helper under an emulated x64 python and installs windows-cuda, so the machine's answer would call that bundle mismatched and discard a working runtime. Test-WinArm64Venv is the same predicate the torch path uses, and it short-circuits free on x64. The block stays inert either way: write_prebuilt_metadata persists no install_kind, so $existingKind is always null. * Windows on ARM: pin the marker split, drop a write-only variable Cross-platform verification of the requirement splits, plus one dead variable the earlier fix left behind. WoaPythonMinor was written in three places and read in none. It was the variable the staging tag was meant to use; that moved to WoaVenvMinor, which is keyed on the venv's own interpreter. Leaving a write-only "the minor we probed" next to the real one only invites a future reader to take the stale answer. test_windows_arm64_requirement_markers pins the mechanism the whole split rests on. Every package needing a different version on win_arm64 is two rows whose markers are exact complements, so exactly one is live everywhere. Both ways of breaking that are silent: an overlap makes pip intersect two specifiers and can render the row unsatisfiable, and a gap drops the package on a platform nobody tested. The tests evaluate every row of every requirements file and every pyproject extra across 60 environments. Two of the checks exist because a scope-limited test skips itself when the bug appears. Spell the machine "arm64" rather than "ARM64" and the conjunction with sys_platform == "win32" becomes unsatisfiable, the split stops existing, and any check that looks for a split finds nothing to look at; Windows on ARM quietly takes the x64 pins and source-builds. That is caught semantically, by asserting no row is dead in every environment, rather than by matching marker text -- torchcodec legitimately pairs 'arm64' with sys_platform == 'darwin' in a separate disjunct. And the expected rows are asserted per source file, because studio.txt and pyproject[studio] mirror each other, so a global check stays green when only one of them loses a row. Verified by mutation: overlapping rows, a lowercase arm64 in either file, aarch64 for ARM64, a deleted WoA row, and either deliberate exclusion (xformers, hf_transfer) losing its marker are each caught, and each by a different assertion. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: close seven gaps the last round left open All seven are on code the previous rounds added, which is where a fresh pass should look. The resolver paths have to be readable by uv or the native stack is not viable. uv splits UV_OVERRIDE on whitespace and accepts no quoting of any kind -- bare, "quoted", 'quoted' and back\ slashed all fail identically on uv 0.10.7 -- so the 8.3 short form was the whole mitigation, and 8.3 generation can be disabled on the volume. A spaced StudioHome then has no space-free spelling and every later uv call dies on a truncated path. Decide that up front and take the x64 path, which never sets these variables, rather than staging wheels into a venv that cannot resolve. Same rule the pyarrow probe already applies. That short form then has to be recognised on the way back in. The block that drops a previous run's resolver variables matched only the long spelling, so after a native install under a spaced home it no longer recognised its own values and the next run in that shell inherited them. Both spellings are checked; a wheelhouse the user set for themselves is still left alone. The probe follows the interpreter that ends up owning the venv. Re-probing for the minor Find-CompatiblePython settled on was not enough: the ARM64 bootstrap below it installs $PythonVersion, which can be a third minor again, and the index and pyarrow source would both have been confirmed for one no longer in play. setup.ps1 is told which index install.ps1 used. It otherwise derives the CUDA index from the driver, and download.pytorch.org publishes no win_arm64 CUDA wheel at all, so a repair or a missing companion on a native venv resolved against an index that cannot serve it. Passed as UNSLOTH_WOA_SELECTED_TORCH_INDEX rather than UNSLOTH_WOA_TORCH_INDEX_URL, which is the user's input override: a second run in the same shell would otherwise read this run's choice as a pin. A wheel's ABI has to agree, not just its interpreter tag. cp313-cp313t is the free-threaded build and uv rejects it on a regular cp313, so counting it as available dropped the package's override and sent the resolve to the sdist the override existed to avoid. The Python half already checked this; the PowerShell half did not. Blockers decide even when the package's own wheel is hosted. tensorboard and librosa publish py3-none-any wheels that the staging step copies, so a wheelhouse can hold the package and still lack grpcio or numba; unskipping it there walks straight into the blocker's sdist. UNSLOTH_LLAMA_ARM64_CUDA=0 is honoured on the direct-upstream path too. It was checked only in the fork resolver, so the documented escape hatch silently did nothing with --published-repo ggml-org/llama.cpp. Each fix is mutation-tested: reverting either Python-side change fails a specific new test, and the PowerShell changes are asserted structurally and by executing the extracted helpers. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: build probe URLs with Join-UrlPath A tokenized mirror is a supported form for UNSLOTH_TORCH_INDEX_URL and for the wheelhouse, and "$base/$leaf" puts the leaf inside the token value: https://mirror/whl?token=abc became .../whl?token=abc/torch/, so the preflight read the mirror root or an error and disabled the native stack on a host whose wheels were there. Join-UrlPath already solves this for the xFormers URLs; it moves up next to the other Windows-on-ARM helpers, since PowerShell resolves a function only once its definition has executed and these probes run far above its old position. Applied to the torch/torchaudio preflight and to all three wheelhouse URLs, which had the same shape. * Windows on ARM: abandon the native route before building, and carry the index across shells Two review findings on the previous head. Abandon the native route earlier. The check for a StudioHome uv cannot read -- a path containing a space on a volume with 8.3 short names disabled, which uv splits UV_OVERRIDE on -- sat next to the resolver files it guards, which is after the ARM64 venv has been created. The install therefore paid for a venv it then tore down. Test-WoaResolverPathsUsable now runs inside Initialize-WoaNativeCudaTorch, immediately after the GPU probe, so the route is never chosen in the first place and every re-probe honours it. The verdict is cached, so a user with several interpreters reads the explanation once. Carry the chosen index across shells. install.ps1 exports the index it probed, but a later `unsloth studio update` runs in a fresh shell where that variable is gone, and Windows on ARM is the one platform whose CUDA wheels are not on download.pytorch.org at all, so the index cannot be re-derived from the driver. The manifest now records it, and setup.ps1 falls back to it when the handover variable is absent. That is a deliberate exception to the rule install_manifest.py documents for itself -- the flavor, never the index URL it came from, since a pinned index can carry a token -- so it is narrowed to the case that motivated it. Only credential-free https://pypi.nvidia.com URLs are written, with no userinfo, query or fragment, and setup.ps1 re-checks the same thing on read so a hand-edited manifest cannot redirect a torch install to another host. A mirror the user pinned is deliberately not persisted; they supply it through their own environment as before. tests/studio/install/test_woa_torch_index_persistence.py covers both halves of the guard, the Python that writes and the PowerShell that reads, including the older manifests that carry no such key and the truncated ones a killed installer leaves behind. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: restore the generated requirement overrides on a fresh-shell update The torch index was not the only thing install.ps1 hands over through a process-scoped variable. It also writes StudioHome\woa\overrides.txt, stages a win_arm64 wheelhouse beside it, and exports both through UV_OVERRIDE / UV_FIND_LINKS / PIP_FIND_LINKS. A direct `unsloth studio update` runs in a fresh shell where all three are gone, so the dependency pass resolved without them. That matters because studio.txt pulls ddgs, which requires httpx[brotli,http2,socks], whose brotli extra is Brotli on CPython, and neither Brotli nor brotlicffi publishes a win_arm64 wheel. Confirmed against the published metadata and against the resolver: uv pip compile for httpx[brotli]==0.28.1 targeting aarch64-pc-windows-msvc resolves 8 packages including brotli==1.2.0, and 7 with the overrides applied. With no wheel to install, those 8 mean an sdist build of a C extension -- the exact failure this path exists to avoid. Only reachable since this PR: an emulated x64 venv has a win_amd64 brotli wheel. Restore-WoaResolverEnvironment sits next to the torch-index recovery so both halves of the lost handover are put back in one place, before the torch installs and the dependency pass, which is where install.ps1 has them set for its own run. It restores rather than re-derives: both files persist under StudioHome\woa\, and the generated file is the only accurate record of what was dropped, since the drops depend on what the wheelhouse turned out to hold. No-op unless Test-WinArm64Venv, and it returns untouched if any of the three variables is already set, so install.ps1 driving the resolve in the same process is never clobbered. A native venv whose overrides file was deleted warns and points at install.ps1 rather than guessing the list. Get-UvSafePath is duplicated from install.ps1 because neither script can dot-source the other; a test asserts the two copies stay identical, since a copy that drifts would hand uv a path install.ps1 had already ruled unreadable. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: re-export the recovered index, restore each resolver variable, and cover no-torch Three follow-ups on the fresh-shell recovery. Re-export the recovered index. install_python_stack.py rewrites the manifest at the end of every run from UNSLOTH_WOA_SELECTED_TORCH_INDEX, so recovering the index into a local variable only meant the first fresh-shell update wrote a manifest without it -- erasing the one thing the manifest exists to carry, and making the whole mechanism work exactly once. It is now put back in the environment right after it is recovered, guarded so an empty recovery exports nothing. The value is always one install.ps1 chose or one write_manifest's credential-free allowlist already accepted, so re-exporting widens nothing. Restore each resolver variable independently. Returning when ANY of the three was set was aimed at the install.ps1 case, where all three arrive together, but it also fired for a shell carrying an unrelated UV_FIND_LINKS or PIP_FIND_LINKS -- a corporate wheel mirror has nothing to do with the win_arm64 drop list, and should not cost the user the brotli exclusions. Each is now restored on its own and a caller's value is still never overwritten, so the install.ps1 path is unchanged: every branch no-ops there. Cover no-torch mode. Both recoveries sat inside `if (-not $NoTorchMode)`, and neither is about torch. install_python_stack.py installs studio.txt at function scope with no NO_TORCH gate, and ddgs is not in NO_TORCH_SKIP_PACKAGES, so UNSLOTH_NO_TORCH=1 resolves the same brotli chain; the manifest is rewritten in that mode too. Both have moved above the guard. The torch-index USES stay inside it, where they belong. The new tests assert the placement rather than the text, and one asserts the studio.txt pass is still unconditional so this reasoning cannot go stale unnoticed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: read the manifest before dropping it, publish the index torch came from Three follow-ups, the first of which made the persistence added earlier a no-op. Read the index before the manifest is deleted. setup.ps1 drops unsloth_install_manifest.json before it replaces pip, torch and triton, so a run killed in those leaves the venv marked half-built rather than behind a marker that verifies. The recovery was placed below that drop and read a file that no longer existed, so it returned empty every time -- on precisely the fresh-shell path the persisted index exists to serve, and silently, since empty is also the correct answer for an older install that recorded nothing. The recovery now runs as soon as the dependency guard opens, before the drop. It stays inside that guard: a run that installs nothing has nothing to recover for. Publish the index torch was actually installed from. The native path resolves from the channel install.ps1 probed, while $TorchInstallIndexUrl still names the driver-derived family, so the handoff sent install_python_stack.py's repair to an index with no win_arm64 CUDA wheel and named the flavor cu130 when the installed wheel is +cu134 -- a repair could have tried to correct a working venv to a build that does not exist for this platform. $_effectiveTorchIndexUrl is initialised to $TorchInstallIndexUrl and overwritten only by the branch that installs torch, so every other path publishes exactly what it published before. On the NVIDIA channel the leaf is nvtorch_oot, not a CUDA family name, so no flavor tag is published at all -- the honest answer, and what the block already documents for an unknown leaf. Gate the llama.cpp ARM64 CUDA branch on the opt-out. resolve_upstream_asset_choice is reached through resolve_asset_choice on the fallback paths, so leaving it ungated meant UNSLOTH_LLAMA_ARM64_CUDA=0 worked in direct_upstream_release_plan and silently did not work here. The same gate is already on the equivalent branches at lines 1126 and 3897. The new tests assert ordering and wiring rather than the function in isolation, which is what let the first of these through. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: rebuild a migrated x64 venv as ARM64, and widen the llama.cpp CUDA opt-out Rebuild a migrated legacy environment. The migration branches keep a healthy ~/.unsloth/studio/.venv exactly as it is, and on a Windows-on-ARM host those are x64 by design: every install predating the native stack deliberately bootstrapped an emulated x64 interpreter. The venv-platform guard then saw win-amd64 and stood down to that same stack, so an upgrade left an NVIDIA user on a Triton that cannot compile for sm_121. Only a SECOND installer run recovered, because migrating makes the layout new and the new-layout branch already does preserve-and-recreate. Do that in the first run instead. Just above the venv-creation block, a migrated environment that is not win-arm64 is preserved with the same Start-StudioVenvRollback and the existing creation path builds an ARM64 venv. Scoped to a venv THIS run migrated: a new-layout venv was already moved aside, and one created moments ago came from the interpreter this run chose. $_Migrated is cleared on success. It gates an upgrade-in-place far below that installs unsloth with --no-deps --reinstall-package; left set, that would run against a freshly created empty venv and leave an unsloth with no dependencies. If the rollback throws, the environment is kept and the existing guard disables native exactly as before -- a user's environment is not worth trading for a native stack, so the platform guard keeps the final say. Widen UNSLOTH_LLAMA_ARM64_CUDA to every ARM64 CUDA bundle. This is a deliberate scope change, not only a bug fix. The helper documented itself as governing the upstream bundle when the fork publishes no checksum, but what the flag promises the user is the CPU bundle, and gating only the unverified tail meant the escape hatch would switch itself off the day the fork published an approved windows-arm64-cuda artifact, since the published branch returns first. The docstring now says what the code does. The inner check inside that branch is unreachable once the branch is gated, so it is gone rather than left reading as a fallback that no longer exists. The opt-out tests walk the AST rather than grepping, because the same attribute guards the x64, Linux and macOS paths, which must not be gated: they assert both that every Windows-ARM64 NVIDIA branch is gated and that no other one is. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: gate the pandas 3 row on Python 3.11, and keep the CPU llama bundle when CUDA is opted out pandas 3 needs Python 3.11. Every pandas 3.0.x is requires-python >=3.11 and its win_arm64 wheels start at cp311, which is why the Windows-on-ARM row exists at all -- but the row carried no python_version bound while requires-python still admits 3.9 and 3.10, so `pip install unsloth[studio]` was unsatisfiable on an ARM64 3.9 or 3.10 interpreter. Split on python_version as well as platform, which keeps the partition exact: below 3.11 an ARM64 host takes 2.3.3 and source-builds, slow but resolvable, as it did before win_arm64 wheels existed. Narrowing requires-python instead would drop 3.9/3.10 everywhere to fix one platform. The same two rows appear in studio.txt and single-env/constraints.txt with the same defect, so all three move together. The 603-case marker suite passed throughout this bug's life: the partition was exact and every environment had exactly one live row -- that row just could not install anywhere. So the suite gains the axis it was missing, asserting that a live row is installable on the interpreter it was selected for, checked against the package's own requires-python, plus the complement that no ARM64 Python is left with no pandas row at all. Keep the ARM64 CPU llama.cpp bundle when CUDA is opted out. With UNSLOTH_LLAMA_ARM64_CUDA=0 the selector installs a windows-arm64 CPU bundle on an NVIDIA Windows-on-ARM host, but the mismatch check expected only windows-arm64-cuda there, so it deleted a correct install on every setup and update -- and an update that cannot download then leaves no llama.cpp at all. The previous commit made this reachable in practice by widening the opt-out to the published artifact. The expected kind now follows the flag, INSTEAD of the CUDA kind rather than as well as it: accepting both would keep a bundle installed before the flag was set, so the opt-out would appear not to work on exactly the machine that already had CUDA. Falsy spellings are _upstream_arm64_cuda_allowed's, with a test asserting the two stay identical, since this is now one contract in two languages. The x64 arm is untouched and covered as a negative control. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: pair torchaudio with torch, prove wheels are CUDA, gate prereleases on nightly Take torchaudio only as a matched pair. The GA channel serves torch 2.14.0+cu134 beside torchaudio 2.11.0+cu134, torchaudio 2.11 dropped the exact torch== pin that 2.10 still carried, and the native specs are torch>=2.4 / torchaudio>=2.4 -- so nothing stopped the resolver installing that pair and leaving torchaudio's extension to load against a libtorch three minors newer. install.ps1 already names this hazard on the bounded-trio branch; the native branch was not covered by it. The probe now reads versions rather than existence and enables torchaudio only when its major.minor pairs with torch's, the relationship the old pin encoded. No matching pair means no torchaudio, which is what every Windows-on-ARM install did before this channel published one; it lights up on its own the day NVIDIA ships a matching pair. Establish CUDA positively. The probe rejected a literal "+cpu", which misses two things: PEP 503 hrefs encode "+" as %2B, and PyPI's own win_arm64 torch wheels carry no local version at all, so they are not "+cpu" and were read as CUDA -- sending the host native on CPU-only torch, which is worse than staying emulated because the GPU then goes unused with no fallback. Filenames are unescaped and must carry a +cuNNN local version. A private index whose CUDA wheels carry no +cu tag now reads as unknown and takes the x64 path: a behaviour change, and the safe direction, since that path works. Gate --prerelease=allow on the nightly channel, as install.ps1 already does. setup.ps1 passed it unconditionally and its comment ("the wheels are nightlies") predated the GA channel. "allow" means every prerelease, and it rode on a command carrying unsafe-best-match and an explicit PyPI extra index, so a prerelease of torch or of any shared dependency could outrank the stable GA build on every setup and update. The other two flags stay unconditional; the GA channel needs them. Verified against both live NVIDIA channels (temp/sim10282/probe_test.ps1) and covered offline with stubbed index pages for the parsing and pairing rules. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: five resolver and validation fixes Reject abi3 wheels on free-threaded interpreters. The exact-minor branch accepted "abi3" unconditionally, shadowing the guarded branch below it, so cp313-abi3 was installable on 3.13t while cp311-abi3 was correctly refused. Free-threaded builds do not implement the stable ABI (CPython #111506, PEP 703), so this marked a blocker available, dropped its skip and sent the resolver at a wheel it cannot use. PEP 803's abi3t is 3.15 and outside requires-python, so it is documented rather than matched. The test simulates both builds instead of reading whichever interpreter runs the suite. Validate UNSLOTH_PYARROW_WHEEL. Every other branch of Get-WoaPyarrowSource checks the interpreter and platform tags; this one took any file that existed, so an x64 wheel, a wheel for another minor or a truncated download selected the native path and then failed at resolution, having already given up the working x64 stack. Same checks as the siblings plus the zip signature, read two bytes off a stream rather than loading tens of megabytes. Keep the caller's resolver configuration. The purge above deliberately preserves anything that is not this StudioHome's own, and the assignments then overwrote it. Folded, not appended: uv combines override files rather than letting a later one win, and two files naming one package without distinguishing markers is an error -- and this file declares torch and torchvision. Same shape as New-UnslothTorchOverridesFile. Find-links have no such conflict semantics, so those are appended with the caller's kept and ours first. install_python_stack.py split find-links on os.pathsep alone, which would have read an appended "dirA,dirB" as one unusable path; it now splits on comma, whitespace and os.pathsep. The generated file is UTF-8 now that a caller's line can be non-ASCII. Accept the automatic ARM64 CPU fallback. resolve_asset_choice falls through to the published windows-arm64 bundle when no ARM64 CUDA asset exists, so demanding the CUDA kind deleted a healthy fallback every run. Widening strands nobody: the installer's already-satisfied check is per candidate and CUDA is attempted first, which a test now pins. The opt-out arm stays exclusive. Make the manifest writer and reader accept the same URLs. urlsplit().hostname strips the port, so https://pypi.nvidia.com:443/... was persisted and then refused by setup.ps1, losing the index on the next fresh-shell update. The writer now requires netloc == hostname, admitting exactly what the reader admits and dropping userinfo with it. Declining is preferred to silently rewriting a caller's URL; the default channels carry no port. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: probe and stage the interpreter's ABI tag, not just its minor A free-threaded build installs cp313-cp313t wheels, but every probe here derived its tag from the minor alone and searched for cp313-cp313. The two halves then failed in opposite directions: the index probe found the ordinary GIL wheels and enabled the native stack, while the wheelhouse staging kept exactly those wheels and discarded the cp313t ones the venv could have installed -- all after the x64 fallback had been given up, so the run failed at resolution with nowhere to go. Reachable: Find-CompatiblePython's python3/python PATH scan and its `py -0p` enumeration both accept any interpreter whose --version matches, which a free-threaded build does, and the native path ranks ARM64 builds first. Test-PythonFreeThreaded asks the selected interpreter for Py_GIL_DISABLED and Get-WoaAbiTag turns the answer into cp313 or cp313t, threaded through the torch, torchaudio and pyarrow probes and through the staging scan, which also records the venv's own ABI. A free-threaded host therefore declines the native path today, because no index publishes those wheels, and starts working by itself the day one does -- better than refusing such interpreters outright. Unknown answers GIL, the historical assumption, so nothing moves for a GIL host: there the two tags are the same string. The staging scan also stops accepting abi3 on a free-threaded venv, the rule already applied to _wheel_matches_interpreter, so the two now agree. Verified against the live GA channel: a GIL 3.13 still resolves torch 2.14.0+cu134 and a free-threaded 3.13 finds nothing. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: re-probe on ABI change, merge caller overrides, honour an explicit pin Re-probe when the ABI changes, not only the minor. The previous round threaded the interpreter's ABI through every probe but left the re-probe guard keyed on the minor alone -- so the one case where the minor cannot change, a 3.13t chosen for the default 3.13 request, was exactly the case that never reached the call passing -FreeThreaded. The initial probe's GIL assumption stood, native mode was enabled on cp313 wheels, and the venv was then built from an interpreter that installs cp313t. $WoaProbedFreeThreaded is tracked beside $WoaProbedMinor and either changing re-probes, including after Install-PythonFromPythonOrg, where the ABI can change while the minor does not. Detection is scoped to an ARM64 host so no other Windows host spends a subprocess on an answer that cannot matter there. Merge caller overrides on an update instead of standing down. install.ps1 folds; setup.ps1 skipped the generated file entirely whenever UV_OVERRIDE was set, which keeps the caller's file and loses the win_arm64 drop list -- so ddgs -> httpx[brotli] -> Brotli went back to an sdist with no win_arm64 wheel. uv combines override files rather than letting one win, and errors on a duplicate package, so the two cases differ: disjoint files are both handed over, needing no rewriting and leaving each file's relative references resolving against its own directory, and only an actual conflict is merged, with this platform's entries winning because they name what it cannot build. Get-RequirementName(s) do PEP 503 normalisation so Brotli and brotli_cffi compare as one name, and a merge that cannot be written falls back to the caller's file with a warning. Let an explicit pin outrank the persisted index. UNSLOTH_TORCH_INDEX_URL and UNSLOTH_TORCH_INDEX_FAMILY both land in $PinnedTorchIndexUrl, and the recovered WoA index was winning over both -- so a user switching to another CUDA mirror was silently still served by the previously recorded channel. The recovery is a memory of what install.ps1 chose, not a decision the user made, so it is now consulted only when nothing is pinned. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: keep caller resolver entries, survive a bad handoff URL, drop a stale torchaudio, check wheelhouse versions - install.ps1: the resolver purge removes only the entries pointing into this StudioHome's woa directory instead of the whole variable, so a caller's mirror appended behind ours survives a second run in one shell. - install_manifest.py: parse the WoA index URL inside a try/except ValueError; a malformed authority no longer aborts the whole manifest write. - setup.ps1: on a native ARM64 install that leaves torchaudio out of the trio, uninstall an already-installed torchaudio whose major.minor no longer matches torch. - install_python_stack.py: the find-links scan records versions, and a hosted wheel clears a win_arm64 skip only when one of its versions satisfies the pin in the requirements file being installed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: match wheel tags as fields, evaluate markers, bound the purge, version the drop list - install.ps1: one Test-WoaWheelTags parses the PEP 425 fields and expands each dot-separated tag set, replacing seven substring probes. "*cp313-cp313*" also matched cp313-cp313t, so a free-threaded wheel could select and then be staged for a GIL interpreter, failing only after the ARM64 venv was committed to. - install.ps1: the resolver purge treats only the prefix itself or a descendant past a path boundary as owned, so <StudioHome>\woa-mirror survives. - install.ps1: the override drop list records wheel versions and consults the floor the released metadata puts on the name (xformers>=0.0.22.post7) before removing a drop; a lower hosted version keeps it. - install_python_stack.py: requirement markers are evaluated for the running interpreter, so the inactive MeCab row no longer overwrites the active one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: honour any explicit pin, preflight PyAV, keep caller override files in place - setup.ps1: the CUDA-preservation shortcut exempts any explicit torch index pin, not only a /cpu one. It distrusts the inferred host-family comparison; a pin at a cu129 mirror is a stated instruction, and the narrower test skipped the dependency pass and kept cu134 while reporting success. - install.ps1: on a free-threaded interpreter, native mode requires an installable win_arm64 av (PyAV) wheel. The wheel constraints.txt asks for is cp311-abi3, which no free-threaded build can use; PyAV publishes cp314-cp314t but no cp313t, so the index is asked rather than assumed. GIL builds are unchanged and never run the probe. - install.ps1: a caller UV_OVERRIDE file that declares none of our packages is passed to uv where it sits, so its relative -r and wheel paths keep resolving. Only a conflicting file is folded, and its relative references are rebased onto the directory they were written for. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: read the machine arch from the registry, not from the process Fresh install of this branch on the GB10 laptop went down the x64 path: plain "NVIDIA GPU detected" and cu130 from download.pytorch.org, which publishes no win_arm64 wheel, with no line saying why. Running the probe's gates one at a time found it: Get-HostMachineArch answered x86_64, so Initialize-WoaNativeCudaTorch returned on its first check before printing anything, and every later gate, nvidia-smi, the NVIDIA index, the pyarrow wheel, would have passed. Every signal it trusted follows the process, and the install ran from an x64 emulated shell. There PROCESSOR_ARCHITECTURE is AMD64, PROCESSOR_ARCHITEW6432 is empty because it is a WOW64 variable and x64 emulation on ARM64 is not WOW64, and .NET Framework's OSArchitecture reports X64, a bug fixed only in .NET 5. VS Code's integrated terminal is often x64 on these machines, so this is a real user path, not a test artefact. The machine-scope PROCESSOR_ARCHITECTURE from the registry is the one value emulation cannot misreport, and it now leads the signal list in install.ps1, in setup.ps1, and in setup.ps1's Get-UvHostArch so the uv an update picks matches the one install.ps1 chose. The per-process signals stay as fallbacks; on x64 hardware every answer is unchanged. setup.ps1 is the one that made this urgent. Test-WinArm64Venv short-circuits on the same function, so from an x64 terminal an update of a native ARM64 venv would have skipped the CUDA preservation guard and repaired a working cu134 torch to a cu130 that cannot install there. The python side had the identical hole: _is_windows_arm64 read the same three signals and its docstring promised ARCHITEW6432 would be set under emulation, which it is not. It reads the registry first now. Two of its three callers drop torchaudio on the grounds that no win_arm64 wheel exists, which is a property of the interpreter, not the machine: an emulated x64 venv on an ARM64 box installs win_amd64 wheels and download.pytorch.org publishes torchaudio for those. Both move to _is_win_arm64_interpreter. The third caller prints the Vulkan note, a machine fact, and stays. Verified with the real functions lifted from install.ps1 and run under both an x64 emulated and a native ARM64 PowerShell: both now answer arm64 and select the nvtorch_oot index. Under the x64 and the ARM64 CPython on the same box the machine predicate is True for both and the interpreter predicate is True only for the ARM64 one, which is the disagreement the wheel filters rely on. Tests: the arch-probe cases patch the registry helper so they answer the same on an ARM64 host as on x64 CI, and gain the emulated case with every per-process signal saying AMD64. The Vulkan-advice fixture takes the host as a parameter instead of reading the box it runs on; three of its tests had failed on every ARM64 dev box for that reason. * Make the marker-evaluation test independent of the host running it test_pins_are_read_canonically_and_markers_evaluated wrote a row under `sys_platform != 'win32'` and asserted the pin survived because "its marker holds on this host". It holds on a Linux runner and on no Windows machine, which is every machine this file is about; on the GB10 laptop it fails with a KeyError. The reader answers True, False or None for a marker, and only False drops the row, so the test now uses one marker that holds everywhere and one that holds nowhere, and asserts both directions. Same coverage, no dependence on the box. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: prepend the managed wheelhouse, rebase merged overrides, tolerate a self-copy - setup.ps1: the resolver recovery prepends $StudioHome\woa\wheels to an existing UV_FIND_LINKS / PIP_FIND_LINKS instead of standing down. find-links are additional search locations, so skipping ours because a caller had set a mirror left the staged win_arm64 wheels out of the search and sent pyarrow to an sdist. - setup.ps1: Resolve-WoaOverrideLine is carried over from install.ps1 and every line folded into overrides.merged.txt is rebased against the caller file's directory, so a relative -r, -c, -f or wheel path keeps resolving. - install.ps1: both wheel staging copies compare normalised paths first. A wheelhouse pointed at the managed wheel directory made Copy-Item overwrite an item with itself, which under ErrorActionPreference Stop aborted the install. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: open the supplied wheel, and honour a configured win_arm64 mirror - install.ps1: UNSLOTH_PYARROW_WHEEL is validated with Test-ZipArchiveReadable rather than a two-byte PK sniff. An interrupted download still carries that signature, and accepting one selected the native path, staged the broken file and failed the pyarrow resolve with the x64 stack already given up. - setup.ps1: UNSLOTH_WOA_TORCH_INDEX_URL is read ahead of the handover variable and the manifest. write_manifest persists only NVIDIA's own channels, since any other URL could carry a credential, so a corporate win_arm64 mirror survives a fresh shell nowhere else; without this a direct update fell back to the driver-derived index, which publishes no win_arm64 CUDA wheel. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: guard the third staging copy against a self-copy UNSLOTH_PYARROW_WHEEL may point at the already-cached wheel under $StudioHome\woa\wheels, which is how an offline run reuses the managed cache. Copy-Item refuses to overwrite an item with itself; here the throw is caught, so instead of aborting it disabled native mode after the ARM64 interpreter and venv had already been chosen, leaving the run on a CUDA index with no win_arm64 torch. Test-WoaSamePath now guards all three staging copies, and a counting test keeps a fourth from being added without one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the new marker and path tests independent of the host running them Same defect as the pin-reading test: a fixture marker of sys_platform != 'darwin' or python_version < '3.14' picks the other row on a macOS 3.14 box, and literal POSIX expectations for Resolve-WoaOverrideLine are wrong on Windows, where GetFullPath returns a drive-qualified backslash path. The marker fixtures now use one marker that holds everywhere and one that holds nowhere, and the rewriter expectations are derived with os.path.abspath. * Windows on ARM: trim the commentary Comment-only pass over the branch: collapse wrapped explanations, drop restatements of the line below them, and shorten test docstrings that carried more than their first sentence. Every fact a comment was carrying is kept. The comment-only property is checked two ways: comment_tools.py for the Python files, and a PowerShell tokeniser comparison for install.ps1 and setup.ps1 (32293 and 29539 non-comment tokens, identical before and after). One test changes: test_no_staging_copy_is_left_unguarded anchored its search on a comment, so a comment pass could break it. It now anchors on the code the region starts with, and still fails when a guard is removed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: six review fixes - _version_satisfies now answers with packaging when it is importable, with prereleases off unless the specifier asks for one, which is the policy uv resolves under. A wheelhouse tiktoken-0.13.0rc1 reduced to (0, 13, 0) and read as satisfying ==0.13.0, so tiktoken was unskipped and the resolve fell to the ARM64 sdist. The numeric fallback now refuses a version it cannot model rather than guessing. - Every pyarrow candidate is floored at the ARM64 constraint (>=21.0.0). A tag-compatible 19.x in the wheelhouse selected the native path, and staging then wrote an exact pyarrow== override that the constraint rejects, after the ARM64 venv had been built. A test keeps the two numbers in step. - --prerelease=allow is decided by the version of the wheel the probe selected, not by whether the index URL says nightly. A mirror of NVIDIA's prerelease channel need not say so, and without the flag uv takes the stable win_arm64 CPU torch from the PyPI extra index instead of the CUDA build. install.ps1 hands the answer to setup.ps1, which cannot probe. - The ARM64 CUDA-preservation shortcut in install_python_stack.py exempts every explicit pin, not only a /cpu one. A user asking for cu129 by URL or family was silently left on the cu134 build. setup.ps1 already did this. - Both requirement scanners follow a nested -r. uv reads those includes, so a conflicting package one level down made two override files look disjoint; handing uv both is an error, and the caller's capped torch could win. The fold flattens the include, rebasing each line onto the file it was read from. - install.ps1's torchaudio and prerelease flags apply only while the index they were measured on is still in force. Change UNSLOTH_TORCH_INDEX_URL and re-run an update in the same shell and the pin outranks the handover for the install, while a stale 1 asked the new index for a torchaudio wheel it does not publish and aborted the whole torch update. Each fix is mutation-tested. Suite: 5902 passed, four pre-existing failures unrelated to this branch (managed node runtime, tokenizers negative control). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: a version floor has to order prereleases Test-WoaVersionAtLeast compared the numeric release and then .postN, so 21.0.0rc1 cleared a 21.0.0 floor. Staging writes an exact pyarrow== override from whichever candidate it picks, and pyarrow>=21.0.0 rejects that rc, which is the failure the floor was added to prevent. A marker attached to the release now sorts below it. A larger release is unaffected, so a wheelhouse nightly still clears an older floor: the pyarrow 24.0.0.dev260 an end-to-end GB10 run staged is named in a test, since a floor that rejected it would break a verified install. The eleven-row ordering table is cross-checked against packaging in the same class rather than asserted from memory, because the PowerShell cannot import it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * GPU driver: an all-[N/A] compute-apps listing is "cannot attribute", not "nothing appeared" Ran the Kaggle GPU payload against a fresh native install on the GB10 laptop. Every workflow passed except cli_run, which said `unsloth run` served from the CPU because no process appeared on the card holding more than 0 MiB. It had: with the exact flags the CLI passes (-ngl -1 --threads 2) the same llama-server generates at 7.7 to 8.3 ms per token, inside the --gpu-layers 99 band (6.5 to 7.1) and far from a two-thread CPU control (18.4). llama-server --list-devices shows CUDA0 with 46 GB. The verdict was blind, not wrong about the rule. On Windows (WDDM) and on unified-memory parts nvidia-smi lists every CUDA process with used_gpu_memory [N/A], and parse_compute_apps drops those rows, so a server that did appear during the window looked like nothing appeared and the device-delta fallback never engaged, because that branch only runs when nvidia-smi cannot enumerate at all. Listing alone is not evidence either: a -ngl 0 server is listed too, since a CUDA build creates a context regardless. Measured here: the device counter reads 132 MiB idle, 272 MiB with that bare context, 560 MiB with the 270M model offloaded, so the existing 200 MiB fallback threshold separates the two. count_listed_pids tells listed-but-unattributed apart from empty, and nvidia_compute_apps returns None for it, which is what the verdict already treats as "judge on the device delta". Readable listings are unchanged, and parse_compute_apps keeps dropping [N/A] rows, so its tests stand. Tests added for the count, for the None, and for the verdict on the numbers measured on this box. test_a_box_without_nvidia_smi_reports_no_gpu_rather_than_crashing fails on this host on unmodified main too, since a real nvidia-smi leaks through its simulation; not touched here. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: sqlite-vec cannot resolve in the studio extra pyproject pinned sqlite-vec==0.1.9 unconditionally, so a native win_arm64 interpreter running pip install "unsloth[studio]" hit a row it cannot satisfy: sqlite-vec publishes win_amd64 wheels only and no sdist at all, so there is nothing to build and no version to floor to. The runtime skip list already covers the name, but that filtering never runs for a direct extra install, which resolves pyproject's rows. Dropped on win_arm64 with the same negative marker cryptography and pymupdf use, mirrored into studio.txt so the two stay in step. Nothing regresses: rag_db.py guards the import and the router mounts either way, reporting RAG unavailable. Guarded generally for the studio extra: no skip-list package may be left live on win_arm64, and a marker that drops one must drop it nowhere else. Scoped to studio on purpose, since the other extras are x64 recipes pinning win_amd64 wheel URLs and were never a claim about this platform. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: three review fixes - The resolver purge split every variable on [,\s] and rejoined each with its own separator. uv reads UV_FIND_LINKS comma-separated, so a caller path like C:\private wheels is ONE directory there: the shared split tore it into two fragments, dropped the managed entry, and rejoined the pieces with commas, quietly losing an air-gapped mirror. Each variable is now split the way the tool that reads it splits it, in install.ps1 and in _find_links_wheel_versions, where the same class of split made wheels in a space-containing directory invisible to the skip list. - The native ARM64 index arguments were gated on $UseUv, so when uv could not be obtained at all the pip fallback got only --index-url pointing at a channel that publishes just the torch trio, leaving their shared dependencies unresolvable. Remove-UvOnlyResolverFlags already exists to make that list safe for pip, dropping --index-strategy and rewriting --prerelease=allow as --pre, so the arguments are built either way. - The manifest was the only durable record of the WoA index, and the dependency pass deletes it before rebuilding it. A run that died in that window left the next update with no handover and no manifest, so it fell to the driver-derived cu130 and failed on an index with no win_arm64 wheel, on every retry. A marker beside overrides.txt, written before the manifest is dropped, now carries it. It persists exactly what the manifest persists, checked on write as well as on read, so a mirror pinned with a token is not written to disk and a hand-edited file cannot redirect a torch install. Seven mutations, all killed. The first attempt at the marker tests passed with the write-side guard removed, since the read guard alone defends a round-trip; a test that inspects the file on disk covers it now. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: two review fixes - New-UnslothTorchOverridesFile kept relative references working by writing the merge into the caller's override directory, which only helped when every override file shared one. Round 19 made two the normal case on the native path, since a non-conflicting caller file is now kept where it sits, so the merge fell through to %TEMP% and every relative include or wheel path resolved against nothing. Each folded line is rebased against the file it came from instead, and nested includes are flattened for the same reason, so where the merge lands no longer changes its meaning. - The dependency pass fell back to pip whenever uv failed or was missing. On the native stack that does not recover: the overrides lift the released torch cap, which no win_arm64 CUDA wheel satisfies, and drop the packages with no win_arm64 build, and pip has no override mechanism to translate them into. Constraints cannot stand in, they narrow a requirement rather than replace one. Both fallback routes now refuse with the reason and name the remedy instead of resolving the wrong stack. Five mutations, all killed. Restored one behaviour the new scanner had changed: comment lines are carried through a fold again, which test_unsloth_torch_override.ps1 was right to be checking. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: four review fixes - An include's inline comment is not part of its path. "-r nested.txt # corporate pins" is a valid line, and capturing the comment meant the file never opened, so a torch conflict inside it went unseen and the two override files were handed to uv as disjoint. Stripped only where whitespace precedes the hash, so "-r a#b.txt" keeps its filename. - An index the marker may not record now CLEARS it rather than leaving the last one in place. A host that moved from NVIDIA's channel to a credentialed corporate mirror kept a marker naming the old public channel, and since the manifest does not record the mirror either, the next fresh-shell update read the marker and put torch back on a source the user had left. An unrecordable index inherits nothing. - The local wheelhouse mirror counted a wheel on its filename, which is all _find_links_wheel_versions reads, so a truncated archive took its package off the ARM64 skip list and uv failed the whole dependency pass on it. It is opened before it is copied now, the same check the remote branch already applied. - The GPU harness read a mixed nvidia-smi listing as proof of CPU: a readable row on one card keeps the mapping nonempty, so the all-[N/A] guard stays quiet and the newly launched server, reported as [N/A] on a WDDM or unified-memory card, is simply missing from it. A pid that appeared but carries no figure now defers to the device-wide delta, as the all-[N/A] case does. Both samples come from one nvidia-smi call, so the attributed mapping and the listed pids describe the same moment. Seven mutations, all killed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: two review fixes - The local wheelhouse pyarrow was selected and staged on its filename. Last round's archive check covered the OPTIONAL mirror, where a bad wheel costs one feature; this is the mandatory one, and staging writes an exact pyarrow== override from whichever file it picks, so a truncated wheel chose the native route and then failed the resolve with x64 already given up. The probe and the staging now apply the same filter, tags and readability, so they cannot pick different files. - The device-wide VRAM total is only a measurement of this process when this process owns the card. Under --studio-concurrent a training leg shares it and both allocates and frees inside the window, which is how one run read the delta as -182.0 MiB while the server held 2.6 GB, so accepting a rise there would pass a CPU-served run on somebody else's memory. Both fallbacks to the total now refuse when anything was on the card before the launch. Decided on evidence rather than configuration: the payload is not told which half it is, but a pid already present says so, and an empty card keeps the fallback the [N/A] parts need. Six mutations, all killed. Two tests from last round asserted the mixed listing case with a co-tenant present, which is exactly the scenario that must not pass; they now use an owned card, and the shared variants are their own cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: quote a rebased override path that needs it Resolve-WoaOverrideLine stripped the quotes off an -r/-c/-f target and wrote the absolute path back bare. These options take one file argument, so a space truncated it and the dependency pass failed opening a path cut at its first space. Two ways in, and only one is about quoting. A caller who quoted the value had the quotes discarded. A caller who had no reason to quote a plain relative name gets a space anyway when it rebases onto a directory that has one, which no amount of preserving would have caught. The rebased value is quoted whenever it contains whitespace, so both are covered, and setup.ps1's parity copy is kept identical by a test. The first version of this had the option token disappear: -match on the rebased value replaces , so reading the groups afterwards returned a bare path where -c <file> had been. The file already carries a comment warning about exactly that. The groups are copied out first now, and a test asserts the option survives. Four mutations, all killed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: four review fixes - The marker recorded the WoA chain, which never consults the generic UNSLOTH_TORCH_INDEX_URL pin the torch steps actually prefer. A run pinned elsewhere installed from the pin and then recorded an NVIDIA channel it had not used, and the next fresh shell went back to it. The pin decides what is recorded now, and an unrecordable one clears the marker. - _find_links_wheel_versions read PIP_FIND_LINKS as well as UV_FIND_LINKS. uv does not consume it, so a wheel hosted only there was counted as available, dropped off the skip list, and then invisible to the uv resolve that followed. pip cannot be the resolver on this path either, since pip_install refuses the fallback once the overrides are in force. - A transitive blocker had no direct row to satisfy, so any hosted version counted. tensorboard 2.21.0 requires grpcio>=1.74.0 and librosa 0.11.0 requires numba>=0.51.0: a wheelhouse holding an older one lifted the skip, and the optional package's own metadata then failed the whole extras pass. The floors are recorded with the release they were read from, and a test fails if extras.txt moves off that pin. llvmlite has no entry deliberately: nothing states a floor for it. - The PyPI pyarrow route cleared native mode on a wheel it then forgot, so no exact override was written. With only pyarrow>=21.0.0 in force, uv takes the newest release, and one that ships an sdist for this interpreter builds Arrow from source. The matched wheel is pinned now, and the probe clears the name on entry so a re-probe cannot inherit it. Six mutations, all killed. The first attempt at the second fix keyed on USE_UV, which is False until the bootstrap runs, so it read the wrong variable everywhere else; the list is unconditional now, which is correct because uv is the only resolver that reaches it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: open the downloaded pyarrow wheel before keeping native mode A mirror can serve a truncated body with a 200, and Invoke-WebRequest reports that as success. This is the mandatory wheel: the exact pyarrow== override is written from it, so an unreadable download kept the native route and then failed uv on that override, after x64 had been given up. The optional remote wheels below already opened what they kept; this one was the last route that did not. It throws into the existing catch, so the failure falls back to x64 rather than continuing, and the bad file is removed first -- left in the managed directory it would be read by the resolver on every later run, and by _find_links_wheel_versions as proof the package is available. Three mutations, all killed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Record the effective torch index in the manifest, not just the marker install_python_stack.py writes UNSLOTH_WOA_SELECTED_TORCH_INDEX into the manifest as woa_torch_index, and the read chain in setup.ps1 prefers the manifest over the marker. Exporting the WoA chain while saving the pin left the manifest shadowing a marker that had already been corrected, so the next fresh shell went back to the index the run had not installed from. Resolve the value once from Get-PinnedTorchIndexUrl, falling back to the WoA chain, and write both records from it. The torchaudio and prerelease flags install.ps1 leaves in the shell describe the index it probed, so drop them when the resolved index differs from that one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop the generated requirement snapshots that reached the branch _filter_requirements writes its skip-filtered copy beside the source so relative -r/-c includes still resolve, and pip_install unlinks them in a finally. A test that calls the helper directly, or an install killed mid-run, leaves them in the checkout, where a git add -A picks them up. Nine were committed that way. They are a snapshot of a file already in the tree, so a stale one reads as a second, silently divergent copy of the pins. Remove them, ignore the pattern, and add two tests: one that no tracked file matches the generated shape, one that the ignore rule covers it. The shape is matched exactly rather than by a leading dot, so a real hidden requirements file such as .constraints.txt stays in scope. * Persist an explicit pin, install hosted optionals, scope the GPU samples Three separate holes. setup.ps1 ran the index persistence block only when the WoA chain had something in it. A venv installed through a credentialed mirror has nothing to recover, so a later run pinned at an NVIDIA channel installed from the pin and recorded neither record, and the next fresh shell fell back to the driver-derived index that publishes no win_arm64 CUDA wheel. Either record now opens the block. install.ps1 reported that a hosted optional wheel was keeping its package, but only declined to emit the removal override. hf_transfer and xformers are excluded on win_arm64 by marker, so no requirement survived for the wheel to satisfy, and torchcodec's only requirement line was filtered out here unconditionally. Make that filter conditional on a hosted wheel, and install the distributions the metadata cannot ask for, --no-deps and best effort. The Kaggle GPU harness sampled every physical card while the payload was pinned to one, so an unrelated process on a hidden card could supply the memory that carried a CPU-served run past the assertion. Both samplers now pass CUDA_VISIBLE_DEVICES to nvidia-smi -i. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin the probed CUDA trio, and validate the wheelhouse xformers uv documents --index-strategy unsafe-best-match as selecting the best version from the combined candidate set of every index, and the PyPI extra index is there because NVIDIA's channel publishes only torch, torchvision and torchaudio. So torch>=2.4 takes PyPI's stable win_arm64 CPU wheel the moment it is one release ahead of the selected channel, and the native GPU path is replaced by a CPU build that imports perfectly. Probe torchvision as well as torch and torchaudio, keep all three versions, and install them pinned exactly, local +cu tag included. An unreadable version keeps the floor it had before. The optional wheelhouse installs added last commit took the bare name, so any tag-compatible wheel landed: below the declared xformers floor, or built for another torch and CUDA pair, whose extension then fails to load while the step reports success. Select the newest hosted version that clears the floor, install that version, and for xformers compare cpp_lib.json against the resident torch, removing it on a mismatch the way the post-repair resync already does. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin the repair path too, pair the companion wheels, fix two availability rules The forced-repair path in setup.ps1 installs the trio with the same unsafe-best-match and public PyPI extra index as the install path, so open-ended specs there had the defect the install path was just fixed for. Re-probe the effective index at repair time and pin what it publishes; a repair can happen in a fresh shell long after the install, so a remembered version would be stale. The probe is a parity copy of install.ps1's, compared body for body by a test. Pinning both torch and torchvision exactly made the pair mandatory, and NVIDIA's nightly channel publishes each project on its own schedule while nightly torchvision metadata pins its exact torch. Select the companions as builds of the torch that was chosen, matching on the dev stamp and the local tag, and fall back to the floor when the index carries no companion from that build. Test-WoaVersionAtLeast read .dev as a pre-release of the release, so 0.0.22.post7.dev0 came out at or above 0.0.22.post7. PEP 440 hangs .devN off whatever precedes it. Track it separately and as an ordinal. The win_arm64 skip list was decided from the local wheelhouse alone, so a native CPython 3.14 host dropped librosa even though llvmlite and numba publish cp314 win_arm64 wheels. Record what the public index resolves for this interpreter. Also removes a file named - from the root: a workspace simulation ran uv pip compile -o -, which writes a file rather than to stdout. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the Windows on ARM comments Post-convergence comment pass over the three files that carry the added prose: collapse the multi-line rationales to the fact each one is there to state, and delete the ones that restate the line below them. 782 added comment lines to 569. Comments, docstrings and whitespace only, verified with comment_tools. * Stop the override file from undoing the CUDA pins, and two probe fixes uv's --overrides replace a requirement's version even when it is named directly on the command line: verified on 0.10.7, an override of packaging>=20 beat a CLI packaging==24.0. The generated file carries torch>=2.4 and torchvision>=0.19, so it discarded the exact CUDA pins the probe selected and best-match then took PyPI's newer CPU wheel. Build the value for that one command without the trio, passing every other file through, and restore it in a finally so the later unsloth resolve still gets the drop list. nvidia-smi failing to answer is not evidence the GPU is gone. A transient failure during a direct update dropped windows-arm64-cuda from the expected kinds, deleted the working llama.cpp tree and installed the CPU bundle. Accept a win_arm64 venv on a persisted WoA CUDA index as evidence too; only NVIDIA's own channels are ever persisted, so a cpu pin cannot claim it. The public-index wheel table is only availability if the resolve looks at PyPI. Offline, or pointed at an exclusive corporate index, unblocking librosa dropped the skip and then failed the extras pass on an unavailable numba chain. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Take a wheel from PyPI when PyPI builds it, not from the wheelhouse The Windows on ARM wheelhouse exists for distributions PyPI has no win_arm64 build of. regex is not one of them: PyPI has published win_arm64 regex since 2025.7.29, and the wheelhouse copy is the same version. The staging directory is first in UV_FIND_LINKS, so ours won the tie and the venv got our binary of a wheel the project releases itself. Nothing needed it there either: regex is in no skip list, no drop list and no constraint, so it unblocked nothing. Staging now asks PyPI first and skips the copy when PyPI publishes that project at or above the version being staged. Version-aware in both directions, because the reverse is a downgrade: an upstream release BEHIND the wheelhouse leaves ours in place. The probe is PyPI-only on purpose. Test-WoaWheelAvailable falls back to the wheelhouse, and a wheel is by definition present in the wheelhouse it is being staged from, so reusing it would answer $true every time and the guard would never fire. Its PyPI leg became Test-WoaPyPIWheel and it now calls that, so there is still one place that knows how to read the simple index. Tag matching is relaxed for this caller alone. hf_transfer ships cp38-abi3 and sqlite_vec ships py3-none, both of which import on cp313; the exact-tag test would call them foreign and go on shipping ours the day upstream publishes one. Free-threaded venvs keep the exact test, which is what it was written for. A wheel this venv could not have used is never judged: a cp312 wheel is not made redundant by a cp313 wheel. pyarrow keeps its own path. Get-WoaPyarrowSource already probes PyPI before the wheelhouse and reports which one it used, which is why the generic loop skips it. Two comments were wrong and are corrected. pyarrow has never published a win_arm64 wheel at any version, so the constraints floor is the floor of the wheels we stage rather than of anything upstream ships, and Get-WoaPyarrowSource checks an explicitly supplied UNSLOTH_PYARROW_WHEEL before PyPI, not after. * Fix uninstall.ps1 Split-Path calls that throw on Windows PowerShell 5.1 Split-Path puts -LiteralPath in its own parameter set, which carries only -Resolve and -Credential. -Parent belongs to the -Path set, so naming both resolves no parameter set and the call throws AmbiguousParameterSet. It throws at runtime rather than at parse time, which is how eight of these shipped in scripts/uninstall.ps1. Two of them sat outside a try/catch, so a real uninstall printed a red error and left the derived path null: the legacy sibling build at <parent>\stable-diffusion.cpp was never removed, and the empty path then tripped the deny list into "refusing to remove unsafe path:" with nothing after the colon. The other six were swallowed by their surrounding try/catch and failed silently. That left _RootFromConf returning nothing, so studio.conf-based root discovery never worked, and it disabled the parent-of-USERPROFILE rule in _IsUnsafeRoot, so C:\Users read as a safe recursive-delete target. Drop -Parent rather than switching to -Path. -LiteralPath on its own already returns the parent, and -Path globs, so an install root containing [ ] would be read as a wildcard. Verified on this box under Windows PowerShell 5.1.28000.2704 against a real env-mode install: the root, its studio.db, auth, outputs and exports all go, an Unsloth-owned sibling stable-diffusion.cpp now goes with them, and an unowned checkout at the same path is still kept. The new test is static because CI has no Windows PowerShell 5.1 to run the scripts under. It covers install.ps1 and studio/setup.ps1 too, which are already clean. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Give the uninstaller's remove retries a long enough budget Uninstalling a Studio that had actually been used left the whole install tree behind. The stop pass kills the server, but torch inductor's compile workers keep handles on the .py files they wrote under <root>\TORCHINDUCTOR_CACHE_DIR for several seconds after that. Those are plain data handles on files the worker never loaded as a module, so neither the ExecutablePath pass nor the loaded-module pass in _StopProcessesLockingRoots has a process to kill, and the only thing left to do is wait. _RemovePath waited 700ms three times, so it gave up after about 2.1s and reported 'could not remove', leaving the tree and studio.db on disk with a summary telling the user to delete them by hand. Replace the flat sleep with an escalating backoff that tops out near 20s, which covers the teardown with room to spare and still bounds the run. * Say that a Windows on ARM desktop build needs clang Building studio/src-tauri for aarch64-pc-windows-msvc with only MSVC BuildTools installed fails with ring@0.17.14: failed to find tool "clang": program not found because ring and aws-lc-sys assemble their aarch64 sources through cc-rs, which calls clang rather than cl.exe on that target. The error names the crate rather than the missing toolchain, and the prerequisite was written down nowhere: not in the workflows, not in CONTRIBUTING.md, not in the manifest. Recorded in build.rs, which is the file whose job this is and the one a reader reaches for when the build fails. No behaviour change. Not a hypothetical: the desktop app was built and launched on a Windows ARM64 box for this branch, and this was the only thing standing in the way. It builds and runs natively there once clang is present, PE machine 0xAA64, window up and the frontend rendering off tauri.localhost. * Fix the two CI reds: a dead import and a pid-file race Import hoist lint: run_studio_gpu.py still imported count_listed_pids after the listing helpers grew up around it. nvidia_compute_apps_listing now returns the pids themselves via listed_pids, because a MIXED listing needs names rather than a count, so the count import had no caller left and only survived in a comment. Dropped both. count_listed_pids itself stays: gpu_assert still exports it, its tests still cover it, and listed_pids documents itself against it. Tauri rust tests: stop_update_kills_descendants_after_the_group_leader_exits panicked with ParseIntError { kind: Empty }. The test child runs `echo $! > "$1"`, and the redirection creates that file before writing to it, so waiting on is_file() can win the race and read an empty string. It is a real flake rather than anything about this branch, and it does not reproduce reliably: the same test passed on main minutes earlier. It surfaced here only because the Tauri workflow is path filtered on studio/src-tauri and the clang note is the first change this branch makes there, so this was its first run. Wait for a pid that parses instead of for a path that exists, over the same kind of budget. A child that never writes one now fails with a sentence rather than an unwrap panic. * Close three holes in the PyPI-first wheelhouse guard Review of the guard I added in 7fe37c6 turned up three ways it could do harm, one of them worse than the duplication it was written to fix. The bad one: skipping a wheel made it stop counting as available. $WoaWheelNames is rebuilt by scanning the staging directory, and a $WoaDropCandidates name that is missing from it gets emitted as `name ; platform_machine == "AMD64"`, which EXCLUDES the package on ARM64. So the day PyPI published a win_arm64 hf_transfer or brotli, the guard would have turned "installed from our wheelhouse" into "not installed at all" -- the opposite of preferring upstream, firing on exactly the event the guard exists for. A skipped wheel is now recorded in $script:WoaPyPIProvided with the version PyPI matched, and that is merged into $WoaWheelNames, so availability reads the same whether the wheel came from the wheelhouse or from PyPI. The xformers floor still applies to the PyPI version, so a release below it keeps the drop. abi3 was treated as universally compatible. It is forward compatible from the version it was built against, so a cp314-abi3 wheel does not import on cp313, and calling ours redundant against one would have left the package uninstallable. Test-WoaWheelTagsUsable now applies the same minimum rule the staging scan already used for abi3 and py3N, and free-threaded venvs keep the exact-tag test, which is what it was written for. PyPI publishing a wheel is only availability if the resolve will look at PyPI. UV_INDEX_URL and UV_DEFAULT_INDEX REPLACE the default index, and offline flags remove it, so on a corporate mirror the guard was dropping our wheel and leaving it obtainable from nowhere. Test-WoaResolveReachesPyPI mirrors _public_pypi_is_reachable in studio/install_python_stack.py, which reasons about the same five variables for the same reason. An unreachable pypi.org already failed safe; reachable but unused did not. The new tests execute the helpers under pwsh instead of reading install.ps1 as text, because two of these three were wired correctly and answered wrongly, which is precisely what a source-shape test cannot see. Hermetic: the live PyPI leg is exercised on hardware, not in CI. * Don't recursively delete ~/.unsloth/studio unless it is ours The custom-root branch refuses a path that carries none of the four ownership sentinels ("refusing to remove non-Unsloth path"), and then the default root a few lines down is removed with no check at all. So a bare run, the documented one-liner with no UNSLOTH_STUDIO_HOME set, recursively deletes whatever happens to sit at ~/.unsloth/studio, and the empty-dir prune takes ~/.unsloth with it. That path is not far-fetched to own. On a machine where Studio was only ever installed in env mode, ~/.unsloth/studio is an ordinary place for someone to keep notes or a checkout, and nothing warns them that running the uninstaller for their custom install will also clear it out. Hit it here for real: a run against a disposable env-mode install removed an unrelated directory that held only a log file, and reported nothing unusual while doing it. Gate it on _IsStudioRoot, the same test the custom root gets. An interrupted install that lost every sentinel is now left on disk with a message instead of removed, which is the failure direction that does not destroy data. Both scripts had it; uninstall.sh line 588 was the same bare call. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pair stable companions by release line, honour uv config files, and clean up the override swap Stable torch, torchvision and torchaudio releases all carry an empty dev stamp, so the CUDA local tag alone paired a companion from any release the index still served and the exact-pin install then asked for a pair that does not exist. Test-WoaWheelPairsWithTorch now applies the release mapping for stable builds: torchvision 0.(M+15) pairs with torch 2.M and torchaudio agrees on major.minor. Nightlies still pair by dev stamp. The setup.ps1 parity copy is identical. Only environment variables were consulted before assuming a resolve reaches public PyPI. uv also discovers uv.toml and pyproject [tool.uv] from the current directory and its parents, then the user and system files, so a no-index or an exclusive default-index set there still answered "PyPI". Both the PowerShell probe (Test-WoaResolveReachesPyPI, now also gating the pyarrow source probe) and the Python one (_public_pypi_is_reachable) read that configuration, honour UV_NO_CONFIG and UV_CONFIG_FILE, let an index in the environment decide alone, and resolve doubt to "not PyPI". The filtered torch-step override was written with GetTempFileName(), which follows %TEMP% and so could produce a quoted, uv-rejected path, and the copies were never deleted. New-WoaTorchStepOverrideValue now writes under the already validated WoA directory, passes every path through Get-UvSafePath, and reports the copies so the caller removes them in finally. setup.ps1 never performed this swap around its own CUDA trio install, so the restored overrides.txt floors undid its exact pins too; it now swaps the same way. On the manifest fast path the dependency block is skipped, so the llama.cpp NVIDIA-evidence check read an unset index and a transient nvidia-smi failure deleted a working CUDA bundle. The check now reads the pin, manifest or marker itself when the pass did not run. sqlite-vec joins the explicit ARM64 optional install map: its requirement is marker-excluded on win_arm64 everywhere the torch path applies, so a staged wheel was ignored. The Kaggle harness stops the Studio server before the CLI baseline regardless of skip_ui, so its own llama-server is not counted as a co-tenant. Tests cover each change and the free-threaded probe test inlines the helpers Test-WoaWheelAvailable now routes through. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Decide "public PyPI" by host, not by substring An exclusive index whose URL merely contains the name, such as https://pypi.org.corp.example/simple or .../api/pypi/pypi.org/simple, passed the substring test in both the PowerShell and the Python reachability probes, so the pyarrow route preferred a wheel the resolve could never fetch. Both now parse the URL and require the host to be exactly pypi.org. TestPyPI, which does not carry these packages, is therefore not public PyPI either. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rank uv's [pip] index settings above the top-level ones, and drop a redundant wheel's managed copy uv pip gives [pip].no-index and [pip].index-url precedence over their top-level twins, and an [[index]] entry with default = true beats [pip].index-url (verified on uv 0.10.7 with dry-run resolves). Both readers assigned whichever value came first in the file, so a uv.toml that set the top-level key ahead of the [pip] one could report public PyPI as reachable while the real resolve went to an exclusive index. Each value is now collected and ranked at the end, in both install.ps1 and install_python_stack.py. A wheelhouse wheel that PyPI now publishes was skipped at staging time but a copy already in the managed directory stayed there, and that directory is first in UV_FIND_LINKS, so it still won the tie: the offline-cache mode points the wheelhouse at that very directory, and a URL wheelhouse leaves the copy an earlier install staged. The managed copy is removed in both modes; an external wheelhouse file is never touched. test_woa_wheelhouse_guard_behaviour inlines every helper the reachability probe now calls, so a missing one no longer answers True from inside a non-terminating error. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Run the two uninstaller tests that no workflow ran cross-platform-parity-ci already triggered on uninstall*.ps1 and scripts/uninstall*.ps1, so editing the uninstaller queued a run that then asserted nothing about it. test_uninstall_prebuilt_parity.ps1 and test_uninstall_reparse_stop_roots.ps1 were referenced by no workflow at all. Both are hermetic -- they parse scripts/uninstall.ps1 with the AST and execute only the helper under test -- so they cost a few seconds on a row that is already running. Worth being precise about what this does not buy, because the gap is wider than the wiring. Neither test executes the uninstaller BODY, and neither does any other: every uninstaller test we have asserts source shape. A runtime parameter-binding fault is therefore invisible to all of them, which is exactly how `Split-Path -LiteralPath $p -Parent` reached users. It binds to no parameter set and throws on every uninstall, on 5.1 and on 7 alike, but it parses, so the board stayed green. The reparse test also could not run unprivileged. It created its home with New-Item -ItemType SymbolicLink, which needs elevation or Developer Mode, so it died on NewItemSymbolicLinkElevationRequired before the first subtree check. It now runs over both reparse kinds. A junction needs no privilege and is the shape a user is likelier to have -- mklink /J is what gets reached for when moving a studio home to another volume -- and _ManagedPathsUnderReparseTargets reads nothing but .Target, which both kinds supply rooted. A kind that cannot be created is reported as SKIP rather than passed over, and a final check fails the run if neither kind was exercised, so an environment that can make no links cannot report a clean pass having asserted nothing. * Read a uv config the way TOML is actually written Test-WoaResolveReachesPyPI decides whether PyPI is in the resolve at all, and a wrong "yes" is the expensive answer: it lets the redundancy guard delete the wheelhouse copy of a wheel nothing can then fetch, and makes Get-WoaPyarrowSource answer "pypi" and skip a usable local pyarrow, which gates the whole native path. The subset parser behind it answered "yes" for six shapes of config that had replaced or disabled PyPI. All six failed in that direction. Held against tomllib over 41 fixtures, the parser and a correct reading now agree everywhere; before this they disagreed on exactly these six: no-index = true# offline lab a comment needs no space in front index-url = "https://corp/simple"#corp same, on the value side "index-url" = "https://corp/simple" a quoted key is the same key pip.no-index = true a dotted key is [pip]'s no-index pip.index-url = "https://corp/simple" same [tool.uv] + pip.no-index = true same, in pyproject.toml The comment cut was `(^|\s)#`, which misses a `#` with nothing before it. That cannot be widened to a bare `#`, though, because the same line may carry one inside a string: `index-url = "https://host/simple#frag"` is a fragment, not a comment, and the old pattern got that right only by accident. Both halves need a scan that knows where the quotes are, so Remove-WoaTomlComment does that, and Split-WoaTomlKey does the matching job for keys -- unquoting them and splitting on dots, so `pip.no-index` is routed into the [pip] scope instead of being compared, whole, against the literal `no-index` and never matching. The two new helpers are added to the behaviour test's HELPERS tuple in the same commit. Leaving them out would have been the bug that tuple exists to prevent: a helper the prelude does not extract is a command-not-found inside Test-WoaResolveReachesPyPI, which aborts the statement, skips the branch, and answers True. Coverage was the reason these survived. The PowerShell side had no config-file fixtures at all -- test_pypi_counts_only_when_the_resolve_would_reach_it drives environment variables only -- while the Python twin has had file fixtures and a real tomllib all along. Sixteen cases now cover both directions of the scan, the [pip] precedence both ways, a lookalike host, and the shapes that must keep answering True so this cannot be mistaken for a blanket "not reachable". * Put the uv user config where uv would look for it on Windows The user-file row of test_a_configured_exclusive_source_is_not_pypi failed on Windows and always would have. The fixture deletes APPDATA and then points only XDG_CONFIG_HOME at a directory, but _uv_config_files reads %APPDATA%\uv\uv.toml on Windows and $XDG_CONFIG_HOME/uv/uv.toml elsewhere, so on Windows the file was written somewhere uv never looks, nothing was discovered, and the assertion that a user-level no-index takes PyPI out of the resolve could not hold. It stayed green because tests/python runs on the Linux row, where XDG is the right answer. Both names now point at one directory, so the case is real on every platform. test_project_outranks_user_for_a_scalar needed the same treatment for a sharper reason: it wrote the user file to the old xdg path, which after this change is not a config location anywhere, so it would have passed on the project file alone while asserting nothing about precedence. It now writes to the same place and first asserts the file is one _uv_config_files discovers, because a precedence test whose lower-ranked file was never found is a test that cannot fail. * Don't strand an old install behind the new ownership gate bcc0ffd stopped the uninstaller recursively deleting ~/.unsloth/studio unless one of install.ps1's sentinels says the directory is ours. That was right, and the list it used was too short. On Windows share\studio.conf is never written -- only install.sh writes it -- so the three sentinels that actually decide a Windows root all postdate the bin\ shim dir and the unsloth_studio rename. An install from before those has neither: the venv lived at <root>\.venv, the venv's Scripts dir went on PATH in place of a shim dir, and .unsloth-studio-owned did not exist yet. install.ps1 still migrates exactly that layout, at "found legacy Unsloth environment", so it is not hypothetical. Uninstalling one refused, printed the user's own install as a non-Unsloth path, and left the tree and studio.db on disk. tests/sh/test_uninstall_arg_guard.sh has been asserting this all along -- its fixture is a pre-marker unsloth_studio venv and "no arguments removes the install" fails at eda3801. So accept two more things Unsloth put there: the owner marker inside the legacy .venv, and either venv dir carrying the unsloth console script pip generates for the CLI. That is the property the gate is testing. A bare .venv, a venv merely named unsloth_studio and a hand-made studio directory still have none of them and are still refused. (cherry picked from commit 05c6cdf9a23273bb7a5e9fe4900b59dbe6cda3a1) * Recognise the abi3 pyarrow upstream is going to publish pyarrow is the mandatory gate for the whole native path: staging turns the chosen wheel into an exact `pyarrow==` override, so whichever file we select is what the resolve gets. Today that is our own 24.0.0.dev260, a pre-release, while upstream stable is 25.0.1 -- acceptable only because PyPI publishes no win_arm64 pyarrow at all yet. It will. apache/arrow#48539 adds the wheel and is held behind apache/arrow#50398, whose plan is an abi3 floor of 3.11, so the first one on PyPI will be named pyarrow-<v>-cp311-abi3-win_arm64.whl. Every pyarrow candidate went through Test-WoaPyarrowWheelUsable, which gated on Test-WoaWheelTags -- exact PEP 425 fields -- and an exact test answers false for cp311-abi3 on every interpreter we build. The day upstream shipped, we would have gone on selecting our own dev build and pinning it, and nothing anywhere would have said so. Silent, permanent, and pointing the wrong way for a PR whose whole aim is that a new user on this hardware installs from upstream wheels. Test-WoaWheelTagsUsable is the predicate that already answers this correctly, and it keeps both properties exactness was buying: an abi3 wheel built against a NEWER interpreter than the venv is still refused, because abi3 reaches forward from its build version and not backward, and abi3 is refused outright on free-threaded builds, which have no stable ABI (CPython #111506). One function changed, because all six call sites already funnel through it -- the supplied wheel, the PyPI scan, the wheelhouse directory and index, and the two in staging. test_every_pyarrow_candidate_goes_through_the_floor counts them and still reads 7. Four cases cover the shape upstream will ship and the two directions it must not loosen, and a fifth asserts the free-threaded refusal on its own. The preludes that lift Test-WoaPyarrowWheelUsable out of install.ps1 gained Test-WoaWheelTagsUsable in the same commit, for the reason that keeps recurring in this file: a helper the prelude does not lift is a command-not-found, which aborts the statement rather than answering, and the caller then reports success. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Windows on ARM: make the purge and merge tests speak the host's path separator These tests are gated on pwsh being present, not on the platform, so on a Windows box with PowerShell installed they run and 11 of them fail. None of it is the product: the purge builds its owned prefix with Join-Path, which uses the host separator, and the tests hand it a hardcoded POSIX home, so "C:\...\unsloth\woa" was compared against ".../unsloth/woa" and never matched. In a real run both sides come from the same Join-Path, so the purge is correct either way. Build the fake home and the entries the way the host does. The same cases now cover the separator the machine actually uses, and on POSIX the values are byte-identical to before. The two merge tests failed for a second reason: their fakepython stub is a shebang script, which Windows cannot execute, so the merge got no pins back and the failure was an empty path rather than anything about rebasing. Write a .cmd stub there instead; PowerShell's & runs it and the stub ignores -c regardless. 473 passed, 0 skipped on win-arm64 with PowerShell 5.1. (cherry picked from commit 8dc13a7692c325408a9cb56eb94b612e71f45a2f) * Test the Windows on ARM install path on Windows on ARM Everything this repo knows about win_arm64 has been tested only on x64 runners: ubuntu-latest and windows-latest. That is the wrong machine for all of it, and it is why several real bugs sat behind a green board. Three of them could not have failed anywhere else. The purge builds its owned prefix with Join-Path, which uses the HOST separator, so a test hardcoding a POSIX home compared "C:\...\woa" against ".../woa" and never matched -- eleven tests passed on Linux and failed on Windows for a reason that had nothing to do with the purge. The uv config parser, which exists because Windows PowerShell 5.1 ships no TOML reader, read six shapes of config wrongly, every one of them answering "PyPI is reachable" for a config that had taken PyPI out of the resolve. And the uninstaller's ownership gate refused genuinely old installs, leaving the user's tree and studio.db on disk while calling their own install a non-Unsloth path. windows-11-arm has been free for public repositories since 2025-08-07, and this leg is cheap because none of this needs a GPU, CUDA or a compiler: it is arithmetic over wheel tags, path handling and PowerShell parsing. A full install or a Tauri build needs much more and is deliberately not attempted here. Two rows, because the shells are not interchangeable and each is what something real uses. pwsh 7 is what CI has always run. Windows PowerShell 5.1 is what the CLI launches setup.ps1 with, and it is the only shell on a stock Windows box, so a developer on this hardware runs 5.1 whether or not anyone planned for it. cross-platform-parity-ci already keeps a 5.1 row for #8335 on the same reasoning. The suites locate their shell with shutil.which("pwsh"), which 5.1 does not provide, so that row puts a copy of powershell.exe on PATH under that name -- without it the row would SKIP everything and report green having run nothing. A copy rather than a .cmd shim, because CreateProcess will not launch a .cmd without a shell, and Python is what starts them. Both architectures are asserted rather than assumed. A leg that silently ran on x64 would be worse than no leg, since it would report green for precisely the platform it does not cover, and setup-python needs an explicit architecture: arm64 because the image ships an x64 Python that runs under emulation and reports win-amd64 from sysconfig -- half of these assertions are about wheel tags, so an emulated interpreter would quietly test the wrong platform. A third check pins that the shell resolved for the row is really the major version that row is for. Dry run of this exact selection on win_arm64 under PowerShell 5.1: 671 passed, 0 skipped. One note recorded in the workflow for later: GitHub moves the windows-11-arm image to Visual Studio 2026 between 2026-09-21 and 2026-09-30. This job invokes no compiler so it cannot be affected, but a leg that builds studio/src-tauri would be, and would also need clang on PATH for ring and aws-lc-sys. * Only ask for a junction where junctions exist My own regression, caught by the parity job the same commit wired these tests into: parity (ubuntu-latest) went red on FAIL Junction : a linked root yields the sd.cpp tree under its physical target FAIL Junction : ... and the venv under it A junction is a Windows-only reparse type, and New-Item -ItemType Junction does not throw on Linux pwsh. It quietly produces a plain directory with no .Target, so the SKIP guard -- which only catches a failure -- never fired, and the row ran and failed every positive assertion. The negative ones passed for the wrong reason: _ManagedPathsUnderReparseTargets returns nothing for a path with no target, which is also what "the bare physical target is NOT in scope" wants. Choose the kinds by platform instead of by catching an exception that never comes, and treat $IsWindows being undefined as Windows, since that is Windows PowerShell 5.1 and 5.1 runs nowhere else. Also verify the link after creating it. Created is not the same as usable: the helper reads nothing but .Target, so a link without one cannot exercise anything, and reporting that as a failure blames the helper for the filesystem's answer. A kind that produces no target is now a SKIP, and the existing check that at least one kind ran still fails a run that exercised nothing. Windows unprivileged: Junction all pass, SymbolicLink SKIP (needs elevation), overall pass. Linux keeps the SymbolicLink row, which passed on that job already. * Assert the uninstaller's ownership gate on POSIX too The gate that decides whether ~/.unsloth/studio is recursively deleted exists in both uninstallers, and when it regressed it regressed in both. Verified on real Linux (aarch64, bash 5.2.21): against scripts/uninstall.sh before the fix, three layouts Unsloth itself created were refused -- FAIL: legacy .venv carrying the owner marker (got foreign, want own) FAIL: pre-marker unsloth_studio venv (got foreign, want own) FAIL: pre-marker legacy .venv (got foreign, want own) -- and against the current one all thirteen checks pass. So this was never a Windows problem: a Linux or macOS user with an install predating the unsloth_studio rename was told their own install was a non-Unsloth path and left with the tree and studio.db on disk. There was no POSIX test for it. tests/studio/test_uninstall_legacy_layout_gate.ps1 covers the Windows side; this is its twin, following the extract-via-sed pattern of test_uninstall_shared_icon.sh so the uninstaller body, which kills processes and deletes trees, is never executed. Both directions are asserted, because a gate is only as good as what it refuses. The negative cases include a venv that merely carries an unrelated console script, so accepting bin/unsloth cannot quietly become "any venv with anything in it", and a bare .venv, which is the case the gate was added for and which stays refused because it is genuinely indistinguishable from a user's own project venv. Wired into the parity workflow's Linux row. Worth noting how it went unnoticed: the shell uninstaller tests are named individually in workflows and four of them are named in none, while consolidated-tests-ci excludes tests/sh outright. A test nobody runs is indistinguishable from a test that passes. * Cut the comment volume in the Windows on ARM installer * Cut the comment volume in the studio setup script * Cut the comment volume in the python installers and the CI workflows * Cut the comment volume in the uninstaller and its tests * Cut the comment volume in the tests, requirements and tauri sources * Second comment reduction sweep across the Windows on ARM changes * Third comment reduction sweep over the Windows on ARM changes * Rebase editable and bare local-directory override lines too New-UnslothTorchOverridesFile used to write its merge beside the caller's UV_OVERRIDE file when there was exactly one, so relative references in it kept resolving. The merge moved to %TEMP% with per-line rebasing, which also fixes the multi-directory case that was always broken, but Resolve-WoaOverrideLine did not know -e/--editable or a bare ./pkg directory requirement, so those two forms started pointing at nothing. Reached every Windows host with UV_OVERRIDE set, not only Windows on ARM. * Fold 64 copies of the PowerShell invocation into one helper Every test that runs a lifted PowerShell function spelled out the same five-line subprocess.run and the same `assert done.returncode == 0, done.stderr`, 64 times. _ps and _ps_ok replace them, and the timeout stays a parameter because five sites need 180 and one needs 60. 369 lines out of the file, no test added or removed: 502 collected before and after. Mechanically substituted, then checked the tests still catch what they are for, by breaking install.ps1 three ways and comparing which tests fail. The failing node ID sets are identical before and after (27 for a Split-WoaTomlKey that never splits, 8 for a Test-WoaWheelTagsUsable that always says yes), so the tests still execute PowerShell rather than passing vacuously. * Test the default wheelhouse URL, which nothing tested Found by mutation: changing the default to a working but wrong host left all 1492 tests passing. It is load bearing twice over. It is the only source for the pyarrow that gates the native path, so a typo silently sends every Windows on ARM host back to the emulated x64 stack with no error anywhere, and it is fetched over the network, so a wrong host is a wrong download. Pins the literal, that it is an https resolve/main URL under our own org rather than a repo page that would serve HTML the staging code saves as a .whl, and runs the assignment for eight override shapes: unset, empty, a mirror, one and several trailing slashes, surrounding whitespace, and a local directory whose trailing backslash TrimEnd('/') must not touch. The same mutation now fails 3 tests instead of 0. * Keep windows-vulkan in the ARM64 kind lists main just widened main added windows-vulkan to every branch of the llama.cpp kind check, because any x64 Windows host can land on a Vulkan bundle and a guard without it deletes a working install on every setup run. That reasoning holds for the ARM64 arms this branch adds, so it goes in $_nvidiaKinds rather than being appended at the one call site: the opted-out arm needs it as much as the CUDA one. Test expectations and the source-slice anchors move with it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move the uninstaller fixes out to their own PR The ownership gate, the ~/.unsloth/studio guard, the Split-Path call that throws and the remove-retry budget are not about Windows on ARM, and two of them change behaviour on Linux and macOS. Reviewing them as a footnote to a Windows installer feature was the wrong shape. They are now #10471, against main, with the parity CI wiring that runs them. These files go back to main exactly. Also drops this workflow's references to test_uninstall_legacy_layout_gate.ps1, which now lives in that PR; the runner loop already skips a test file it cannot find, so it can be listed again once #10471 lands. * Move the Kaggle GPU harness fix out to its own PR An all-[N/A] compute-apps listing means nvidia-smi cannot attribute memory, not that the card is idle, which is how WDDM and unified-memory parts report. That is a GPU attribution fix in the test harness and touches no installer code, so it reviews better on its own: #10472. These files go back to main exactly. * Read the two installer scripts once, not 117 times 62 tests re-read install.ps1 and 55 re-read setup.ps1, each on its own line, and none of them mutate what they get back. Two module constants replace all of it, and _ps_function serves from them rather than opening the file again on each of its 60 calls. The wheelhouse-tag tests get the same treatment for sys.version_info and the cp tag derived from it, restated in nearly every test. 130 lines out, no test added or removed: 1502 collected before and after. Substituted on NAME tokens through tokenize, never on raw text, so an occurrence of "text" inside a PowerShell string literal could not be caught by it. Verified by mutation, not by the suite going green: breaking install.ps1 three ways fails exactly the same node IDs as before, 3 for the default wheelhouse URL, 27 for a Split-WoaTomlKey that never splits, 8 for a Test-WoaWheelTagsUsable that always says yes. Formatted with scripts/run_ruff_format.py and confirmed idempotent. * Lower the win_arm64 floors to the first release that actually has a wheel Three floors were set one release too high, and each one excludes a wheel that exists. scikit-learn is the one that bites. 1.8.0 publishes six win_arm64 wheels including cp313-cp313t; 1.9.0 publishes five and dropped the free-threaded one. So >=1.9.0 leaves a free-threaded 3.13 with no binary candidate and an sdist to compile, which is what the ARM64 rows exist to avoid. The comment two lines up already said "from 1.8 only" and contradicted its own pin. av first ships win_arm64 at 17.0.0 (cp311-abi3, plus a cp314t), not 17.1. No release has a 3.13t wheel, so that half of the comment now says so. bitsandbytes is a comment fix only: the wheel starts at 0.50.0, and the pin was already below both. tests/studio/install/test_diffusers_pin.py pins all three against the index rather than against an audit, and rejects a second ARM64 row for the same project so a future edit cannot leave two floors disagreeing. It fails on each of the three old floors. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Translate every spelling of --prerelease for the pip fallback, not one Remove-UvOnlyResolverFlags handled --prerelease two ways, and both were wrong away from the single spelling the two call sites use. The space-separated form dropped the flag and its value and emitted nothing, so a pip fallback given `--prerelease allow` resolved without prereleases and could not see the torch build the run had just asked for. uv accepts that form everywhere it accepts the = form. The = form emitted --pre for any value. Of uv's five, only allow means what pip's --pre means: disallow is pip's default and if-necessary, explicit and if-necessary-or-explicit have no pip flag at all. So --prerelease=disallow became --pre and inverted the caller's intent. Both are latent today, since install.ps1 and setup.ps1 both pass --prerelease=allow, which is the one case the old code got right. The helper takes a caller-supplied argument list, so the grammar it accepts should be uv's. The test walks ten argument lists through the real function: both spellings of both flags, four --prerelease values, a bare value that must not be swallowed by a flag it does not belong to, and the two flags combined. Four fail on the old implementation. * Stop the llama.cpp gate deleting what the selector just installed on ARM64 setup.ps1 deletes an existing llama.cpp tree whose recorded install_kind is not in a set it computes for the host, then reinstalls via install_llama_prebuilt.py. Nothing made those two agree, and where they do not the disagreement is permanent: every update deletes the tree and refetches the identical bundle, forever, on a machine that is working. Walking the product of what either side branches on finds two disagreements on main, both only reachable from Windows ARM64, which is why they lasted: arm64 + nvidia selector installs windows-arm64, gate expected windows-cuda arm64 + rocm selector installs windows-arm64, gate expected windows-rocm The first is every Windows ARM64 machine with an NVIDIA GPU, and this branch already fixed it by widening the gate to the ARM64 kinds. The second is fixed here. No ROCm bundle exists for Windows ARM64 at all: upstream ships hip-radeon-x64 and we publish none, so the selector falls through to the ARM64 CPU bundle while the gate went on expecting windows-rocm or windows-hip. The two ROCm kinds stay in the set for the day one is published. tests/studio/install/test_llama_kind_gate_matches_selector.py asserts the only relationship that matters between the two sets, over the twelve host combinations, rather than the text of either. Offline: direct_upstream_release_plan takes a release dict, and the gate's own block is lifted out of setup.ps1 with its inputs replaced. It fails 3 checks against the gate before this commit. While confirming this, the x64-fallback churn loop I had noted earlier turned out not to exist. An emulated x64 Python on an ARM64 box reports AMD64, so that venv is an x64 host to the selector, it installs windows-cuda, and the gate expects windows-cuda. All four x64 rows agree, before and after. * Fold the repeated script and source boilerplate in the WoA persistence tests Four whole installer and stack files were still being re-read per test; they are read once now. The 62 copies of the join-a-list-of-lines idiom and the 48 run-then-take-the-last-line pairs become _script and _ps_last, and the three copies of the parity normaliser become one _ps_copies helper. * Give the wheelhouse tag tests one cache fixture and one read of each source The find-links listing was cleared by hand in eleven try/finally blocks and a dozen bookend pairs; an autouse fixture now empties it before and after every test, and the clears that sit between two states inside a test stay. install.ps1, install_python_stack.py and extras.txt are read once, and the wheelhouse builder becomes a fixture rather than a helper taking three fixtures per call. * Read each source file once in the marker and guard behaviour tests install_python_stack.py was exec'd twice in the marker tests and install.ps1 was re-read and re-scanned for ten helper bodies on every one of the guard tests' thirty-five pwsh calls. * Use the hoisted source constants directly rather than aliasing them Sixteen tests opened with a one line rebinding of a module constant to a shorter local name; the constant is now named at the point of use. * Let the prerelease gate test tell producing the flag from comparing to it Remove-UvOnlyResolverFlags now names --prerelease=allow to translate it into pip's --pre. That is not a call site adding the flag, it runs after the gate has already allowed it, so the array-shape rule the gate test enforces does not apply to it. * Verify the Windows ARM64 CUDA bundle against GitHub's asset digest This was the one archive the installer would install unchecked. Every other prebuilt goes through apply_approved_hashes, which drops an attempt our own checksum manifest does not cover. The ARM64 CUDA branch could not: we publish no windows-arm64-cuda artifact yet, so nothing covers upstream's zip, and refusing outright would mean no CUDA llama.cpp on this hardware at all. So it logged that it was installing without a hash, and did. GitHub's release API now reports a per-asset digest. Checked against ggml-org/llama.cpp b10853: 27 of 27 assets carry one, including llama-b10853-bin-win-cuda-13.4-arm64.zip and the cudart archive it pairs with. That is weaker than our manifest, which we compute ourselves rather than read from the same host that serves the bytes, and the comment says so. It still pins the download to what the API listed, which is enough for this branch to stop being the exception. An asset GitHub states no digest for is now refused and the install falls through to the ARM64 CPU bundle, so no path installs an unverified archive. Three things kept deliberately narrow: * A separate github_release_asset_digests rather than a second return value from github_release_assets. That one is mocked in a dozen suites and used on every platform; this is read on one branch. * Any failure fetching the digests is an empty mapping, which that branch reads as refuse. A rate limit costs the CUDA bundle, not the install, and cannot be mistaken for "no digest needed". * release_asset_digests requires the sha256: prefix rather than stripping it for convenience. normalize_sha256_digest accepts a bare 64-hex string, and an unprefixed digest is one whose algorithm GitHub did not state. tests/studio/install/test_arm64_cuda_digest_verification.py covers the digest parser over seven rejected shapes, the drop and unpair rules, the fail-closed fetcher, and asserts against the source that every return in the resolver is hash gated. That last one fails on the branch before this commit, on the literal `return upstream_arm64_cuda`. 143 failures before and after across the four llama selection suites, identical sets, so nothing here changes any other platform's routing. * Bound the early GPU probes, gate the wheel on the driver, and tighten the resolver and index checks install.ps1 - Test-WoaNvidiaPresent and the new Get-WoaDriverCudaVersion run nvidia-smi through the bounded runner. - A CUDA wheel whose major exceeds the driver's falls back to the x64 stack with a driver-update message. - The av probe is gated on the resolver reaching PyPI; public PyPI supplied as an extra index (env or uv config) keeps the probe in play. - A managed wheel is redundant only when PyPI has the exact version, and the managed scan lifts win_arm64 or any wheels only. - On an equal numeric release a final build outranks a .dev one; later stamps win among dev builds. - The exact CUDA trio pin ignores UV_EXCLUDE_NEWER for that one install, since the index carries no upload dates. - A pyarrow staging failure after the venv exists is a hard stop with the opt-out named. - Rebased file: references become file URIs, so spaces are encoded. studio/setup.ps1 - The merged override file is recorded, removed at the end of the run and on failure, and any stale copy is removed first. - WinArm64NoAudio follows the effective-index probe; retention pairs torchaudio with the new torch by both predicates. - Same tie-break, file URI and exclude-newer handling as the installer. studio/install_python_stack.py, install_manifest.py, install_llama_prebuilt.py - Extra indexes that are public PyPI count; the resident xformers check runs after a failed refresh. - The NVIDIA index is persisted with scheme and host normalised. - Legacy bundle ordering synthesises names for the arch being resolved and passes it through. .github/workflows/windows-arm64-ci.yml - The ARM job fails when a required test is reported as skipped. Tests cover each change, including a real Exit-SetupFailure run under UNSLOTH_TAURI_MODE. * Make a paired torchvision part of the native gate, and read a supplied wheel's name from its archive install.ps1 - An index qualifies for the native path only when it publishes a torchvision paired with its torch; otherwise the next candidate is tried, and with none pairing the x64 stack is kept with the reason logged. The vision pin is the version the gate accepted. - UNSLOTH_PYARROW_WHEEL saved under another name (.bin, no extension) has its wheel name read from the archive before the project and tag checks, as staging already does. studio/setup.ps1 - When the effective index pairs no torchvision with its newest torch, the fresh-shell probe keeps the installed torch and torchvision pair instead of pinning a torch whose torchvision would resolve against another build. Tests cover the gate order, the x64 fallback, the kept pair, and archives named .bin or without an extension. * Rank prereleases in PEP 440 order, probe a reselected interpreter, and put the caller's resolver variables back install.ps1 - Within one numeric release the wheel pick follows PEP 440 order in full: dev below a below b below rc below the final build, later numbers winning within a kind. Same in setup.ps1's copy. - After the re-probe flips native mode, the reselected interpreter is probed before it is taken; one with no stack of its own is declined and the accepted interpreter keeps its answer. - UV_OVERRIDE, UV_FIND_LINKS and PIP_FIND_LINKS are snapshotted before the first assignment and restored in the script-level finally, so an `irm | iex` session does not keep resolving with Studio's overrides and wheelhouse. studio/backend/plugins/data-designer-unstructured-seed - The plugin admits pandas 3, which constraints.txt installs on Windows on ARM; it only reads and writes parquet. Tests cover the ordering, the reselection, the restore, and the plugin range against the constraints. * Format the three files this branch owns with the ruff the hooks actually pin .pre-commit-config.yaml pins ruff v0.15.18. I had been running scripts/run_ruff_format.py against a local ruff 0.6.9, and the two disagree about where an assert's message goes: 0.6.9 assert ( cond ), "message" 0.15.18 assert cond, ( "message" ) So every local format pass wrote the older shape and pre-commit.ci rewrote it on push. This puts the three files that are ours at the pinned formatter's fixed point instead. Verified rather than assumed: with 0.15.18, running the wrapper twice leaves every file byte-identical, so the wrapper is idempotent and it was only the version that was wrong. Four more files still differ under 0.15.18 and are left alone, because they differ identically on origin/main and so are not this branch's drift: studio/prebuilt_core.py, tests/studio/install/test_cuda_repair.py, tests/studio/install/test_rdna1_unsupported_message_8529.py and tests/studio/install/test_windows_torch_flavor_invariant.py. 855 tests pass across the three. * Let the dependency index follow the resolver policy, widen the ARM CI filters, and survive an upstream listing failure install.ps1 and studio/setup.ps1 - torch's shared dependencies resolve from the index the caller's resolver policy names (environment, then uv config), from public PyPI when it names none, and from the find-links wheelhouse alone under no-index. Invoke-InstallCommand clears the inherited index settings whenever --default-index is passed, so the policy is restated on the command line. setup.ps1 carries the readers as parity copies. .github/workflows/windows-arm64-ci.yml - The path filters name pyproject.toml and the whole requirements tree the ARM tests read. studio/install_llama_prebuilt.py - A failure listing the upstream release assets on a Windows ARM64 NVIDIA host costs the CUDA bundle only; the planner falls through to the published ARM64 CPU bundle. Tests cover the policy readers in both scripts, the filters, and the fall-through. * Judge index reachability and the dependency index for the resolver that runs the pass uv reads UV_* and its configuration files and ignores PIP_*; pip reads PIP_* alone. Reading both sets together reported public PyPI reachable from a PIP_EXTRA_INDEX_URL that uv, the resolver in use, never consults, and then dropped the librosa and tensorboard skip on wheels the resolve could not fetch. - studio/install_python_stack.py: the probe branches on USE_UV; the uv path also honours UV_NO_INDEX. - install.ps1: the probe reads uv's policy only, since this script resolves with uv; Get-WoaDependencyIndexArgs takes the resolver and reads that resolver's variables, uv's configuration files for uv alone. - studio/setup.ps1: the parity copy, with the resolver it will use passed in. Tests cover the cross-resolver case in all three places and the pip fallback's own policy. * Keep markers out of rebased file references, clean the merged overrides on every exit, and stop on an unwritable wheel directory install.ps1 and studio/setup.ps1 - A PEP 508 marker on a `pkg @ file:...` override is kept aside while the path is rebased and re-attached after the URI. - setup.ps1 traps a terminating error after the resolver restore, removes the merged override file, and rethrows. install.ps1 - A wheel directory that cannot be created after the ARM64 venv exists is a stop with the reason, not a stand-down into an index with no win_arm64 wheel. The directory is verified after New-Item, which can return without creating it when a file is in the way. - The trio command names the staged wheelhouse with --find-links, since the inherited UV_FIND_LINKS is cleared for a pinned index; UV_NO_INDEX yields for that one command and is restored after it. - The two probe warnings that print an index URL redact its credentials. studio/backend/requirements/no-torch-runtime.txt - sqlite-vec carries the negative Windows ARM64 marker, as it does in studio.txt. Tests cover each change; the marker table claims the new row. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the five Windows-on-ARM tests actually run on Windows All five assert Windows-on-ARM behaviour and none of them could execute their own fake on Windows, so they failed on the one platform they are about while passing on Linux. windows-arm64-ci.yml runs this file on a win-arm64 runner under both shells and asserts sysconfig.get_platform() first, so these were red, not skipped. Test-WoaNvidiaPresent. The fake was an extensionless file containing #!/bin/sh. Windows has no shebang handling and PATHEXT covers no extensionless name, so the probe found nothing at all. Now written as nvidia-smi.cmd on Windows, which is the idiom the rest of this suite already uses, and the sh script elsewhere. The hung-probe case shared the same fake and so was a false pass: it asserted the call returns, and a probe that finds no executable returns immediately. It now actually blocks, via ping rather than timeout, since timeout /t needs a console. The installed-pair probe. The block runs <VenvDir>/Scripts/python.exe by that exact name, and the fake was a shell script called python.exe, which Windows will not execute because it is not a PE. All three cases therefore asserted the floor rather than the branch they were written for. Replaced with a real venv carrying real .dist-info metadata, so importlib.metadata answers the way it would after a real install and the interpreter answers the tag query itself. Confirmed it now bites: breaking the installed-pair read fails test_the_installed_pair_is_kept, which it could not do before. The file: URL row. Its expectation was built by pasting a rebased path after "file://". A rebased Windows path is C:\opt\corp\ov\dist\a.whl, and "file://" plus that is not a file URL: the authority would be C: and the separators are wrong. The rewriter emits file:///C:/opt/corp/ov/dist/a.whl, which is correct, so the code was right and the expectation was not. Split into its own test that builds the URL with pathlib as_uri instead of concatenating. 26 pass here on native Windows ARM64 under PowerShell 5.1. * Add soxr to librosa's unblock set: it has no win_arm64 wheel either librosa is on the win_arm64 skip list and comes off it once its blockers are installable. The blocker set was llvmlite and numba, but librosa 0.11.0, which is what extras.txt pins, requires soxr>=0.3.2 as well, and soxr has published no win_arm64 wheel in any release up to 1.1.0. Verified against the PyPI JSON API for the pinned version rather than for latest, since only the pinned release's metadata is the one that applies. So hosting cp313 numba and llvmlite would have un-skipped librosa, and the extras pass would then have gone looking for soxr and found only an sdist. Building that needs the toolchain the whole skip list exists to avoid. Nobody has hit it because numba and llvmlite publish win_arm64 for cp314 only, so librosa has never actually un-skipped on a cp313 host, which is what kept it latent. soxr also gets a floor entry, read from librosa 0.11.0's own metadata, matching the provenance convention the other two floors already follow. openai-whisper is left alone deliberately: its metadata asks for numba and tiktoken and does not mention soxr. Upstream is dofuuz/python-soxr PR 36, "Add Windows ARM64 wheels", still open. The three existing tests that drove librosa off the skip list now stub the index answer for soxr, so they stay about what they were written for: the interpreter tag deciding availability, the empty-wheelhouse early return, and losing the index offline. The new truth gets its own test, which fails when soxr is taken back out of the tuple. 165 pass. * Name the wheelhouse on the setup.ps1 trio command, read pip's config files, evict a stale xformers without a hosted wheel for PR #10282 setup.ps1: Fast-Install clears UV_FIND_LINKS and PIP_FIND_LINKS beside the other inherited index settings whenever --index-url is given and leaves UV_NO_INDEX alone, so a native CUDA update under --no-index saw neither the staged wheelhouse nor the CUDA index. $WinArm64IndexArgs now carries --find-links for the wheelhouse when the directory exists, and UV_NO_INDEX yields for the trio command through the existing cutoff save and restore, as install.ps1 already does. install_python_stack.py: the pip path of the PyPI reachability check read PIP_* only. It now consults `pip config list` when the environment is silent: an `[install]` or `[global]` no-index or exclusive index-url set in pip's files means PyPI is not in play, a PyPI extra-index-url keeps it in play, and a pip config that cannot be read keeps the skip. :env: rows are skipped since the variables were already read. install_python_stack.py: the resident xformers compatibility check ran only after a hosted install attempt, so a copy an earlier run installed against another torch stayed resident whenever the wheelhouse stopped offering a usable wheel. The check is now a helper that also runs when nothing usable is hosted. Tests: setup.ps1 twin of the no-index trio class (find-links on the command line, UV_NO_INDEX yield and restore, off-ARM64 untouched), the pip fallback rows carry --find-links through the pip translation, a pip configuration class with stubbed and real `pip config list` output, and the wheelhouse optional rows for eviction without a hosted wheel. Twelve mutants on the three changes are killed. * Search every supported nvidia-smi location for the driver version Test-WoaNvidiaPresent searched PATH, System32 and the NVSMI folder, but Get-WoaDriverCudaVersion searched PATH and System32 only. On a host that carries nvidia-smi.exe under %ProgramFiles%\NVIDIA Corporation\NVSMI alone, the presence probe reported a GPU and the version probe returned nothing, so Initialize-WoaNativeCudaTorch skipped its CUDA-major compatibility guard and could select a cu13x ARM64 wheel against a driver that only advertises CUDA 12. Factor the candidate list into Get-WoaNvidiaSmiPath and have both probes call it, so the two cannot disagree. Same two locations the main GPU detection already searches. * Inject Get-WoaNvidiaSmiPath into the scripts that paste the probes' real bodies The pwsh tests compose real function bodies into a bare script. Routing both nvidia-smi probes through the shared lookup gave those bodies a call the compositions did not inject, leaving it unresolved. One of the two would have failed loudly. The other passes for the wrong reason: PowerShell writes the unresolved-command error and continues with $null, so Get-WoaDriverCudaVersion returns $null before it ever calls nvidia-smi and the '[]' assertion still holds, with the timeout the test exists to bound never exercised. Both are marked requires_pwsh, so on a host without pwsh they skip and the suite is green either way. Add a guard that needs no pwsh: it fails if a composition injects a body calling Get-WoaNvidiaSmiPath without also injecting the helper. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Read uv's inline index spelling, and stop guessing when it cannot be read Read-WoaUvTomlIndexKeys returned $null on sight of an `index` key, so `index = [{ url = "...", default = true }]` -- valid, documented uv config -- read as Unreadable. Get-WoaDependencyIndexArgs never looked at Unreadable and fell through to its https://pypi.org/simple default, silently replacing whatever mirror the file configured. Test-WoaResolveReachesPyPI had honoured the same flag all along, so the two consumers of one signal disagreed, and the unsafe one built the install command. Parse the inline form, conservatively: a single-line, brace-balanced array of flat inline tables only. A multi-line array, a nested value, a bare entry beside a table, a missing url, a non-boolean default or an explicit index all still return $null and stay Unreadable, because guessing is the only outcome worse than not parsing. Then make the consumers agree, per call site, since they genuinely differ. With no env default and an unreadable policy, name no dependency index rather than substituting PyPI. install.ps1's trio step calls uv directly, so uv reads the file itself and the empty answer is strictly better than today: no substitution and no failure. setup.ps1's runs under Fast-Install with --index-url, which sets UV_NO_CONFIG and scrubs UV_*, so uv cannot read it and an empty answer would leave the trio index as the only source; that one stops with a message naming the file, the spelling, and both ways out. Get-WoaUvConfigIndexPolicy now records which file it could not read so the message can name it. Blast radius, traced before writing: a previously-$null read now returns a value, which moves Test-WoaResolveReachesPyPI and therefore wheel pruning. A mirror URL still gives false and still disables pruning, so that case is unmoved. A genuine pypi.org URL now gives true and enables it, which is correct and was previously suppressed by a parse failure rather than by a decision. Verified against pwsh 7.4.6 rather than skipped: the inline reader is exercised on five accepted shapes and six ambiguous ones. Running them for real caught a bare string entry beside a table being accepted, because the scanner entered string mode before testing depth. Full tests/studio/install plus the wheelhouse tags suite gives an identical failure set to HEAD, 173 either side, none new. * Apply the pinned formatter's kwarg spacing to the new inline-index cases The ruff-format-with-kwargs hook pins ruff==0.6.9, which wraps the new parametrize tuples one element per line. Formatting only; the 733 tests in the two files are unchanged and still pass, and a second run of the hook is a no-op. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Default the Windows on ARM wheelhouse to the published one for PR #10282 The default named unsloth/windows-arm64-wheels, which does not exist, so no host took the native path without UNSLOTH_WOA_WHEELHOUSE set. The wheelhouse now lives under danielhanchen/unsloth-blackwell-docker: pyarrow 25.0.1 for cp311 through cp314, built from the Arrow release tag with apache/arrow#48539's build script on a windows-11-arm runner, and sqlite-vec 0.1.9 repacked with an ARM64 DLL built from its release tag. Every wheel was smoke tested on the ARM64 runner, every native image inside is ARM64, and install.ps1 ran its native path against the published URL end to end (pyarrow 25.0.1, torch 2.14.0+cu134, sqlite_vec 0.1.9 all importing in the win-arm64 venv). The test that pins the URL follows. * Share the PowerShell test harness and parametrize the Windows on ARM persistence tests for PR #10282 * Shorten the comments the Windows on ARM installer added for PR #10282 * Parametrize and share fixtures across the Windows on ARM wheelhouse tests for PR #10282 * Keep explicit uv indexes out of the dependency index, prune wheels an earlier wheelhouse left for PR #10282 Explicit indexes: uv serves an index marked explicit = true only to packages pinned to it via [tool.uv.sources], so it is neither the default nor an extra for the trio's shared dependencies. The block form [[index]] reader in install.ps1 and setup.ps1 read url and default only and flushed such an entry as a general extra, which Get-WoaDependencyIndexArgs then passed as --extra-index-url to the native torch resolve; the inline table reader refused the key outright, which turned the documented PyTorch pattern into an unreadable policy. Both readers and the Python reader now skip an explicit entry; explicit together with default = true also removes PyPI as the default, which is not modelled, so that combination stays doubt. Stale wheels: the managed woa\wheels directory persists across runs, so after UNSLOTH_WOA_WHEELHOUSE changed a wheel the earlier wheelhouse staged stayed in it, the hosted scan read it as current and UV_FIND_LINKS installed it from a source no longer configured. Staging now reconciles the directory against the wheelhouse listing it just read, keeping the listed wheels and the selected pyarrow wheel; an unreadable listing (offline) keeps the staged copies, and the directory used as its own wheelhouse lists everything it holds. Tests: block-form and inline rows on both scripts plus the Python reader, the dependency args never naming an explicit index, and the reconciliation across a local wheelhouse, a URL wheelhouse, an offline listing and the self-sourced directory. Ten mutants on the change are killed. * Shorten the comments the explicit-index and wheelhouse reconciliation changes added for PR #10282 * Hand PyPI-provided wheels to the skip list, install a hosted torchcodec, order xformers after the final torch repair, and key the pip refusal to the generated overrides for PR #10282 * Keep folded caller overrides in a per-run file rather than the persistent WoA overrides for PR #10282 * Backtrack to the newest paired torch and keep inline comments when rebasing override lines for PR #10282 * Form the rebase test's expected paths per host and let the ARM64 CI print its pytest output for PR #10282 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add a signed Windows on ARM wheelhouse workflow that builds, Authenticode signs and publishes the pyarrow and sqlite-vec win_arm64 wheels for PR #10282 * Publish the Windows on ARM wheelhouse under the Windows-ARM64 release tag for PR #10282 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> | 2 天前 | |
WSL installer: stop running the C# compiler on the user's Windows machine (#10978) * WSL installer: stop running the C# compiler on the user's Windows machine `install.sh` generates a PowerShell script and runs it on the Windows side to create the WSL shortcut. Two constructs in that path were removed from `install.ps1` in earlier passes and left live here, where nothing looked: - `install.sh:2249` declared `SHChangeNotify` with `Add-Type -MemberDefinition`. On Windows PowerShell 5.1 that writes C# to `%TEMP%` and runs `csc.exe`, producing a DLL on the user's machine. #10540 traced exactly that chain to Bitdefender `Gen:Variant.MSILHeracles.272113`: `unsloth-stud... -> powershell.exe -> csc.exe -> %TEMP%\<random>.dll`. It replaced the declaration in `install.ps1` with reflection emit and missed this copy. - `install.sh:2273` launched the generated script with `-ExecutionPolicy Bypass`, the flag #7822 removed everywhere else. Neither was caught because `test_the_installer_never_runs_the_c_sharp_compiler` parametrised over `("install.ps1", "studio/setup.ps1")` only, and `windows-no-compiler-ci.yml` did not list `install.sh` in its path triggers. A shell script that emits PowerShell can put a compiler on a Windows machine without touching a `.ps1`. ## The compile `SHChangeNotify` is now emitted, mirroring `install.ps1:3485-3499`: a `DefineDynamicAssembly` / `DefineDynamicModule` / `DefineType` / `DefinePInvokeMethod` / `CreateType` block inlined, since the generated script is standalone and cannot call `New-StudioEmittedNativeType`. The enclosing `try {} catch {}` stays, so a failure still costs only a stale icon, and both notifications stay: per-item `SHCNE_UPDATEITEM` + `SHCNF_PATHW` per `.lnk`, then the global `SHCNE_ASSOCCHANGED`. The per-item call is not redundant, and the `ALLOWED_PINVOKES` entry records why: `ie4uinit.exe -show` does not recover a stale same-name `.lnk`. I ran the emitted type rather than assuming it: under pwsh it produces `void SHChangeNotify(Int32, UInt32, String, IntPtr)`, identical to the `Add-Type` declaration, `PreserveSig` set, and `Assembly.Location` empty, which is the point. Nothing reaches disk. ## The execution policy `Bypass` was load-bearing, for one reason: `wslpath -w /tmp/unsloth-shortcut-XXXXXX.ps1` yields a `\\wsl.localhost\<distro>\...` UNC path, PowerShell treats a script there as remote, and `RemoteSigned` refuses an unsigned remote script. So rather than relax the policy, the script now goes to the Windows `%TEMP%` (`cmd.exe /c echo %TEMP%`, CR stripped, `wslpath -u`), which is a local volume and therefore `MyComputer` zone, where `RemoteSigned` loads it unsigned. Same generated script, same launch, same result. When `%TEMP%` cannot be resolved there is deliberately **no fallback to WSL's `/tmp`**, because that is the case that needed the relaxed policy in the first place. Nothing is allocated and the caller takes the notice it already prints when interop is unavailable. That population is very nearly the population where interop is already broken. ## Tripwires, with messages that say what is wrong `tests/sh/test_wsl_shortcut_script_location.sh` evals the real resolution block out of `install.sh` with `cmd.exe` and `wslpath` stubbed, covering resolved, no-interop, unexpanded `%TEMP%`, and a `%TEMP%` that is not a directory, then asserts statically that the launch uses `RemoteSigned`, that the file relaxes the policy nowhere, that it compiles no C#, and that both refreshes survive. A failed extraction is reported as a failure rather than exiting early, so the output names every problem at once, and it still fails closed. `clean-machine-install-ci.yml`'s `wsl` job is the only lane that runs this branch, so it gains the behavioural half: read the created `.lnk` back and refuse a script-host target, require the generated script to have been cleaned up, and fail on any `.cmdline`/`.rsp` under the Windows `%TEMP%`, which is csc.exe output and nothing else. That check plants two intermediates and requires the search to find both before trusting a negative result, because a detector that sees nothing looks exactly like a clean run and every reason it might see nothing is silent. If the control does not fire the job fails as a broken detector instead of reporting a pass. Shortcut absence is a warning, not an error, since the installer itself treats shortcut creation as best-effort. `install.sh` is added to the compiler test's parametrise list and to `windows-no-compiler-ci.yml`'s paths. The `DefinePInvokeMethod` assertion in that test becomes conditional on the file declaring a native import at all, so it does not fail for being satisfied on `scripts/uninstall.ps1` or `studio/setup.sh`. ## Verification - The new shell suite: 16 passed on this branch, and against `main` in a worktree it exits 1 with 6 named failures. Proved to bite rather than assumed to. - The generated WSL script rendered and parsed with `[System.Management.Automation.Language.Parser]::ParseFile`: clean over 765 tokens. No existing gate parses that here-string, and this change edits it. - `bash -n` and `sh -n` on `install.sh`; all of `tests/sh` passing; the parse gate clean over `install.ps1`, `studio/setup.ps1` and `scripts/uninstall.ps1`. - `tests/studio/test_installer_av_shapes.py`, `tests/studio/install/`, `tests/security/`: green. - The new CI PowerShell parsed standalone, and its positive control plus detector exercised for real against a planted `csc.exe`-style intermediate: control 2 of 2, simulated compile caught, clean directory reports zero. I have no Windows or WSL here, so the runtime behaviour of the shortcut path rests on the `clean-machine-install-ci.yml` WSL job, not on a local run. This is a shape fix with a confirmed detection behind the shape; it is not a claim that any particular user report is resolved. * Fix a test of mine that passed for the wrong reason, and could only fail on a real CI machine `test_wsl_shortcut_script_location.sh` asserted that the generated shortcut script's path does not start with `/tmp`, as a proxy for "not in WSL's own temp, which wslpath turns into a \\wsl.localhost UNC path that RemoteSigned refuses". The proxy is broken: the harness builds its *fake Windows temp* with `mktemp -d`, so on any machine with `TMPDIR=/tmp` -- which is every normal CI runner -- the stub's own directory matched the pattern and the test failed while install.sh was behaving exactly as intended. It passed locally only because this workspace sets `TMPDIR` elsewhere. That is precisely the class of accidental pass the rest of this work has been built to catch, so it is worth naming rather than quietly correcting. The replacement asserts the property the proxy was standing in for, structurally: the block contains exactly one `mktemp`, so there is no second path to fall back to, and that `mktemp` allocates inside `$_css_win_temp`. Both are true or false regardless of where anyone's temp directory happens to live. Verified under the exact condition that broke it: `TMPDIR=/tmp` gives 17 passed, 0 failed on this branch, and 6 failed on `main` in a clean worktree -- so the test still bites. * WSL installer: read %TEMP% with cmd /d, so an AutoRun command cannot cost the user their shortcut Reading the Windows %TEMP% through interop is new in this branch, and it reads it with `cmd.exe /c echo %TEMP%`. Without /d, cmd first runs the AutoRun command from HKLM or HKCU \Software\Microsoft\Command Processor, and `cmd /c` is not exempt from that. AutoRun writes to the same stdout, so its output arrives ahead of the path and `tr -d '\r\n'` glues the two into one string. wslpath then rejects it, the directory check fails, and the branch takes its "couldn't create the Windows shortcut" notice. Clink sets an AutoRun, so Cmder does, and so do plenty of corporate images. Every one of those users got a working shortcut before this branch and would silently stop getting one, while being told WSL interop is disabled, which is not what happened. So: /d, which is the cheap fix rather than out-parsing whatever prints. Then the last line rather than the whole stream, for anything that still reaches stdout by another route, and trailing blanks trimmed, because Win32 strips them from a path and [ -d ] does not, so a %TEMP% set with one resolved to a directory that appeared not to exist. The || stays: this file runs under set -e, where a failed substitution ends the install. Tests ----- tests/sh/test_wsl_shortcut_script_location.sh grows the AutoRun and trailing-blank cases and a static check for /d, and its wslpath stub is now input-sensitive. It was not, and that mattered: a stub that returns the fake Windows temp whatever it is handed cannot tell "install.sh read the path" from "install.sh read a banner glued to a path", so both new cases passed against the unfixed file until the stub compared its argument. Against the unfixed install.sh the four new assertions fail; with it they pass. tests/studio/test_wsl_shortcut_script_is_valid_powershell.py is new and covers a gap nothing else did: that what install.sh generates is PowerShell at all. bash -n sees the here-string as a here-string, the contents only run on Windows with both streams sent to /dev/null, and the icon-refresh block runs inside try/catch, so a parse error there is not a crash and not a message, just no shortcut and a misleading notice. The block was just rewritten into a twenty-line reflection-emit sequence, which is when that matters most. The file renders the here-string with a real shell rather than keeping a copy, parses it, and then executes the emit sequence retargeted at a symbol this host exports, so a wrong call order or argument count fails on Linux. Five mutations of the shipped file were tried; four fail it, and the docstring names the fifth that does not and why. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Watch the WSL install live, instead of searching for what CodeDom already deleted The lane looked for .cmdline and .rsp files in the Windows temp after the install had finished. CodeDom removes its whole intermediate directory once the assembly is loaded, which Watch-ForCompiler.ps1 records as a before-and-after diff seeing nothing at all while 4688 recorded csc.exe, so that search could not fail for the case this lane exists to catch. Its positive control planted persistent files, which proves the search can traverse a directory and nothing about whether ephemeral compiler output is observable. The install now runs inside Invoke-WithCompilerWatch, the same detector windows-no-compiler-ci.yml uses, which unions 4688 with a live FileSystemWatcher and treats an unreadable Security log as void rather than clean. The dropped-library half is checked too, since that is what the Bitdefender report in #10540 keyed on. The post-hoc listing stays for what genuinely is left behind, which is the other half of the union, and its message now says that is what it means. Watch-ForCompiler.ps1 is in both path filters now, because this job's detection depends on it, and the new guard is registered in the unfiltered lint job since it reads a workflow file. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Arm the 4688 half of the compiler watch in the WSL lane Watch-ForCompiler.ps1 requires the caller to enable process creation auditing, and this job never did, so its Security log query returned an empty compiler list whatever ran while the step still printed that no compiler process was seen. Enable and verify the audit policy before the install, and require a deliberate compile to be detected first, so a clean verdict is distinguishable from a detector that never attached. * Close the AV shape-guard holes, and load the installers under RemoteSigned (#10982) * AV shape guards: watch the two shipped scripts nothing watched, and ratchet the policy sites Stacked on the WSL compiler fix, which shares this file. Two problems, both found while investigating #10805, and both about the guard rather than the code it guards. ## Two shipped scripts were in no list `PS_SCRIPTS` and `SH_SCRIPTS` between them missed `studio/setup.bat` and `scripts/uninstall.sh`. A shipped `.bat` is scanned like any other file, and `setup.bat` launches PowerShell, so every rule here applies to it. That is how its `-ExecutionPolicy Bypass` survived the passes in #7822 and #8586: the guards were reading a set of files that did not include it. Both are added to `ALL_SCRIPTS`. ## The hidden-window pair check only ever looked at one line `test_a_hidden_window_never_pairs_with_a_bypassed_policy` iterated `splitlines()` and compared the two flags within a single physical line. `install.ps1` assigns `$shortcutArgs` a hidden window at :3419 and overwrites it with a relaxed policy at :3433, fourteen lines apart in the same function, and that passed for as long as it existed. A scanner reads the file, not the line. Three layers now: 1. Same line, unchanged, because it gives the clearest message when it is the real shape. 2. A ratchet on how many relaxed policies the file contains at all, against `KNOWN_BYPASS_SITES`, which records each remaining site with the reason and what would remove it. It fails **both** ways: too many is a new site, too few is a stale entry that has quietly stopped guarding anything. This is the layer that catches a policy arriving somewhere the other two do not model. 3. Same variable, any distance: the union of everything assigned to one name must not contain both flags. This is the layer that sees the 3419/3433 shape. `install.ps1`'s `$shortcutArgs` is recorded in `KNOWN_SPLIT_PAIR_VARIABLES` rather than failed on. Removing it needs `launch-studio.ps1` written to a guaranteed-local directory first, because `%LOCALAPPDATA%` can be folder-redirected to a UNC path where RemoteSigned refuses an unsigned script, and a shortcut that silently does nothing is worse than the token. Recording it lets the guard land now and lock in "no new ones", which is the part that matters going forward. ## Verification 70 passed in the suite, and each new layer was confirmed to bite rather than assumed to: - A new relaxed policy injected into `studio/setup.sh` fails layer 2, naming the file, line number, the offending line, and what to do about it. - Removing the recorded site from `studio/setup.bat` without lowering its count fails layer 2 the other way, telling the reader to lower the count in the same commit. - Layer 3 proved in isolation by renaming `$shortcutArgs` to `$launchArgs` in `install.ps1`: the relaxed-policy count is unchanged so layer 2 passes, and layer 3 fires on its own. The tree was restored after each and re-verified clean. `tests/studio/install/` and the shape suite are green; `test_pr10648_offline_and_status.py::test_the_pr_only_added_new_arms_to_the_setup_status_blocks` fails identically on `main` in a separate worktree and is untouched by this change. * Load the installer scripts under RemoteSigned instead of relaxing the policy Removes three of the four relaxed execution policies the ratchet in the previous commit recorded. RemoteSigned refuses an unsigned script only in the Internet or Untrusted zone, so for a local, unmarked script it behaves identically to Bypass while dropping a token endpoint products score. ## studio/setup.bat `powershell -ExecutionPolicy Bypass -File "%~dp0setup.ps1"` becomes RemoteSigned. setup.ps1 ships beside the batch file inside an installed package, so it is MyComputer-zone and loads unsigned either way. One population could regress: a package unzipped from a download, where setup.ps1 carries a mark of the web that RemoteSigned honours and Bypass ignored. So the mark is cleared first. Execution policy governs script *files* and not `-Command`, so that call runs under any machine policy, and clearing an absent stream writes nothing. The path travels in an environment variable rather than being interpolated into the command string, because an apostrophe in an install path is a real Windows user name and would otherwise break the quoting. `-NoProfile` is on the unblock call and deliberately **not** on the launch. The launch has always run setup.ps1 with profiles loaded, and `tests/studio/test_amd_venv_repair_loop.ps1` drives a profile that sets `Set-StrictMode -Version Latest` against it. Adding the flag would have retired that coverage with nothing failing, so the new test asserts its absence and says why. ## scripts/uninstall.ps1 and README.md Both print `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass` as the documented way to run a cloned script. Changed to RemoteSigned rather than dropped: the Windows client default is `Restricted`, which blocks every script file including a local unsigned one, so the `Set-ExecutionPolicy` itself is load-bearing and removing it would break the documented path. RemoteSigned is sufficient because a git clone carries no mark of the web, and both now say so and point a zip user at `Unblock-File`, which is the case where the two policies genuinely differ. The uninstaller line is never executed -- it lives in the printed `_Usage` here-string -- but AMSI scans the file whole and a classifier cannot tell a string from a statement. ## Where that leaves the ratchet Four recorded sites down to one. The survivor is `install.ps1:3433`, the roaming-profile fallback for a launcher on a share, and it stays because `%LOCALAPPDATA%` can be folder-redirected to a UNC path where RemoteSigned would refuse the launcher, and a desktop shortcut that silently does nothing is worse than the token. Removing it needs `launch-studio.ps1` written to a guaranteed-local directory first, which is a larger change than this one. ## Verification - 751 passed, 6 skipped across the shape suite, `tests/test_installer_interactive_prompts.py` and `tests/security/`. All of `tests/sh` passing. The parse gate clean over all three `.ps1` files. - Both new `setup.bat` assertions confirmed to bite: adding `-NoProfile` to the launch fails with the reason and names the test that would lose coverage, and reverting the policy to Bypass fails naming the offending line. The tree was restored after each. - Relaxed-policy sites counted directly across all seven shipped scripts afterwards: one, in install.ps1, as recorded. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Step setup.bat down to Bypass for a script on a share, the way install.ps1 already does Tightening setup.bat from Bypass to RemoteSigned had one case that is a genuine regression rather than a theoretical one, and it needed handling rather than accepting. Execution policy is judged on the script file's ZONE. A plain \\server\share and a mapped drive are Intranet and load fine under RemoteSigned -- but a dotted-FQDN UNC (\\corp.example.com\...), a DFS path or an IP-literal share is the Internet zone, where RemoteSigned refuses an unsigned script. And Unblock-File cannot save that case: with no Zone.Identifier stream present the PATH decides, so clearing a stream that was never there changes nothing and exits silently. Bypass worked; RemoteSigned does not. install.ps1:3405-3421 already solves exactly this when it writes the Start Menu shortcut -- UNC prefix, then DriveInfo.DriveType -eq 'Network' -- and the new test's own comment calls that site "the only one that is genuinely load-bearing" for the same reason. setup.bat got no such exemption. Now it reuses the identical logic rather than inventing a second answer. The probe does the Unblock-File too, so this costs one process rather than two, and it runs under -Command, which execution policy does not govern at all. Two details that matter more than they look: - The batch variable DEFAULTS to RemoteSigned. A probe that fails to run therefore leaves the tightened policy in place rather than silently restoring the relaxed one. - -NoProfile is on the probe and still deliberately absent from the launch. tests/studio/test_amd_venv_repair_loop.ps1 drives a profile that sets Set-StrictMode against setup.ps1, and adding it would retire that coverage with nothing failing. Tests: the embedded probe is extracted from the batch file and PARSED, because it is one long line inside a `for /f` backquote block and a syntax error there fails quietly -- the `for /f` captures nothing, the RemoteSigned default stands, and the mark is never cleared, so an unzipped download gets a refusal with no hint that the probe was what broke. The pre-existing setup.bat test located the launch by literal filename, which is now an environment variable, so it matches on the flags instead. 5713 passed, 39 skipped across tests/studio/test_installer_av_shapes.py, tests/studio/install/ and tests/security/. The single failure, test_pr10648_offline_and_status, is pre-existing on main. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Group the hidden/bypass pair by casefolded variable name PowerShell variable names are not case-sensitive, so $shortcutArgs and $ShortcutArgs are one variable. The same-variable layer keyed its map on the captured spelling, so writing the two assignments with different capitalisation filed them as two unrelated names, each holding only one of the two flags, and the pair went through. The file-wide ratchet did not catch it either, since install.ps1 already records one permitted relaxed policy. Verified by constructing that exact evasion: renaming the two install.ps1 assignments to $launchArgs and $LaunchArgs passes under the old grouping and fails under this one, naming both spellings. * Stop the installers from explaining, in prose a classifier reads, which antivirus flagged them (#10990) * Stop the installers from explaining, in prose a classifier reads, which antivirus flagged them PowerShell hands the entire top-level script block to AMSI at compile time, before the first statement runs, so every byte of install.ps1 is classifier input -- comments included. VirusTotal's analysis of the file quoted one of our own comments back as grounds for suspicion: "The presence of comments that suggest the script is designed to evade detection by security tools (e.g. "AMSI scans this file in full before a line of it runs") further adds to the suspicion." Six hardening passes wrote careful explanations of which product flagged which shape directly into the files being scanned. The explanations are worth keeping. Their location is not. - A comment in a shipped script says **what the code does and what breaks if you change it**. - `AV_SHAPES_RECORD`, in `tests/studio/test_installer_av_shapes.py`, says **which scanner flagged which shape, and when**. The record lives in that test rather than a doc because the file already owns the guards that enforce the split, it ships to nobody, it is not packaged, and nothing scans it. Each relocated comment keeps its operational content verbatim and gains a pointer. The diff over the five shipped scripts contains no non-comment line, which is checked mechanically rather than asserted. The record is not a dumping ground. It holds the measured detections (Skyhigh `BehavesLike.PS.Suspicious.gr` on install.ps1, Bitdefender `Gen:Variant.MSILHeracles.272113` on the csc.exe output in #10540, Kaspersky `HEUR:Trojan.VBS.Agent.gen` on the .vbs launcher shape, the two fixture archives that made Panda flag the repo zip), the measured before-and-after from `virustotal_delta.py` including the part that did NOT move, why each decision is a robustness fix and not an evasion, the five shapes we are **keeping** on purpose with the reason each is load-bearing, and how to report a new detection. Three things a future pass will otherwise rediscover are written down there, because each one already cost a wrong proposal: execution policy is evaluated against the script file's zone and not against who launched PowerShell, so a `.cmd` shim cannot launder a remote-zone script; UNC paths are a remote zone, which is the whole reason install.sh resolves the Windows `%TEMP%`; and the Windows client default is `Restricted`, so documentation telling a user to set a process-scoped policy is load-bearing and only the word `Bypass` needed to go. A banned-token test alone is satisfiable by deleting the knowledge rather than moving it, so there are three: - `test_no_shipped_script_names_a_detection` over every shipped script including `studio/setup.bat` and `scripts/uninstall.sh`, which no shape test read before. - `test_the_record_survives_and_keeps_its_evidence`, which requires the sections AND the specific detection strings -- a record with headings and no evidence is the same loss with extra steps. - `test_every_script_that_dropped_its_explanation_points_at_the_record`, because a comment reduced to "security software blocks this" with no forward reference is worse than the prose it replaced: the next maintainer cannot tell whether it is still true. Deliberately not banned: `antivirus`, `quarantine`, `scanner`, `blocked`. Those describe a real runtime hazard the code handles and one of them reaches the user. Also not banned, for a more interesting reason: **`false positive`**. It was on the list, and the test immediately found it in install.ps1 and studio/setup.ps1 -- where it means a *statistical* false positive in a registry probe and has nothing to do with a scanner. A token list is only as good as its words having one meaning, and that one does not. - 14 of the new assertions fail against `main` and none against this branch, so they are proved to bite rather than to pass vacuously. - No non-comment line changed in any of the five scripts (`git diff -U0` filtered for lines that are not comments returns empty). - `bash -n` clean on both shell scripts; `Parser::ParseFile` clean on all three `.ps1` files. - Existing tests and CI that reference the old detection strings are unaffected: they live in `tests/` and `.github/`, which ship to nobody and are scanned by nothing. - One self-inflicted bug caught while writing this: a blanket rename of the old doc path rewrote the "must not still point at the doc" assertion into asserting the opposite. The stale path is now assembled from fragments so a future rename cannot do it again. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Match banned vendor tokens on word boundaries, and drop the one that cannot work The guard searched for each token as a raw substring, so it rejected ordinary installer prose: 'panda' is inside 'pandas', a real dependency name, and 'rising' is inside 'surprising', 'arising' and 'comprising'. The failure message then accuses a normal sentence of naming an antivirus vendor, and the fix the next reader reaches for is to delete the guard. Boundaries are conditional on the token's own edges, because 'heur:' and 'gen:variant' sit on a colon where \b asserts the opposite of what is wanted. 'rising' is removed rather than bounded. It is a real engine and one of the two that flagged the fixture archives, but the word is also ordinary English -- 'rising memory use' is a sentence someone will write -- and no amount of boundary matching separates the two. A guard that fails on valid prose is worth less than nothing. 'tencent', the other engine that flagged those archives, has no such problem and stays. Verified both directions against studio/setup.sh: a comment naming Bitdefender, Gen:Variant and Panda still produces three failures, while 'rising memory use' plus 'installs pandas' now passes and produced two misleading failures before. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Carry the AppDomain fallback into the WSL emit, and quote TEMP inside cmd The generated script had only the static AssemblyBuilder spelling under a bare catch, so on any host lacking that overload both SHChangeNotify refreshes would stop with nothing printed. install.ps1 already tries both for this reason; mirror it. cmd expands a variable before it parses metacharacters, so an unquoted echo %TEMP% on a profile holding an ampersand, which is legal in a Windows account name, splits into two commands and the captured line is not the temp path. Quote it inside cmd and strip the quotes back off. The test stub now reproduces cmd's expand-then-parse order so the ordinary path still passes and only the ampersand case moves. * Keep the backend prewarm files as they are on main An all-files formatter pass reflowed two studio/backend files that have nothing to do with the WSL installer change and pulled them into this PR's diff. Restore them so the blast radius is only the installer. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Strip cmd's quotes before trimming trailing blanks Trimming first found nothing to trim, because the closing quote was still the last character, so a Windows TEMP ending in spaces came back untrimmed and the directory check then cleared it. Win32 strips those spaces from a path and [ -d ] does not. The test stub was also wrong in a way that hid this: it consumed the quotes itself, but cmd's echo prints its argument verbatim, quotes included. With the stub corrected the new case fails against the previous commit and the rest still pass. * Fall through to a local Windows temp when TEMP is on a share A script written under a redirected %TEMP% is a remote script, and RemoteSigned refuses an unsigned one, so roaming-profile users would silently stop getting the shortcut they used to get. Rather than relax the policy back to Bypass, try %LOCALAPPDATA%\Temp and then %SystemRoot%\Temp, skipping any candidate that is UNC or still unexpanded. install.ps1 solves the same problem for the launcher by relaxing the policy because the launcher cannot move; this script can. * Reject mapped network drives as temporary locations too Z:\Temp is the same share and the same remote zone as its UNC spelling, and install.ps1 treats DriveType.Network as remote for that reason, but the filter only rejected paths beginning with two backslashes. A mapped drive was therefore accepted and then refused by RemoteSigned at launch. Read the mapped letters from net use and skip candidates on them; if that cannot be read the set is empty and only the UNC check applies, which is the previous behaviour rather than a new failure. * Keep shortcuts working when no Windows drive is mounted With [automount] enabled=false, interop still works but no Windows directory is reachable as a Linux path, so no candidate can be allocated and the file-based launch cannot run at all. Those users got a shortcut before the move off /tmp, so losing it silently is a regression rather than a pre-existing gap. The generated script is now captured once and either written to the Windows temp and run with -File, or handed to powershell on stdin. The stdin path needs no policy relaxation, because execution policy applies to -File and not to -Command, and nothing has to be mounted. It is fed from its own pipe so it cannot drink the installer's stdin under curl | sh. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Validate both halves of the compiler watch in the WSL control The control checked only the process half, so a FileSystemWatcher that never attached, or stopped reporting creations, looked exactly like a run that dropped no library. TempLibraries is the half the Bitdefender report in #10540 keyed on, and the install below trusts it. Require both, as windows-no-compiler-ci.yml already does. * Check the shell suite's failure counter after the last assertion The only $FAIL check sat before the static half of the file. `bad` records a failure and returns success, so an assertion that ran after it printed FAIL and left the script exiting 0, which made the launch-path regression checks ineffective in CI. Moved the summary and the check to the end of the file. * Do not render install.sh with a Windows bash The new here-string renderer drives install.sh through bash, and on a Windows runner bash is Git Bash or the WSL stub in System32, neither of which install.sh is ever run by: its WSL arm is gated on /proc/version naming microsoft, so the shell reading this here-string is always a POSIX one inside the distro. The rendered text is a pure function of install.sh's bytes, so the Linux and macOS legs cover the subject and the Windows leg only contributed three failures. Skipped there, the way the repository already skips its other bash-driven installer tests. Also report the shell's own diagnostic rather than letting CalledProcessError replace it with a 6 KB repr of the script. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 3 天前 | |
Studio: stop a Windows update deleting the venv it is running from (#11248) * setup.ps1: stop a stale-venv rebuild deleting the venv it runs from `unsloth studio update` runs setup.ps1 from the managed venv's own python.exe. When the torch flavor no longer matches the host (here: cu130 installed, an AMD GPU now in the machine, so rocm required), the direct-update branch did Remove-Item -Recurse on that venv. Windows will not delete a running image, so the delete emptied Lib\ and failed on Scripts\python.exe, leaving a venv with no unsloth_cli and no rollback copy; the desktop's update and repair both start from that interpreter, so neither could recover it. setup.ps1 now knows when it runs from inside the venv (the CLI names its interpreter as UNSLOTH_SETUP_HOST_PYTHON; a bounded Win32_Process walk covers an older CLI) and takes the in-place PyTorch reinstall install.ps1 already uses for the same situation. A rebuild that does run renames the venv aside first, so a lock fails whole instead of after deleting most of the tree. * setup.ps1: order the in-place route last, and make the venv probe separator-correct The new direct-update escape sat ahead of the nvidia-smi cu* preservation guard. Its condition is true for every stale direct update, because the desktop always runs setup from the venv's own python.exe, so it consumed $shouldRebuild before the narrower guard was tested and that guard could never fire. That guard does two things: it keeps the venv, and it publishes $script:PreservedInstallerTorchTag, which is what holds the index selection on the cu* arm. Shadowed, the tag stayed null while the in-place route set $script:PinChangedForceReinstall, and the pair reached the CPU arm. Replaying the shipped ladder over 320 enumerated cells, base and head agree on 289; the one cell that is neither an intended fix nor a match is an unpinned direct update with a cu128 venv and a silent nvidia-smi: base: uv pip install torch torchvision torchaudio --index-url .../whl/cu128 head: uv pip install torch torchvision torchaudio --force-reinstall --index-url .../whl/cpu which is the CPU-only update #9857 added that guard to stop. Moving the block below the guard leaves all 30 intended fixes and restores that cell; base and fixed now differ in 0 cells where the venv is kept on both sides. Get-SetupHostInterpreterInVenv built its containment prefix with a literal backslash. .NET returns '/'-separated paths from GetFullPath off Windows and treats '\' as an ordinary filename character, so the prefix matched nothing and the helper answered null for every input. Only Windows runs setup.ps1 for real, but the shipped pwsh tests run everywhere, and test_the_cli_hint_names_the_venv_interpreter was red on every non-Windows runner while its sibling passed vacuously. Build the prefix from DirectorySeparatorChar instead. Also move the stale-venv sweep out of the rebuild branch, so an install that renamed a venv aside, failed to delete the copy under a lock, and thereafter only ever repairs in place still collects it, and give the destination a pid suffix so two rebuilds inside one second cannot collide on a name. * setup.ps1: let the stale-venv sweep prove a directory is ours before deleting it Moving the sweep out of the rebuild branch put it ahead of the custom-root ownership guard, so that guard can no longer cover it, and "$_venvLeaf.stale-*" is a wildcard rather than a proof of ownership: a user's own unsloth_studio.stale-backup matches it and was deleted on an ordinary healthy update. Validate the way install.ps1 already validates its own rollback sweep (Test-StudioVenvRollbackMustBePreserved): refuse anything outside the generated timestamp-PID shape, refuse reparse points, and spare a copy whose owning process is still alive, which is a concurrent setup's rescue copy rather than our litter. Enumerate with -Force too, so a leftover that picked up the hidden attribute is not skipped forever by the sweep meant to collect it. test_setup_ps1_stale_sweep_only_removes_its_own_litter runs the shipped sweep under pwsh against real directories rather than reading it: an abandoned copy in the generated shape goes, a stale-backup directory and a live owner's copy stay, and the live venv is never touched. It fails on the unvalidated sweep with "a directory outside the generated name shape must be left alone". Routing is unchanged: replaying the ladder over the same 320 cells still gives 30 fixes, 0 violations and 0 newly wiped cells against base. * studio.py: say that UNSLOTH_SETUP_HOST_PYTHON is a hint, not a promise sys.executable is the managed venv's python.exe on the path that matters, where the desktop spawns the managed CLI. It is not on the others: a pip-installed CLI, a repo checkout and a staged run all put an interpreter here that lives nowhere near $VenvDir. That is already safe, because setup.ps1 tests containment itself and a value outside the venv reads as 'not inside', which keeps the pre-existing rebuild. The comment asserted the guarantee as fact, which is the kind of thing that later justifies dropping the containment test as redundant. Say which path it holds on and who checks. * setup.ps1: read the stale-venv pid from an explicit Match, not $Matches The sweep decided what to delete from a capture group it reached through $Matches after a -notmatch. Which of -match and -notmatch populates $Matches, and on which result, is version-sensitive in exactly the way #9857's empty-string-vs-null was, and a read that comes back empty here feeds [int]::TryParse and skips the entry. Failing safe is the good case; a wrong pid is not. Take the group off a [regex]::Match whose semantics do not move between Windows PowerShell 5.1 and 7.x. * Tighten the comments added by this change Opening comment-reduction pass over this PR's own additions only, not the surrounding file: 62 added comment lines to 43. Every surviving line names a mistake the next reader would otherwise make, which is why the ordering rationale and the ownership rationale keep a line each. AST-verified comment-only against b30af0c38. * Second comment sweep over this change's own additions The in-place-route block restated the undeletable-running-image mechanism the function header above it already gives, so it keeps only what is local to the decision: the issue number, and that a hand-run checkout still rebuilds. setup.ps1 additions 32 comment lines to 27. AST-verified comment-only against 7d0b1da8e. * Make the stale-copy sweep prove ownership before it deletes Three things found while simulating this PR, all in the new code rather than in what it replaced. The sweep runs on every qualifying setup, including one that rebuilds nothing, and it runs ahead of the custom-root guard. Its only authority for a recursive delete was a name, so a directory named unsloth_studio.stale-<14 digits>-<pid> next to the venv was deleted on a run that otherwise changed nothing, and also on a custom UNSLOTH_STUDIO_HOME whose venv the rebuild then refused to touch for want of an Unsloth sentinel. Both are now refused: the guard predicate is hoisted so the sweep asks the same ownership question the rebuild asks, and a candidate must carry pyvenv.cfg, the studio-owned marker, or the new .unsloth-studio-stale marker that the rename drops into the copy it moved. A copy too far gone to carry any of the three is named and left alone rather than deleted blind. The marker is rewritten when the delete after the rename fails, so the litter that branch announces is still litter the next run can identify. Get-SetupHostInterpreterInVenv accepted a hint that named nothing. The hint is an inherited environment variable and can outlive what it names, and a stale one pointing inside the venv would route a venv that genuinely needs rebuilding into an in-place repair with no interpreter to perform it. It must now name a file that is there. The parent walk also compares creation dates before following ParentProcessId, which is what Microsoft's own Win32_Process guidance says to do about reused process ids. The rename destination took the timestamp and the pid but had no retry, while install.sh's _start_studio_venv_replacement, which this mirrors, appends a numeric suffix until the name is free. One process can reach this twice inside a second, and a copy it left earlier can still be on disk because its delete failed. A taken name now takes a bounded suffix, and the sweep's shape recognises it, or the copy it names could never be cleaned up. Tests: the sweep test now drives the shipped sweep over six directories, including one that wears the name and holds no environment and one whose name took a collision suffix; a new test refuses an unclaimable custom root and then, as a negative control, sweeps the same tree once the owned marker is present; the helper test adds a hint naming a deleted path, a directory, and Scripts itself, with the real interpreter as its control. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 11 小时前 | |
Installers: require x64 Python on Windows on ARM, and respect active conda (#11024) * Installers: require x64 Python on Windows on ARM, and respect active conda Windows on ARM: pyarrow and hf-transfer publish no win_arm64 wheel, at any version, so a native ARM64 interpreter source-builds both and fails on CMake minutes into the install. install.ps1 already swapped a freshly selected ARM64 interpreter for x64, but it warned and continued when no x64 interpreter could be installed, and it never re-checked the interpreter of an environment it reused, so a venv migrated from an older install stayed on ARM64 CPython. Both now stop or rebuild, with UNSLOTH_ALLOW_ARM64_PYTHON as the opt-out. Conda: every persistent PATH write prepended, which writes an ordering that outlives the conda activation it was made under, so from the next shell on our directory sits ahead of the active environment's own entries. install.ps1, studio/setup.ps1, install.sh and studio/setup.sh now register the same directory at the back while a conda environment is active, and a system Python installed from python.org gets AppendPath instead of PrependPath. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Honour the ARM64 opt-out before installing x64, and agree with the shell installers on conda Three corrections. UNSLOTH_ALLOW_ARM64_PYTHON was read only after Install-X64Python had already failed, so on any Windows on ARM machine that can reach python.org, which is most of them, setting it did nothing: an x64 interpreter the user had asked not to have was downloaded, installed and then used. It is read first now, and the warning says how to undo it. Inside an active conda environment the python.org route runs before winget, and the winget arm's own python.org fallback then ran it a second time when both had failed. On an offline machine that is the same failing download twice, announced as a fallback that had already been tried. install.sh and studio/setup.sh decided "are we inside conda" from CONDA_PREFIX alone while install.ps1 and studio/setup.ps1 read CONDA_PREFIX and CONDA_DEFAULT_ENV, so a shell hook that exports only the second gave the same user a prepend on Linux and an append on Windows. Both shell installers share one _unsloth_conda_env_active predicate now, and a test asserts the two halves name the same variables in either direction. Cases added: the opt-out with x64 installable, the single python.org attempt, CONDA_DEFAULT_ENV on its own for all three POSIX writers, and a zsh rc file. * Append to PATH in fish for real, and honour the ARM64 opt-out on a reused environment Two defects in this branch's own new code. fish: -a alone is not an append to PATH. Without --path, fish_add_path edits $fish_user_paths, and fish's own documentation says that variable 'is itself prepended to PATH, so they still stay ahead of the system paths'. Its worked example for exactly this case is 'fish_add_path --append --path /opt/fallback/bin'. So the conda arm wrote a line that left the directory in front of the active conda environment, which is the defect #5871 reports, arriving through fish instead of an rc file. Both installers now write 'fish_add_path -a -P'. The drop-in lives in conf.d, which is read alphabetically after conda.fish and sourced by every shell, and fish_add_path leaves an already-included directory in place, so re-sourcing and re-running the installer are both no-ops. The bare -a spelling still counts as present, so an install from before this fix does not collect a second line. UNSLOTH_ALLOW_ARM64_PYTHON: Resolve-WindowsOnArmX64Python never runs when the ordinary probe already found an x64 Python on the machine, so on an ARM64 box that has both interpreters the variable was never read, and the migrated-environment re-check then moved aside the very native ARM64 environment the user had asked to keep. One Test-Arm64PythonOptOut helper is now read at both sites. The rebuild also names where the old environment went and that its extra packages are not carried over, which a rebuild on a different architecture cannot do. Sources: https://fishshell.com/docs/current/cmds/fish_add_path.html Nine pwsh cases and three shell cases added; they fail on the previous head of this branch. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reposition an existing PATH entry when conda downgrades the prepend A machine that ran the installer once outside conda has our directory at the front of the User PATH. Rerunning inside an activated environment downgraded the prepend to an append, but the append-idempotent early return then returned without rewriting the registry, so the front entry survived and the run reported that conda keeps priority while it did not. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Take the python.org route for the x64 bootstrap under conda, and keep the ARM64 environment * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep an active conda ahead of the User PATH in the refreshed session * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Preserve the session PATH for a name-only conda activation * Remove conflict markers left in the installer PATH test * Keep an active conda environment in front during setup PATH refreshes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reposition a persisted prepend, and read the venv architecture before the move * Do not move the venv twice, keep what the opt-out promises, repoint before the guards * Anchor the ARM64 swap test on the call, not the shape of the if The guard is multi-line now that the native CUDA torch check joined it, and an anchor on "$DetectedPython -and" matched an unrelated earlier block instead of failing. * Let the ARM64 opt-out choose the interpreter, not only keep it UNSLOTH_ALLOW_ARM64_PYTHON was read at the swap that follows the selection, but on a host that already has an x64 interpreter the selection is x64 and that guard never fires, so the variable did nothing: a fresh install built an x64 venv and a reinstall replaced the native ARM64 one. Rank ARM64 first when the opt-out is set on an ARM64 host, leaving Install-X64Python's own -X64Only lookup alone. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Commit the rc rewrite by rename, and enumerate every stacked conda prefix cat staged > rc truncated the user's profile before writing a byte back, so an interrupt or an I/O error left it half written and the failure branch then deleted the only complete copy. Stage beside the real file, carrying its permission bits, and rename onto it; the symlink chain is resolved by hand first, since renaming over a link managed by chezmoi or stow would replace it with a regular file. conda activate --stack records the outer environments as CONDA_PREFIX_1 upward and CONDA_SHLVL counts them, so a fixed list ending at _3 dropped everything past the fourth and left those prefixes sorting after Machine and User. Also gives the ARM64 resolver harness the two real functions it calls, without which three of its four cases were failing on an unknown command rather than on an answer. * Hand the rewrite values to awk untouched, and repoint the $HOME spelling too POSIX awk decodes backslash escapes in a -v assignment, so a line holding a backslash -- which the writers escape before building it -- matched the literal grep and then matched nothing in awk: the helper renamed an unchanged file and reported the stale prepend as moved with conda still in front of it. ENVIRON hands the value over as it is. And the uv repoint pass was built only from the expanded path, so on every ordinary machine, where the destination IS ~/.local/bin, it could not match the export PATH= "$HOME/.local/bin:$PATH" line the shim block writes unexpanded. That pass already visits every startup file astral wired, so both spellings go through it; the shim's own repoint reaches only whichever profile is selected now. * Repoint the $HOME spelling in the standalone setup path too studio/setup.sh runs on its own during an update, after install.sh wrote the shim line unexpanded. _SETUP_LOGIN_PATH holds the expanded directory, which is what selects the repoint-only branch, while the line in the profile says $HOME, so the rewrite never fired and the stale prepend stayed ahead of the active conda environment. Both spellings go through the repoint, in that branch and in the already-present one. * Run the repoint helper in the shell harness and fix the spelling it uncovered * Move the fish entry rather than append one fish will ignore * Let the ARM64 opt-out find a native interpreter, and only a native one * Move only a line the installer put there * Bring the repointer fixtures to the ownership marker * Keep the rc file's permissions through the repoint The rewrite renames a staged copy onto the user's profile, so the copy's mode becomes the profile's mode for good, and a plain cp does not carry the source bits across: POSIX creates the destination with the source's mode as the mode argument, and a mode argument is always modified by the file creation mask. A 0644 .bashrc rewritten under umask 077 came back 0600. Both copies of the helper stage with cp -p now, which is specified to duplicate the permission bits, and fall back to the plain copy only where -p is refused outright. * Keep the ARM64 opt-out's environment on an ARM64 interpreter The selection prefers ARM64 under UNSLOTH_ALLOW_ARM64_PYTHON, but only among the interpreters discovery can see. With the ARM64 CPython that built the venv gone, there was nothing left to prefer: an x64 interpreter was selected, the reinstall had already moved the native environment to its rollback path, and the new environment really was built on x64 while the opt-out's own message said it would not be. pyvenv.cfg still names that base interpreter, so it is tried before the swap, and where it cannot be found the message now says plainly that the new environment is built on x64 and the ARM64 one is kept. * Keep the x64 bootstrap fixture parseable on the lint target A backslash inside an f-string expression is a 3.12 syntax addition and the repo's ruff target is 3.11, so the whole-repo check failed on the Windows paths in the stub preamble. Bind them before the template. * Cut the commentary down Same code, fewer words. The product diff went from 446 comment lines to 290, and the whole PR from 3381 added lines to 3099. Every reason survives; the paragraphs explaining them do not. --------- Co-authored-by: danielhanchen <unslothai@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> | 1 天前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 1 小时前 | ||
| 1 小时前 | ||
| 13 天前 | ||
| 3 小时前 | ||
| 6 个月前 | ||
| 18 小时前 | ||
| 18 小时前 | ||
| 18 小时前 | ||
| 11 天前 | ||
| 17 天前 | ||
| 6 个月前 | ||
| 11 小时前 | ||
| 2 天前 | ||
| 1 天前 | ||
| 18 小时前 | ||
| 8 天前 | ||
| 4 天前 | ||
| 2 个月前 | ||
| 3 天前 | ||
| 3 个月前 | ||
| 3 个月前 | ||
| 2 天前 | ||
| 3 天前 | ||
| 11 小时前 | ||
| 1 天前 |