unsloth/studio
Wayne Sletcher 2dc042ccfc
studio: report a denied rename as a possible ACL fault, not a scanner (#10075)
* studio: report a denied Node rename as a possible ACL fault, not a scanner

_replace_with_retry retries `os.replace` on WinError 5, 32 and 145 and logs one
message for all three: "a scanner is likely still holding the extracted files".
WinError 5 is not only a scanner. It is equally what a tree with unreadable ACLs
looks like, and that is a permission fault no backoff can clear. It matters most
because `_swap_into_place` also uses this helper to move the EXISTING install
aside, the tree a user is likeliest to have broken permissions on.

In #9928 the same rename in the llama.cpp installer failed with (5) on every
attempt and reported a scanner. The reporter's cause was corrupt ACLs -- icacls
and Get-Acl both returned access-denied for the directory -- and
`takeown /F ... /R /D Y` followed by `icacls ... /reset /T /C` fixed it. The Node
helper carries the same three codes and the same message.

5 stays in the retry set: a scanner does raise it right after extraction. Only
the reporting changes: the per-retry line offers both causes, and the
takeown/icacls recovery prints once, when the budget is exhausted rather than on
every attempt. WinError 32 and 145 keep their wording. The two commands go on
separate lines for the reason tests/studio/install/test_setup_denied_install_tree.py
pins -- on one line takeown swallows icacls as an argument. The constant and the
recovery lines live in this module because it imports nothing outside the stdlib
on purpose.

Three tests extend the suite that already covers the helper. Two fail without
the change: test_replace_names_acl_recovery_when_access_denied_persists and
test_replace_does_not_blame_a_scanner_for_access_denied.
test_replace_keeps_the_scanner_message_for_a_sharing_violation is a parity guard:
32 keeps its wording and prints no ACL guidance.

One consequence worth naming: a 5 that really was a scanner now prints ACL
guidance that does not apply to it, at the point the rename has already failed.

Refs #9928.

* studio: make ACL rename recovery actionable

* studio: address ACL recovery review feedback

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

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

* studio: stop reporting every blocked llama.cpp install as an active process

setup.ps1 exits 3 when the existing llama.cpp install could not be replaced, and
both sites that report it asserted an active llama.cpp process as the cause.
Windows raises WinError 5 for a file another process holds open and for a tree
whose ACLs are unreadable alike; is_busy_lock_error in studio/prebuilt_core.py
classifies 5 as busy for exactly that reason, and _access_denied_recovery_lines
prints takeown/icacls guidance for the ACL case. Write-LlamaFailureLog relays
that guidance into the same block, so an ACL-denied user read repair commands
bracketed by a headline and a retry hint that both named a process instead.

Two sites, not one. The prebuilt exit-3 branch is the reported one; the
local-directory link path carries the same three lines and says in its own
comment that it mirrors the prebuilt handling, and that "Denied counts as
surviving: unreadable is not gone" -- so it reaches the message by the same
route and had to move with it.

The classification is unchanged: exit 3 still means blocked, is_busy_lock_error
still treats WinError 5 as busy, and the user is still told to close other
llama.cpp users. Only the claim about which cause it was is dropped, in favour
of naming both recoveries. whisper.cpp's exit-3 branch already reads "install
busy; keeping existing runtime" and is the cause-neutral precedent followed here.

Studio.Setup.BlockedInstallCause.Tests.ps1 pins it: 15 tests, 5 of which fail
without this change, including a file-wide guard that neither phrase reappears
at a third site and that every blocked-install site offers ACL repair.

One consequence worth naming: the local-link path names the directory but cannot
print the takeown/icacls lines, because it has no installer output to relay. It
points at the ACLs on that path and leaves the commands to Write-PathAccessDenied,
which already prints them wherever setup detects a denial up front.

* studio: gate ACL retry guidance on helper output

* studio: keep a running Node when its replacement stays denied

The re-raise of a reported access denial sat ahead of install_prebuilt's
keep-existing check, so a WinError 5 rename that outlasted the retries failed
setup even when the Node already on disk still ran: exit 1, which setup.ps1 and
setup.sh turn into "Could not install an isolated Node runtime". The re-raise
was there for a real reason -- both scripts show the installer's output only on
a non-zero exit, so a plain keep-existing return would have swallowed the
takeown/icacls lines -- but the two goals conflicted only because exit 0 was
silent.

It no longer is. install_llama_prebuilt.py already exits 0 when it keeps the
tree on disk after a transient failure, and setup.ps1 and setup.sh match its
"keeping the existing complete install" line to warn "update unavailable,
existing prebuilt kept". Node now takes the same shape: the reported denial
reaches the keep-existing check like any other failure, a Node that still runs
is kept with exit 0 on a line ending "keeping existing isolated Node", as the
download-failure case already logs, and both scripts match that line, warn
"update not applied, existing isolated Node kept", and relay the installer
output when it carries takeown lines. The line names the rename instead of
"Node download failed" when that is what failed. The warning is not llama.cpp's
text, which test_pr10648_offline_and_status.py holds to exactly two keep arms.

No new exit code, and nothing else moves: a Node that does not run, a forced
reinstall, and a same-version install whose digest is not the pin still raise,
exactly as they do for a failed download. setup.sh gets the arm for parity; its
takeown branch can only fire on Windows output.

test_install_prebuilt_keeps_existing_when_a_reported_denial_blocks_the_swap
replaces test_install_prebuilt_does_not_hide_reported_acl_denial and fails
without this change, when the swap's OSError escapes. The three cases of
test_install_prebuilt_reraises_a_reported_denial_it_cannot_keep_existing_through
are parity guards. Studio.Setup.BlockedInstallCause.Tests.ps1 gains a block on
the setup.ps1 arm, four of its five tests failing without it, and
tests/sh/test_setup_node_kept_existing.sh runs the real setup.sh block against a
stand-in installer, three of its thirteen checks failing without it.

One consequence worth naming: setup now carries on with a Node it could not
replace, so a user can stay on an older runtime while setup succeeds, and the
warning prints once per run.

Refs #9928.

* studio: offer the Node ACL repair only where the caller names the paths

_replace_with_retry printed takeown/icacls for its source whenever a WinError 5
outlasted the retries, and flagged the exception as a reported denial, whoever
called it. Since #10648 that includes atomic_replace_from_tempfile, which renames
a temp file onto the downloaded archive and onto the install marker. A denial
there named a temp file that is removed moments later, and the flag made the
keep-existing line say the existing Node "could not be replaced" when the swap
had never run.

The repair lines and the flag now need access_denied_paths, which only
_swap_into_place passes, for its three renames. A temp-file rename still retries
the same budget and still raises; it only stops naming a path that will not
exist, and a Node kept after it is logged as "Node download failed", as before.

test_a_denied_marker_write_offers_no_repair_for_its_temp_file drives
atomic_replace_from_tempfile into an exhausted 5 and fails without this change.
test_replace_names_acl_recovery_when_access_denied_persists now passes its paths,
as the callers that get the lines do.

Refs #9928.

* studio: describe the llama.cpp exit-3 repair gate by what it checks

The comment above setup.ps1's llama.cpp exit-3 branch said "The helper prints
takeown guidance only when access was denied", and the header of
Studio.Setup.BlockedInstallCause.Tests.ps1 named _access_denied_recovery_lines
as the source of llama.cpp's repair commands. Both described the installer
half this PR carried before the llama.cpp message was left to #10182.

On main no installer prints those lines: install_llama_prebuilt.py and
prebuilt_core.py contain no takeown at all, and _access_denied_recovery_lines
exists only in install_node_prebuilt.py. So the branch's `-match 'takeown /F'`
arm is correct but cannot fire until #10182 lands, and both comments said it
could.

The gate stays, because it is what #10182's output needs, and without it the
ACL repair hint would be offered to every busy install. Only the text changes:
both comments now describe the check rather than a producer.

Comments only, no assertion changes.

Refs #9928

* tests: anchor the Node exit-chain Pester block on the chain itself

Get-BlockSource took the Node exit handling from the first
`$nodeExit = $LASTEXITCODE` to the next closing brace at that indent. #11786
put the mirror retry between the two, `if ($nodeExit -notin 0, 3, 4 -and
(Use-MirrorSpare node)) { ... }`, so the slice ended at the retry's own brace.
Merged with main, four of the five tests on a kept Node failed against a
setup.ps1 that still has the branch they look for.

The marker is now `$nodeExit -eq 3`, the chain's first line, once in
setup.ps1. Against main's setup.ps1 the file still fails 11 of 22 (4, 3, 1, 3
and 0 by block), as it did at a09f3765b; with this branch's it passes 22.

* studio: print the Node ACL repair only when the existing Node is kept

Since #10533, a denied swap that cannot keep the existing Node reaches main()
as a PermissionError and exits 4, and setup.ps1 answers exit 4 with
Exit-PathAccessDenied: its own elevated takeown/icacls for the refused path
and a note that antivirus can deny it too. _replace_with_retry had already
printed ours, so that path showed two sets of repair commands for the same
folder, with different icacls flags, around #10533's advice that elevation
may not clear a security-software block.

The lines now ride on the error and install_prebuilt logs them only in the
branch that keeps a Node that still runs. That is exit 0, where setup.ps1
and setup.sh relay them on `takeown /F` and nothing else names a repair.
"rename still blocked (5) after N attempts" still prints at the retry, so the
exit-4 output keeps the rename's own record.

Measured through main() with the real swap and a refused rename: exit 4
printed our four repair commands ahead of #10533's message, and now prints
none, with the denied-path and denied-scope markers unchanged. A kept Node
prints the same lines as before, ahead of "existing Node could not be
replaced".

test_a_denial_that_cannot_keep_node_leaves_the_repair_to_setup fails
without this change; test_a_kept_node_prints_the_repair_setup_relays is a
parity guard for the kept path. Four tests that read the lines from the log
or checked the old flag, and the swap stand-in that set it, now use the error.

One consequence worth naming: when the rollback of a failed forward rename
is denied too, the repair lines naming the backup no longer print. The line
"could not restore the previous Node install from <backup>" still names it,
and the run exits 4.

* Trim comments in the denied-rename reporting

---------

Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
2026-10-05 03:56:17 -07:00
..
backend Studio: seam-free VAE tiles for Qwen-Image-2.1 on low-VRAM tiers (fixes thin horizontal / vertical lines) (#12696) 2026-10-05 03:45:02 -07:00
frontend Studio: hide iq4_nl KV cache where llama.cpp runs its attention on the CPU (#7008) 2026-10-05 03:38:06 -07:00
scripts
src-tauri Studio: in-app browser panel for chat (#12347) 2026-10-05 00:54:03 -07:00
systemd Studio: opt-in systemd user service for Linux auto-start (#9308) 2026-10-02 02:19:53 -07:00
.npmrc Harden npm and yarn installs against supply-chain attacks (#12660) 2026-10-04 02:21:52 -07:00
__init__.py
audio_cpp_prebuilt_pins.json Studio: add audio.cpp as a native engine for speech, music and dictation (#12342) 2026-10-02 09:32:09 -07:00
INFERENCE_ENGINES.md Studio: add opt-in vLLM and SGLang support with multi-GPU inference, quantization and vision (#11491) 2026-10-02 06:40:21 -07:00
install_audio_cpp_prebuilt.py Studio: add audio.cpp as a native engine for speech, music and dictation (#12342) 2026-10-02 09:32:09 -07:00
install_lemonade_prebuilt.py
install_llama_prebuilt.py
install_manifest.py
install_mxc_prebuilt.py
install_node_prebuilt.py studio: report a denied rename as a possible ACL fault, not a scanner (#10075) 2026-10-05 03:56:17 -07:00
install_python_stack.py Studio (AMD, Windows): pin the multi-arch ROCm torch to rocm7.14.0, whose fused attention works (#12670) 2026-10-04 04:48:45 -07:00
install_q4nx_converter.py Unsloth Studio: export or convert a GGUF to FastFlowLM Q4NX for the AMD Ryzen AI NPU (#12541) 2026-10-03 14:39:20 -07:00
install_sd_cpp_prebuilt.py Studio: faster MiniMax-H3 GGUF renders (resident under memory auto, sd.cpp pin upgrade, speed_mode=max kernels) (#12405) 2026-10-01 04:18:09 -07:00
install_whisper_prebuilt.py
lemonade_prebuilt_pins.json
LICENSE.AGPL-3.0
MCP.md
node_prebuilt_pins.json
nvidia_probe.py
NVLINK_P2P.md
package-lock.json
package.json
PDF_OCR.md
prebuilt_core.py
q4nx_converter_pins.json Unsloth Studio: export or convert a GGUF to FastFlowLM Q4NX for the AMD Ryzen AI NPU (#12541) 2026-10-03 14:39:20 -07:00
ROCM_RDNA2_APU.md
setup.bat
setup.ps1 studio: report a denied rename as a possible ACL fault, not a scanner (#10075) 2026-10-05 03:56:17 -07:00
setup.sh studio: report a denied rename as a possible ACL fault, not a scanner (#10075) 2026-10-05 03:56:17 -07:00
SKILL_MENTIONS.md Studio: deterministically load explicitly mentioned skills before generation (#12538) 2026-10-04 06:02:40 -07:00
Unsloth_Studio_Colab.ipynb