Commit graph

119 commits

Author SHA1 Message Date
Daniel Han
f40e20274c
Say which runtime components an AppImage is missing before its env hygiene (#9370)
`Desktop app clean machine` is red nightly on main, seven AppImage jobs, all with
the app's own preflight naming four missing sonames. The tree is not at fault and
neither is the test, so this changes only what the verifier says.

What is actually happening
------------------------------------------------------------------------
The scheduled run downloads the published release rather than building one.
`REL_TAG` is `v0.1.800-beta`, whose Linux asset was uploaded 2026-08-14T15:26Z and
never rebuilt. I extracted it: `usr/bin/unsloth-studio` and zero `.so` files, so
it resolves webkit2gtk, libsoup, javascriptcoregtk and appindicator from the host.
It is a pre-#9113 thin build, provably: the diagnostic it prints lives at line 210
of `build-thin-appimage.sh`, a script #9113 deleted, and the replacement
`appimage-apprun.sh` does not contain that string.

#9113 ("Desktop: ship a complete Linux AppImage") landed 2026-08-19T13:09Z and
also dropped the line that used to install the host runtime into the appimage row,
which is correct under a self-contained bundle and is why that row flipped on an
unchanged asset. The Aug 18 run was green, Aug 19's red was the ManagedStale
incident that #9263 fixed, and Aug 20 is the first scheduled run after #9113.

So the asset under test predates the packaging it is being tested against. The
first desktop release cut from current main will satisfy these jobs; nothing in
the tree needs changing to make that true, and until that release exists this
workflow is red for a correct reason.

Note this is invisible on a pull request by construction: on `pull_request` the
AppImage jobs consume the artifact from `appimage-pr-build`, built from the PR, so
#9113 was green when it merged. Only `schedule` and `push` reach the release.

The one real defect
------------------------------------------------------------------------
`verify-complete-appimage.sh` run against that asset reports:

    Complete AppImage does not clear an inherited LD_LIBRARY_PATH

It exits 1 correctly, but it fails at the launcher-hygiene check and never reaches
the required-component loop, so a bundle that ships NO runtime at all is reported
as an environment-variable bug. That is the difference between an hour of
diagnosis and a minute of it.

The required-component check now runs first and reports every miss instead of
exiting on the first, mirroring the AppRun preflight, which names all four sonames
at once rather than making the reader rediscover them one launch at a time. On the
published asset it now says:

    Complete AppImage is missing required runtime component: libwebkit2gtk-4.1.so*
    ... 21 lines ...
    Complete AppImage is missing 21 of 21 required runtime components; it resolves
    them from the host

Order only. No check is removed and no assertion is weakened.

Verified
------------------------------------------------------------------------
`bash -n` clean. A synthetic AppDir with all 21 components present falls through
to the launcher-hygiene check exactly as before, so the hygiene checks still run
and still fail when they should. Removing only webkit from that AppDir yields
`missing 1 of 21`, so the count is real rather than a formatting flourish.
tests/security/test_release_desktop_appimage.py: 15 passed.
2026-08-20 02:58:29 -07:00
karan yadav
dc26127a42
Studio: propagate required backend version to repair pipeline (#8610) (#8670)
* Studio: propagate required backend version to repair pipeline (#8610)

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

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

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

Fixes #8610

* Narrow desktop repair to version propagation

* Handle version suffixes in repair fallback

---------

Co-authored-by: imagineer99 <samleejackson0@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-08-19 19:12:22 +01:00
Michael Han
0488fe094a
Studio: stop the desktop health watchdog killing a backend that is busy generating (#8990)
* Studio: stop the desktop health watchdog killing a backend that is busy generating

The watchdog probes /api/liveness every 15s with a 10s budget and declares the
backend dead after 3 consecutive misses. Startup was the only load it made an
allowance for. A host serving a model far larger than it can hold runs at
fractions of a token per second, and the loop feeding those streams goes quiet
the same way the warm thread's `import torch` makes it go quiet, so ~45s of
silence ended a response that was still being produced and put "Server stopped
unexpectedly" in front of it.

/api/liveness (and /api/health, which the launcher falls back to) now publishes
`inference_active` from the active-generation registry, and the watchdog widens
its failure budget to 12 while the last answered probe said the backend was
generating. Only for probes that time out: a refused connection means the port
is gone, and that is still reported at three strikes.

Both watchdog paths classify a failed probe. The adopted path folded every
transport error into "not verified", so it reads liveness first and classifies
there; the ownership probe's own first step is that same GET, so nothing is
skipped by returning early, and both markers stay gated on verification.

The kill is also logged with the budget it was measured against, so tauri.log
says whether a backend was killed while stalled or was already dead.

* Classify an adopted stall from the watchdog's own probe, and ask once more before killing a stalled backend

* Keep a reused port on the normal watchdog budget

An ownership mismatch answers the probe in full: the liveness fetch
succeeds and owner_matches_metadata rejects the different root or token,
which is a complete answer, not silence. Labelling it a timeout handed a
dead adopted backend the 12-strike busy budget, so it lingered about 180
seconds rather than 45 while its old port was already answering for
someone else. The probe now says the one thing it can say for certain,
and only from a parsed body, so a transport error or a non-2xx still
reads as silence and Unmanageable keeps the busy budget.

The preflight hunk is test-only: ManagedCapabilityCacheHome set an
environment variable without taking PROCESS_ENV_LOCK, racing the reader
in with_xdg_data_home, which resolved the real home and collided the
three webview-profile tests on one lock file. Measured at 1 failure in
30 runs on the unmodified tree, so it is pre-existing rather than new
here, but the new tests shift the schedule enough to surface it.

* Re-read the backend before the watchdog spends its verdict

Both probes are awaits, and the last-chance one is 30s wide on top of
the 10s cycle probe, so up to 40 seconds pass between reading the state
at the top of the loop and acting on it. stop_backend takes whatever
handle is stored now and carries no generation of its own, so a stop
and start landing in that window had the stale watchdog kill the
replacement and emit server-crashed over a backend that had just come
up. The busy budget makes that window ordinary rather than unlucky: 12
strikes is about 300 seconds of hanging, which is long enough that a
manual restart is the expected response. The generation is the identity
the probe never carried, so re-read it and exit through the tail, which
is generation-guarded already.

* Carry the adopted pre-probe answer through the last-chance confirmation

The confirmation asked for alive && inference_active, but on the adopted path
alive means the ownership re-check verified us, and that check's two extra
loopback requests are what a saturated backend drops. Such a backend answered
the pre-probe with the busy marker set and was cleared anyway, mid-response.
The busy marker now survives an unverified re-check, cleared only by a real
takeover, and the confirmation reads it alongside the stall.

* [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: danielhanchen <danielhanchen@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-19 06:11:13 -07:00
Michael Han
588405dce2
Desktop: make every drop zone take a drop again (#9036) (#9056)
* Desktop: make every drop zone take a drop again (#9036)

Tauri delivers OS file drops window-wide and suppresses the webview's own
drop events, so a zone wired only to `onDrop` does nothing in the desktop
app: no drag-over border, and the file is silently ignored.

Native drop routing (#8265) was only ever adopted by the shared image
picker and the create-project dialog. Every other file drop zone still
relied on HTML5 handlers that never fire, which is why this reads as
intermittent: the same file works when it lands on the chat, covered by
the window-wide handler, and does nothing anywhere else.

Adds `useNativeFileDrop`, which claims the native drop for an element and
returns drag-over state plus the HTML5 handlers the web build still needs,
then adopts it in the zones that were dead:

- Projects -> Sources, which also had no drag-over styling at all
- Data Recipes unstructured seed
- Diffusion training images
- Video and audio reference pickers

Documents upload by lease rather than an inline read, since the native
reader only serves media inline, so the recipe seed route now accepts
`nativePathLease` the way the RAG upload routes already do. The native
path policy accepts video containers so the reference picker can register
what it is given.

Also stops two silent discards with the same symptom: a claimed zone that
refused a drop while disabled, and compare mode disabling the window-wide
handler outright. Both now say what happened.

`native-dropzone-coverage.test.ts` walks src/ and fails if a zone reads
files from a drag payload without either claiming the native drop or
explicitly deferring to the window handler.

* Desktop: refuse a dropped model in compare too, and validate before reading

Two things the first pass got wrong.

Keeping the window-wide listener on outside single chat also handed it
model drops, so a GGUF dropped on a compare or project view would load
and replace the active model. Nothing happened there before, so that is
not a change this should be making. The refusal now covers every kind the
handler would act on, models included.

The recipe seed route also moved its extension check after the read, so a
rejected 500 MB upload was pulled into memory first. Back to validating
the filename before reading a byte, as it was.

* Desktop: size the native video cap to the largest client-side limit

64 MB sat under the reference picker's own 72 MB, so a clip the picker
accepts was refused on drop. The cap is a backstop; callers keep theirs.

* Desktop: drop the diffusion zone from this pass, and bound the native read

Two review findings, both correct.

The diffusion dataset zone accepts .bmp, .m4v, .caption and .jsonl, which
the chat attachment policy rejects outright, and .txt, which registers but
cannot be read inline. Captions beside images are the documented workflow
there, so wiring that zone to the attachment path would have uploaded the
images and silently lost the captions. It needs its own registration and
upload policy, which is more than this belongs to, so it goes back to the
picker it had.

The recipe seed route also read a dropped path in full before checking any
limit, so a multi-gigabyte local file went into backend memory before the
413. It now refuses on the stat and bounds the read by what the block has
left, in case the file grows in between.

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

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

* Desktop: keep a busy drop zone hit-testable, and size the video cap to the raw limit

A disabled seed zone carried pointer-events-none, which takes it out of
elementFromPoint, so nativeDropTargetAt could not find the target it had
just registered. The disabled message was unreachable and the drop fell
through to the window handler instead.

MAX_NATIVE_VIDEO_BYTES was set to the reference picker's 96 MiB, but that
cap bounds the data URL, not the file: the picker's own raw limit is
75497280 bytes. Rust was reading and base64-encoding up to 96 MiB, 128 MiB
across the bridge, for clips the picker then rejected.

* Match the document-refusal test to the message it now returns

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

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

* Studio: attach video to a chat, and say why when it is unavailable (#9057)

* Studio: attach video to a chat, and say why when it is unavailable

llama.cpp takes video through its OpenAI-compatible chat endpoint as an
`input_video` content part, but only when the mmproj declares video, the
binary was built with video support and ffmpeg is installed. It reports
that verdict at /props under modalities.video. Nothing about the GGUF
alone can tell us, so that is what Studio now reads.

Frontend: video joins the drop classifier and its own pending queue
beside images and audio, and a VideoAttachmentAdapter takes one clip per
message from the picker or a drop. When the model cannot take video the
adapter names all three possible causes instead of letting llama-server
refuse the request later.

Backend: video_base64 on the chat request is forwarded whole as an
input_video part, since llama-server owns the frame sampling and there is
nothing useful to transcode. has_video_input rides the same path as
has_audio_input out to the model row.

Compare mode is left out on purpose: video_base64 targets the single
loaded GGUF, so at most one side could answer. Dropping a clip there now
says so rather than ignoring the file.

Size caps line up across the three hops (64 MB in the desktop reader, in
the composer and in the route) so no hop accepts what the next refuses.

* Studio: carry the video capability through, and cover the passthrough path

Three review findings, all correct.

syncModelCapabilities took has_video_input but never copied it into the
row, and /api/models/list omits it for the active GGUF, so the adapter read
false after every load and refused video even when /props reported it. The
feature did not work in its main path.

The tool and response_format passthrough returns before the injection and
forwards an explicit field list, so a clip rode along nowhere and the model
answered without it. Refused now, the way audio already is there.

The size cap floored the base64 inflation, so a clip of exactly the size
the composer allows was refused with a 413, and the data URI header was
counted against the payload. Padded ceiling, measured after stripping.

* Studio: carry the video capability through every hop, and refuse it where it cannot be served

Three separate places map backend capability flags onto a model row and
each one dropped the video flag: the direct status adoption, the queued-run
capability Pick, and (fixed earlier) syncModelCapabilities. The adapter
reads that row, so any of them leaves video refused on a model that
supports it. Covered by a rule rather than three spot checks.

Injection lives in the GGUF branch, so an external provider or a local
transformers model answered as if no clip were attached. Both now refuse,
as does token counting, which cannot inject the frames it would need to
count. The size check also moved ahead of the automatic model switch so an
oversized clip does not evict a working model before the 413, and video now
votes in the pre-switch projector requirement alongside audio.

The video drain's read-failure toast said 'audio', inherited from the
audio drain it was cloned from.

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

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

* Studio: name the attached modality in the pre-switch refusal

Adding video to require_vision made the shared rejection reachable for a
request carrying only a clip, but its text is fixed at 'image or audio
input', so the user who attached a video was told about modalities the
request never carried. The label now follows what is attached, and defaults
to the existing wording so the image-only callers are unchanged.

* Match the document-refusal test to the message it now returns

* Send the API key on the /props readback and skip video in the context recount

The /props probe went out without an Authorization header, so under
UNSLOTH_DIRECT_STREAM=1 llama-server answered 401 and video capability
never came back. Context recount already bails on images and audio
because toOpenAIMessages has no branch for them; video has the same
property and was missing the bail, so the usage bar priced a text-only
prompt and stringified megabytes of base64 on the UI thread.

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

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

* Send a picked clip under the container its extension names

The accept list carries extensions as well as mime types because the
browser's answer is unreliable for mkv and some mov files, and the
picker takes those files on the extension. Only an empty type was being
replaced, so a clip the browser called application/octet-stream kept
that type into the attachment, and the request builder recognises a file
part only when its mimeType matches ^video/. The clip was attached, sent
and dropped, and the model answered as though nothing were there, which
is the silent drop this PR exists to remove. The table mirrors the one
in native_intents.rs, so a clip read by the desktop reader and one
picked in the browser reach the route the same way.

---------

Co-authored-by: shimmyshimmer <182633334+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>

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

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

---------

Co-authored-by: shimmyshimmer <182633334+shimmyshimmer@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-19 06:10:51 -07:00
oobabooga
de102032ee
Desktop: ship a complete Linux AppImage (#9113)
---------

Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
2026-08-19 10:09:29 -03:00
Darshan Poudel
5991194df8
fix(studio): enable microphone/camera access in Linux WebKitGTK webview (#8720)
WebKitGTK ships two defaults that together block voice dictation on
Ubuntu/Linux builds: enable-media-stream is off, and the stock
permission-request handler denies every request that has no listener
override, including UserMediaPermissionRequest. Neither can be fixed
from outside the embedding application, so opt in from the Tauri
setup hook: enable enable-media-stream on the webview settings and
auto-allow only user-media permission requests, leaving every other
permission kind on WebKitGTK's default deny.
2026-08-17 03:28:23 -07:00
Michael Han
6453075ccd
Studio: let Allow microphone recover from a saved "Don't allow" on Windows (#9006)
* Studio: let Allow microphone recover from a saved "Don't allow" on Windows

WebView2 saves a denied microphone prompt in its profile and ships no site-settings
UI, so one accidental "Don't allow" blocked dictation permanently. The profile
survives reinstalls, and because the request never reaches the OS, Windows privacy
settings never list the app to toggle either, which leaves the user with nothing to
undo it (#9001).

Allow microphone now clears that saved answer before it asks. The reset only erases
the stored decision, never grants access: the next getUserMedia raises the same
prompt the first one did. It is best effort, since a browser tab has no command to
call and a WebView2 runtime older than the permission APIs has nothing to clear, and
in both cases the request is still worth attempting.

The blocked-microphone messages also stop pointing desktop users at browser site
permissions, which the desktop WebView has no UI for.

* Studio: keep the browser wording for a browser denial

micAccessBlocked is shown in Settings > Voice on the desktop app and in a
browser tab, and the reset behind Allow microphone only exists on the
desktop: resetMicrophonePermission returns early when isTauri is false. So
a browser keeps its saved deny, clicking the button rejects again with no
prompt, and telling those users they will be asked again is wrong.

The shared key goes back to the page-permission wording every other locale
already carried, and a new micAccessBlockedDesktop covers the desktop
build. Its text names the retry and the system privacy settings, so it also
holds on macOS and Linux, where the reset is a deliberate no-op.

Added to all twelve locales, since strict parity fails on an en-only key.
2026-08-17 02:56:54 -07:00
Wasim Yousef Said
83e163f13c
Revert "Desktop: ship a complete Linux AppImage (#8695)" (#8823)
This reverts commit 93a77d4104.
2026-08-14 06:55:54 -07:00
Long Yixing
b69bfe0216
fix(studio): keep a slow install alive and name what it is downloading (#8805)
A desktop install was killed after two hours of wall clock regardless of progress. On a slow link the cu126 torch wheel takes longer than that on its own, so the install could never finish, and each kill wasted every byte already fetched because uv restarts an interrupted download from zero. The installer also keeps uv's output in a log it only prints on failure, so those two hours looked identical to a hang, both to the user watching and to anyone reading the logs afterwards.

install.sh and install.ps1 now turn uv's announcements for downloads of at least 50 MiB into `[TAURI:DL]` / `[TAURI:DL_DONE]` protocol lines, while the per-package chatter stays in the log where it was: a full dependency set is dozens of announcements plus a line per installed package, which would bury the installer's own output. The app consumes the markers without displaying them, so a healthy install looks exactly as it did before. Once five minutes pass with nothing printed it reports the step, the package being fetched, its size and elapsed time, and it now gives up only at a twelve-hour backstop.

Markers travel on stderr from install.sh, alongside the other protocol lines that function already writes there. That also keeps them clear of the verbose path's redactor, whose sed block-buffers its output: a marker queued behind it would reach the app only once the download it announces had finished. install.ps1 writes them on stdout, so both reader threads filter them out of the UI. Where awk is absent, which this script already supports elsewhere, the filter degrades to plain capture rather than closing the pipeline under the child.

There is deliberately no rule that stops an install for being quiet. That needs evidence that work is happening, and the markers cannot supply it everywhere: the uv calls under `studio setup` capture their output rather than streaming it, so a silence rule would stop healthy installs in exactly the phase this change exists to protect. Extending the markers to those wrappers is follow-up work.

Validated by driving both installers' real command wrappers against recorded `uv pip install` output -- `run_install_cmd` under sh, and `Invoke-InstallCommand` under PowerShell across both its quiet and verbose arms -- plus a timing check that a marker is readable while its download is still running, and the existing installer and watchdog suites. Both installers are pinned to the same 50 MiB threshold, so they cannot drift apart.

Refs #8698
2026-08-14 06:36:44 -07:00
Wasim Yousef Said
93a77d4104
Desktop: ship a complete Linux AppImage (#8695)
* Desktop: ship a complete Linux AppImage

* Address complete AppImage review findings

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

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

* Isolate AppImage GIO environment

* Fix release resolver lane count for PR #8695

* Keep the AppImage runtime out of host launcher processes

* Ship a resolvable AppImage .DirIcon

* Scrub AppImage GUI paths from managed children

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-14 03:46:47 -07:00
Daniel Han
4dab08b273
Desktop: keep "Run Unsloth at login" when something deletes the Run value (#8707)
The desktop stored this setting in exactly one place, the HKCU Run value that
auto-launch writes, and read it back the same way. Nothing else on Windows
removes that value, but plenty of things do: the NSIS uninstaller drops it on
any non-update run (installer.nsi), and an antivirus quarantine or a registry
cleaner takes it the same way. Every one of those turned into a silent,
permanent "off" that the user only found out about the next morning, with no
tray icon at login and the toggle reading unchecked.

Keep our own record beside the OS entry, in the app config dir, the way
close-to-tray already does. reconcile_autostart_entry then writes the entry
back at startup when it is gone and the record says the user had asked for it.
An install that already has autostart on adopts a record on its next launch, so
the first deletion is recoverable rather than being the one that teaches us to
care.

Restore a deleted entry, never a disabled one. Task Manager's Startup tab and
Settings > Apps > Startup disable an entry by writing a timestamp into
StartupApproved\Run; they never delete the Run value. auto-launch's is_enabled
conflates the two, so reacting to it directly would re-enable autostart every
morning for someone who deliberately turned it off. Read both keys separately
and only act on an absent value that Windows has not been told to hold down.

Windows only. Removing a login item is a first-class gesture on the other two
platforms, since macOS System Settings > Login Items deletes the LaunchAgent
and GNOME's startup UI deletes the .desktop file, so a restore there would
resurrect what the user just removed. Behaviour off Windows is unchanged.
2026-08-13 07:56:34 -07:00
Daniel Han
1b48147d8e
Windows: stop depending on the generated unsloth.exe console script (#8592)
* Windows setup: install uv from a pinned release instead of running remote script text

studio/setup.ps1 piped astral's install.ps1 straight into Invoke-Expression. That
download-and-execute shape is the single construct AMSI providers and cloud ML
scanners score hardest, and install.ps1 already replaced it with a pinned-SHA-256
archive download. Port the same implementation across.

Progress goes to the pipeline rather than the console, so the quiet path swallows
it exactly as it swallowed astral's installer output and the printed lines around
the call site are unchanged.

* Windows: stop pairing a hidden window with a bypassed execution policy

The Studio shortcut launched launch-studio.ps1 with -WindowStyle Hidden and
-ExecutionPolicy Bypass on the same command line. That pair is what Microsoft's
own detections key on, and studio/src-tauri/src/install.rs already refuses it for
the app's own launch of install.ps1.

The installer writes launch-studio.ps1 itself, so the file carries no
mark-of-the-web and RemoteSigned loads it. The hidden window is unchanged, so the
shortcut behaves exactly as before. The generated launcher's own child launch
moves to RemoteSigned for the same reason: it runs an inline -Command against an
executable, where no script file is loaded and the two policies are equivalent.

Also refresh a stale comment in studio/setup.ps1 that attributed the PSModulePath
fix to astral's uv installer, which no longer runs in-process.

* Installers: keep download-and-run command lines out of the shipped script text

AMSI scans install.ps1 in full before a single line of it runs, and generic
script classifiers read install.sh the same way inside the Linux bundle. Both
headers rehearsed the piped web one-liner five times over, plus a scriptblock
form and an execution-policy bypass, none of which anything in the scripts reads
and all of which the README already documents.

Point at the README instead and reword the in-body comments that quoted the
one-liner as shorthand. Every printed line is untouched: the remediation text the
installers show users still spells out the command in full.

Same treatment for scripts/uninstall.ps1's header.

* Windows: resolve process image paths with one Win32_Process query

install.ps1's venv-holder probe opened a handle to every running PID through
inline C# compiled at runtime. Opening a handle per process is a shape AV
heuristics score hard, and it bought nothing: Win32_Process reports
ExecutablePath for exactly the processes those handles could be opened against,
and answers for all of them in a single query instead of once per PID.

The remaining file-canonicalisation imports stay -- handle-based resolution of
linked ancestors has no faithful Windows PowerShell 5.1 equivalent, and it runs
on security-relevant paths.

Falls back to the per-process .Path when the query is unavailable, so a degraded
WMI repository degrades exactly as the old code did on a process it could not
open.

* Desktop: say who blocked the install when AMSI stops the script

PowerShell hands the whole top-level script block to AMSI while compiling it, so
a security product's verdict arrives as a parse error over the entire file before
install.ps1 runs a statement: no [TAURI:ERROR] marker, no phase log, and a stderr
tail the user cannot act on. unsloth#8523 shows what that looks like in the UI --
"Installation failed: + FullyQualifiedErrorId : ScriptContainedMaliciousContent".

Recognise the two stable error ids on either stream and append what the user
actually needs: nothing was installed, nothing was changed, it is a false
positive, update definitions and retry, do not turn off endpoint protection. The
raw id stays in the message, because the diagnostics report and any vendor
submission both need it.

Matches the id, never the message text, which is localized, and tolerates the
cmdlet suffix the Invoke-Expression form carries.

* Desktop: ship each bundle only the installer it can run

resolve_install_script picks install.sh on unix and install.ps1 everywhere else,
but the shared Tauri config bundled both into every target. The Linux AppImage
therefore carried 280 KB of Windows PowerShell it can never execute -- and it is
the largest script body a generic classifier walking the squashfs reads, which is
where Microsoft's Trojan:Script/Wacatac.B!ml verdict on 0.1.701-beta landed.

Move the resource map into the per-platform configs. The clean-machine job
already fails when a Linux bundle ships no install.sh; it now also fails when one
ships install.ps1, so the split cannot silently regress in either direction.

The .deb scanned clean with the same payload, so this is surface reduction rather
than a proven fix for that verdict.

* POSIX installers: install uv from a pinned release before falling back

install.sh downloaded astral's install.sh to a temp file, ran it and deleted the
file; studio/setup.sh piped it straight into a shell. Both are, shape for shape,
what a dropper does, and generic ML script classifiers score them accordingly --
the 0.1.701-beta Linux AppImage came back Trojan:Script/Wacatac.B!ml while the
.deb carrying the same scripts came back clean.

Fetch the pinned release archive and verify a hardcoded SHA-256 instead, matching
what install.ps1 already does on Windows. Only the four mainstream targets are
pinned: musl, armv7 and any host without a digest tool keep the path they have
today, because guessing a target triple wrong would break the install outright
and that costs far more than the heuristic score of the fallback.

Destination, PATH handling and every printed line are unchanged, so a host that
takes either path ends up in the same state it did before.

* tests: pin the installer shapes antivirus heuristics score

One file collecting what was removed, so it cannot drift back: no remote script
run in-process, no encoded or base64 payload, no hidden window paired with a
bypassed execution policy, no handle opened against another process, and no new
runtime-compiled native import outside an allowlist that carries a reason for
each entry that stays.

The last test is the other half of the contract. Hardening must not change what a
user sees, so the remediation lines the installers print -- which still spell out
the web one-liner in full -- are asserted verbatim. Removing the one-liner from
comments is the point; removing it from what the user is told to run would be a
regression.

Runs on the existing discovery-based pytest step, no workflow list to update.

* release: emit a false-positive submission packet for whatever gets flagged

The build job assembles a Microsoft submission packet, but only for the Windows
-setup.exe. The detection that actually arrived on 0.1.701-beta was
Trojan:Script/Wacatac.B!ml on the Linux AppImage, so nothing was produced for the
one asset that needed it.

The VirusTotal job already knows which assets were flagged and by which engines,
so put the packet there: hash, size and both portals, for every flagged asset
whatever platform it came from, with a note that clearance is per hash and per
vendor. Engine names are not repeated -- they are third-party text and already
appear escaped under Flagging engines.

The gate stays advisory; this only makes acting on it take seconds.

* Revert "Windows: resolve process image paths with one Win32_Process query"

This reverts commit 7897865c9.

tests/python/test_windows_installer_concurrency_guard.py bans Get-CimInstance
and $process.Path from Get-RunningStudioVenvProcesses outright, and requires the
native image-path lookup. That contract came out of #7764, which closed a set of
races where the installer inferred "in use" from something other than a confirmed
executable identity and blocked installs that should have proceeded.

Win32_Process.ExecutablePath does answer the same question, but a wrongly blocked
install costs far more than the heuristic weight of three native imports. Record
the imports in the AV-shapes allowlist with that reasoning instead, and keep the
ban on the process-memory APIs, which the installer has no use for.

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

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

* Tighten the comments added by this branch

Opening comment-reduction pass over the PR diff: same intent, fewer lines. Cut
hardest on the prose that restated the PR description rather than explaining the
code next to it. Comments and docstrings only, verified with comment_tools.py
check --strip-docstrings across every Python file in the diff.

* Drop an unused helper from the uv pinned-release test

* Fix three review findings on the installer hardening

Stray-resource check aborted the step it was meant to assert. grep exits 1 when
it selects nothing, and under this step's set -o pipefail plus the runner's
bash -e that kills the assignment outright, so every correctly split .deb failed
clean-machine CI before reaching the check. Both lookups take || true now: no
match is the passing case for the stray one, and for install.sh it was swallowing
the explicit annotation in favour of a bare exit 1.

studio/setup.sh skipped astral's XDG_DATA_HOME/../bin destination tier, which
install.sh, install.ps1 and studio/setup.ps1 all honour. A host that configured
an XDG location got uv under ~/.local/bin instead, where no later shell looks for
it. The session PATH prepend hid it at install time.

The AMSI guidance claimed nothing was changed even when the block landed on the
nested studio/setup.ps1, which install.ps1 launches through the same inherited
pipes after the venv, PyTorch and the packages are already on disk. Split the
wording on whether a [TAURI:STEP] marker has been seen: a pre-start block
produces none, so the reassurance is only given where it is true.

* Key the submission packet on the flagged count, not the engine list

stats and results are separate fields of the same VirusTotal response, so an
asset can carry a flagged count with no readable results map. The summary table
reports that asset and the packet skipped it, which is exactly the one that needs
a packet. Select on stats.flagged and keep the engine list for the Flagging
engines section, which is correctly keyed on having engines to name.

* Drop the bundle stray-resource assertion from clean-machine CI

That job downloads a published release, never a bundle built from the branch, so
asserting the new resource split there turns every run red until a release ships
with it. The split is a property of the Tauri config, and
tests/studio/test_tauri_installer_resource_contract.py already enforces it at the
right layer.

The || true on the install.sh lookup stays: it is what lets the explicit
annotation print instead of the step dying on grep's exit 1 under pipefail.

* Windows: stop depending on the generated unsloth.exe console script

Fixes #8490. On Windows the `unsloth` entry point is materialised as a
generated, unsigned launcher .exe. AppLocker, WDAC and Smart App Control
deny it, while the venv's python.exe, a copy of the signed CPython binary,
still runs. The installer died at "running unsloth studio setup" with
`Program 'unsloth.exe' failed to run: An Application Control policy has
blocked this file`, and because the launch throws rather than returning an
exit code, it escaped Install-UnslothStudio and printed a raw
NativeCommandFailed dump instead of a diagnostic.

The desktop updater already solved this in update.rs by reaching the CLI
through the interpreter. This applies the same idea everywhere else: the
setup handoff, autostart, the shortcut launcher, the Tauri backend, auth
provisioning, the install health probe, the preflight probes and the
`studio run` respawn. unsloth.exe is still generated, still hardlinked to
the shim, and still works. Nothing depends on it any more.

Also adds `python -m unsloth_cli` as a supported entry point, and a
bin\unsloth.cmd companion to the shim so `unsloth.cmd` is available where
the .exe is denied.

The trampoline is one string shared by install.ps1, process.rs and
studio.py:

    import sys, os; sys.path[:1] = [x for x in sys.path[:1] if x not in ('', os.getcwd())]; sys.argv[0] = 'unsloth'; from unsloth_cli import app; app()

Both halves are load bearing. argv[0] is assigned before the import
because unsloth_cli decides at import time whether it is the console
script, which gates the UTF-8 stream setup and the -np<N> rewrite, and it
keeps typer's prog_name at `unsloth`. The sys.path[:1] filter drops the
working directory entry that `python -c` adds and a console script does
not, which is what lets the invocation stay off -I: -I would drop it too,
but also PYTHONPATH, PYTHONWARNINGS and user site-packages, which the
console script honours.

Behaviour on a machine with no policy is unchanged, and that is enforced
rather than asserted. tests/python/test_module_entry_point.py compares
stdout, stderr and exit code between the console script, `-m unsloth_cli`
and the trampoline over --version, --help, `studio --help` and two error
paths. The writes are idempotent: bin\unsloth.cmd, launch-studio.ps1 and
the .lnk files are content compared, so a second install changes no bytes
and no timestamps.

tests/studio/test_application_control_cli_fallback.ps1 pins the pieces
that are easy to get wrong: the failure is classified off the exception
(Win32 1260), never off $LASTEXITCODE, which no process was created to
set; Start-Process gets one pre-quoted command line, since -ArgumentList
joins an array with spaces and quotes nothing; and bin\unsloth.cmd only
counts as an ownership marker when its contents match the shim we write,
so an unrelated file of that name in a custom root cannot qualify it for
removal.

The new windows-application-control-ci.yml leg reproduces the report:
AppLocker denies only Scripts\unsloth.exe for a standard user, a negative
control proves the rule is actually enforced (the job fails loudly if the
stub runs), and the full installer then has to succeed.

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

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

* Tighten the comments added for the Application Control fix

* Add the AGPL header to the module entry point test

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

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

* Drain the shim launch probe's pipes before waiting on it

* Harden the cmd shim ownership marker, updater env and launcher hints

* Run the Application Control CI leg without --tauri so the pinned root applies

* Stub the runtime gate so the Windows launcher tests run on Windows

* Isolate the advertised module route from the working directory

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

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

* Fix the contradictory updater env assertion and the user-site fallback

* Treat a quarantined stub as a managed install on Windows

* Tighten the duplicated trampoline rationale to one authoritative copy

* Windows: keep a quarantined launcher and a partial migration recoverable

Two follow-ups on the Application Control work.

An antivirus quarantine deletes the unsigned unsloth.exe rather than
denying it. The updater then found no launcher, no copy to restore, and
reported a broken update, rolling back a package that was in fact fine.
Absence is now excused the same way a policy denial is, but only after
every recovery copy has been tried, so a launcher that could be put back
still is.

find_unsloth_binary_in_studio_dir accepted a bare python.exe in layout
order, so an interrupted migration leaving a partial new environment
beside a working legacy .venv targeted the broken one. A launcher
anywhere now outranks an interpreter on its own.

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

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

* Windows: let studio run start a venv whose console script was quarantined

The Windows respawn goes through the interpreter and never launches
Scripts\unsloth.exe, but the gate before it still required that file, so
an install whose stub antivirus had taken aborted with "Unsloth venv
missing 'unsloth' entry point" despite being able to run. The installed
package now answers for the deleted stub, one layer down and just as
cheap. POSIX still proves a CLI with the console script it execs.

* CI: apply the AppLocker policy before AppIDSvc reads it

The negative control watched the denied user start the stub. The job
started AppIDSvc and set the policy afterwards, and the service loads the
effective policy when it starts, so it was enforcing nothing; gpupdate
does not make it re-read a local policy. Restart the service once the
policy is in place, and retry the control while enforcement goes live,
which is asynchronous and unsignalled.

* Cover the uv host matrix and repeat application in the pinned-release test

The pinned path picks an archive per host triple, and a wrong pick installs a
binary that cannot execute, which is worse than not installing at all. Drive
_uv_pinned_asset over 20 host combinations and require each one to return its
own triple or decline to the fallback.

Also run the installer three times over one HOME and require an identical tree,
and require a stale uv at the destination to be replaced rather than joined by a
second copy: the installer is re-run on every upgrade and every repair.

* Windows: close the parity and old-install gaps found by the idempotency audit

Five independent audits of the before/after parity bar, plus local
simulations, turned up six things worth fixing.

Parity, on machines with no policy at all:

- Under PYTHONSAFEPATH or -P there is no implicit -c working-directory
  entry to strip, so sys.path[0] is whatever PYTHONPATH put there and the
  console script honours it. The filter removed it anyway; a PYTHONPATH
  starting at the working directory was measured selecting a different
  package through the trampoline than through the console script.
- The backend start log went from a joined argument string to Rust's
  debug list on every platform. It is what users paste into issues.

Idempotency:

- The .cmd shim and launch-studio.ps1 compared decoded text, which drops
  a BOM and ignores case, so a BOM-prefixed shim was called unchanged and
  left with cmd.exe reading the BOM as part of @echo off. Both compare
  bytes now, launcher preamble included.
- A run killed between the temp write and the rename left a temp file no
  later run would collect, since each names its own after its PID. Swept,
  skipping any whose owner is still alive.
- The Application Control probe cached its verdict in :, which
  under irm | iex is the caller's session, so a second run in one console
  answered from the first run's machine state.

Old installs:

- An installer older than the shim directory never created one, and
  unsloth studio update is the only route those installs take back into
  install.ps1, so they never gained the .cmd. Created there now.
- A migration interrupted by an open handle can split either layout. The
  finder now prefers a launcher with its interpreter beside it in either
  base, then an interpreter alone, then a launcher alone, so neither half
  of a split tree wins by layout order.

* Pick the pinned uv archive off a positive libc check, not the absence of musl

An independent audit pass found the Linux selector accepts any host whose ldd
output does not say musl. That is not the same question astral's installer asks:
it checks a minimum glibc and drops to its musl-static archive below it, so
three hosts that worked before this branch now get a GNU binary that cannot exec,
and the helper reports success so the fallback never runs.

  aarch64 with glibc below 2.28 (Ubuntu 18.04)
  x86_64 with glibc below 2.17 (RHEL 6)
  a musl image with no ldd at all, where the probe simply finds nothing

Read the version instead, from ldd or getconf, and require it to clear astral's
floor for the triple. Anything unreadable declines to the fallback. Also ask the
userland for its bitness rather than trusting uname on a 64-bit kernel running a
32-bit userland, and follow astral in reading hw.optional.arm64 so a translated
shell under Rosetta 2 still gets the native macOS build.

Three more from the same pass:

Report success only when the destination uv is executable. A copy onto a busy or
read-only destination could leave a file that is not, and reporting success there
skipped the fallback. Nothing is unwound on the failure path on purpose: the
fallback installs over whatever is at the destination, and deleting there would
take out a working uv the host already had.

Clear the mark of the web on the launcher we author. WriteAllText replaces the
unnamed data stream and leaves other NTFS streams alone, so a launch-studio.ps1
that somehow carried one would keep it across the rewrite, and RemoteSigned
refuses a marked unsigned script.

Store the security-block kind and resolve its wording in message(). stdout and
stderr are read by independent threads, so a [TAURI:STEP] written before a block
can be observed after it, and freezing the wording at observation time could tell
a user nothing was changed on a run that had already installed PyTorch. Also
require the error id to appear as the value of a FullyQualifiedErrorId field, so
a scanner log that merely names it cannot attach antivirus guidance to whatever
fails next.

The host matrix in the shell test grows to 28 rows covering every case above, and
removing the new gate fails 8 of them. install.rs gains two tests: 37 pass.

* Replace a symlinked uv destination instead of writing through it

Three from the review on the previous head.

cp onto a destination that is a symlink follows the link, so installing over
`~/.local/bin/uv -> /opt/homebrew/bin/uv` rewrote the Homebrew binary in place
and left the link pointing at a file another package manager owns. Stage next to
the destination and rename over it: rename replaces the link itself, and it is
atomic, so a concurrent reader never sees a half-written uv either. The staging
file is removed when the rename fails, so a failed run leaves no debris.

Verify the Windows copy the same way the shell scripts now do. Copy-Item is
non-terminating under the caller's ErrorActionPreference, so a locked or
ACL-denied destination let execution reach `$haveUv = $true` and the function
reported success over whatever was already there. Compare the destination against
the archive we just verified, so a stale uv.exe cannot pass for the one we meant
to install. install.ps1 carried the same shape and gets the same treatment.

Point the header links at the heading that exists. The README has no "Install
Unsloth Studio"; it is "Unsloth Studio (web UI)", whose anchor is
#unsloth-studio-web-ui.

Three test cases cover the symlink: the file behind the link is untouched, the
link itself is replaced, and no staging file survives. Reverting the fix fails
two of them.

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

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

* CI: keep one Application Control negative-control log per attempt

Enforcement went live on the fourth try on the hosted runner, and a single
overwritten log left the evidence artifact showing a pre-enforcement
attempt's output beside a passing step.

* Fail the build when the uv pin drifts from a version floor

Before the pin, astral's endpoint always delivered the newest uv, so raising
UV_MIN_VERSION was safe on its own. It is not any more: a floor above the pin
means a host with no uv gets 0.12.1 installed and then judged too old by the same
script that installed it, on the one path where the pin is what runs.

Two checks. All four installers must name the same uv, or which version a machine
ends up with depends on which script reached it first. And the pin must clear
every floor in the tree (UV_MIN_VERSION, UV_OFFLINE_MIN_VERSION, $UvMinVersion).
Raising a floor past the pin fails the first, bumping one installer's pin alone
fails the second.

* Fix two Windows-only test failures that predate this branch

test_path_identity_failure_is_reported_as_unknown failed on both shells,
on main as much as here. Test-StudioPathEqual reports an unresolvable
path identity through Write-StudioLine, the harness extracts the mutex
helpers but not that, and these scripts run under -ErrorActionPreference
Stop, so the catch path died with CommandNotFound before the test could
measure anything.

Extracted rather than stubbed: it is self-contained, and a stub would
keep passing if the real call ever went wrong. A new check asserts every
installer function the extracted helpers call is in the harness, and it
runs on every platform, so the next drift cannot hide where only a
Windows runner would see it.

Measured on a Windows runner: main fails 24 of these, this branch fails
2, and both of those 2 are in main's set. With this, 0.

* Write the shell profile entry the pinned uv path no longer gets for free

The P1 here is a real regression and it took a second look to see why.

install.sh decides whether to add ~/.local/bin to the user's shell profile with
`case ":$PATH:"`, near the end of the run. By then this process has prepended
that directory twice, once for the uv bootstrap and once for the venv, so the
guard answers yes for a login shell that would answer no and the profile line is
never written. That was survivable while astral's installer ran, because it wrote
its own profile line and its env file. The pinned path writes neither, so on a
fresh account whose login PATH lacks ~/.local/bin the install succeeds, the
current shell works, and the next terminal cannot find `unsloth` or `uv`.

Snapshot the inherited PATH before anything prepends to it and test the guard
against that.

Two more from the same review.

Honour a configured uv mirror exclusively. UV_INSTALLER_GHE_BASE_URL and
UV_INSTALLER_GITHUB_BASE_URL already win outright in both PowerShell installers
and in astral's own; the shell path ignored them and tried the public hosts
first. A restricted network sets one precisely because those hosts are
unreachable, and download() has no timeout, so it would hang rather than reach
the fallback.

Do not let the twin of an earlier clear erase a later AMSI verdict.
Clear-TauriInstallError writes one logical clear to BOTH streams
(install.ps1:198) and independent threads read them, so a block observed between
a clear and its own twin was discarded by the twin. Ignore a clear identical to
the one just processed; a genuine later recovery carries different text and still
clears. Two tests cover both directions, 39 install tests pass.

* Stage the uv copy under a per-process name

An audit pass reproduced a race I introduced with the symlink fix. Both POSIX
helpers staged through a fixed destination-side name, so two installers targeting
one directory shared it:

  A finishes copying the staging file
  B opens the same path with truncation
  A renames that inode into place as uv
  B keeps writing through its open descriptor, which is now the published uv

The published uv was observable at zero bytes until B resumed, which makes the
claim in the comment about a concurrent reader flatly wrong. install.ps1 is
covered by its named mutex, but nothing serialises the POSIX helpers, and
studio/setup.sh runs standalone on every studio update.

mktemp in the destination directory instead. Each rename then publishes a file no
other process can still be writing, which is what the atomicity argument needed
all along. The loser cleans up its own staging file and declines, so the caller
falls back rather than reporting a success it did not achieve.

* Keep a default install as quiet as it was when a uv mirror misbehaves

Two console regressions from the audit pass, both on paths the install still
recovers from.

download() runs curl -LsSf, and -S deliberately prints its own errors. The
fallback ran under run_maybe_quiet, so a failed download printed nothing before;
the pinned attempts run outside that wrapper, so an unreachable mirror now put
two curl: (N) lines on the console of a default install that then succeeded.
Redirect stderr on the speculative attempts only, leaving download() untouched
for every other caller.

[TAURI:WARN] is a marker level install.sh has never emitted, and the app forwards
unknown markers to its progress UI verbatim (install.rs:639), so a digest
mismatch would have surfaced as raw text in the desktop window. Make it a verbose
only stderr line: the next mirror or the fallback still runs, so a default
install has nothing to say here.

Printed-string diff against the merge base is back to additions inside $(...)
capture plus that one verbose-gated line, with nothing removed or changed.

* Ask the installed uv whether it runs before skipping the fallback

The libc gate reads a glibc version from ldd or getconf and treats that as proof
a GNU binary will execute. It is not. A stripped NixOS-derived image without
nix-ld reports a glibc version through getconf while its loader lives in the Nix
store, so the pinned x86_64 uv asks for /lib64/ld-linux-x86-64.so.2 and gets
nothing. Every static check passed, so the helper reported success, the astral
fallback was skipped, and the first real uv call failed with No such file or
directory. astral's installer fails its own glibc probe on that host and ships
the fully static musl archive, which runs. The user went from a working uv to
none.

The archive is digest-verified astral uv by the time it is placed, so ask it:
run --version and require it to succeed. One exec closes the whole class rather
than this one host, covering a wrong triple, a loader that is not where the
binary looks, and a destination we could not really write.

A test drives an archive whose uv cannot execute and requires the helper to
decline; removing the exec check fails it.

* Pair every clear with its twin, not just the previous one

install.ps1 clears after each recovered step, so a lagging reader can be several
clears behind when a block lands. With clears A then B on one stream and A's twin
arriving on the other after the verdict, asking only whether this is the message
just seen answers no, and the delayed twin discarded the verdict the guidance
exists to explain.

Each logical clear emits exactly two markers, so count unpaired ones by message:
the first sighting is the clear, the next pairs with it. A test drives the A, B,
verdict, A', B' ordering; 40 install tests pass.

* Close the exactness gaps found by ten adversarial audits

Ten independent audits, each asked to falsify the claim that this is pure
hardening. Six things were worth changing.

- The desktop updater is isolated again. It shipped with -I, it is the one
  managed invocation nobody types by hand, and it decides which install
  gets rewritten, so a user-site unsloth_cli must not answer
  `from unsloth_cli import app` there. Every other call site inherits,
  because the console script does.
- The trampoline ends in sys.exit(app()), like the generated console
  script, so a returned value becomes the exit status. Typer raises
  SystemExit itself today, but the two routes have to agree.
- A launcher that could not be restored keeps its recovery copies. Judged
  healthy through the interpreter is not the same as repaired, and
  deleting the copies threw away what a later run needed.
- `unsloth studio update` puts the shim directory on PATH. An installer
  older than that directory put the venv Scripts dir there instead, so the
  .cmd was written where nothing would look for it.
- The console script reconfigured its streams twice off Windows, once
  through the import gate and once through the module-entry path.
- Replacing a bin\unsloth.cmd that carries neither our marker nor our
  trampoline now says so.

Also states the scope plainly: this answers EXE-and-DLL enforcement of the
unsigned console script. A machine that also enforces AppLocker's Script
collection denies .cmd and .ps1 alike, and install.ps1 would not have run
there either.

The two test harnesses that extract functions out of install.ps1 now
assert they define everything those functions call; both had already
shipped a gap that made a check pass for the wrong reason.

* Ask the interpreter, not site-packages, whether the managed CLI is there

The quarantine fallback accepted an unsloth-*.dist-info or an
unsloth_cli/ directory as proof of a runnable CLI. Neither is: an
interrupted install, or an editable install whose checkout has moved,
leaves metadata with nothing to import. This gate sits in front of the
headless-public strip of .bootstrap_password, so a false yes lands the
exact lockout its placement exists to prevent -- a public Studio with no
login page and no plaintext recovery credential.

find_spec through the managed interpreter answers the question the
trampoline will actually ask, with the same sys.path[0] scrub so a
checkout in the caller's cwd cannot stand in for the venv. A probe that
produces no verdict at all falls back to the old on-disk layout, so a
half-quarantined install still starts.

* Hide the import probe's console window, as every other managed probe does

* Validate the staged uv before it replaces a working one

My own exec check was on the wrong side of the rename. The sequence that bites:
a host has a uv good enough for UV_OFFLINE_MIN_VERSION but below UV_MIN_VERSION,
so the block runs with _uv_present_before true; the pinned path renames over that
working binary; the --version check then fails because the loader is missing or
the destination is mounted noexec; the fallback download also fails. The
installer neither restores the old uv nor reports that none is available, and
every later command runs the broken one.

Test the staging file instead, before the rename. It sits on the destination
filesystem, so it answers the noexec question too, and a binary that cannot run
here never gets to replace one that could.

Two tests: a working incumbent uv survives an archive whose uv cannot execute,
and the rejected staging file is cleaned up. Moving the check back after the
rename fails the first.

* Make each managed CLI probe ask the question its launch will answer

Three findings from the latest review round, one theme: a probe that stands in
for a launch has to run under the same conditions as that launch, or it can pass
where the launch then fails.

* The quarantine gate in `studio run` asked find_spec whether unsloth_cli
  resolves. It resolves for an emptied unsloth_cli/ directory (find_spec calls
  that a namespace package), for a package whose __init__ raises, and for one
  whose dependencies an interrupted install never fetched, and the trampoline's
  `from unsloth_cli import app` fails on all three. Verified: an empty package
  directory in a bare venv gives find_spec True and ImportError on the import.
  This gate stands in front of the headless-public strip of .bootstrap_password,
  so a false pass there is a public Studio with no login page and no plaintext
  recovery credential. The probe now performs that exact import.

* The updater's interpreter health check ran without isolation while the launch
  it predicts, build_update_command in studio/src-tauri/src/update.rs, runs under
  Isolation::Isolated with PYTHONHOME/PYTHONPATH cleared. A foreign checkout on
  PYTHONPATH could answer --version for a managed package the update had broken,
  and validate_launcher would keep an update the next desktop launch cannot
  start. _managed_cli_argv now takes the same isolated flag the Rust Isolation
  enum carries; the health probe is the only caller that sets it, and a test
  pins that it stays the only one. Every other invocation keeps PYTHON* parity
  with the console script.

* Binary resolution, second pass. With an interrupted migration leaving an
  interpreter in both layouts and a launcher in neither, layout order handed back
  the new base even when its site-packages was empty and the legacy base still
  held the package. A directory test rather than an import probe: this runs on
  the launch path and from the capability checks, so it stays a stat.

Tests: the four unimportable package shapes, the isolated/inherited argv split
and its single caller, and both directions of the two-interpreter tie-break.
The old whole-file "no -I anywhere" assertion is now read off the ternary, since
one deliberate -I exists.

* Stop the AMSI guidance claiming more than it knows

Two of these are honesty defects in text a blocked user reads.

"nothing was changed on this machine" is false. Rust starts a diagnostics
attempt and its phase log before PowerShell is ever spawned, and spawn_script can
create ~/.unsloth first, so a pre-start block has already written to disk. The
honest claim is that no installation step ran.

"This is a false positive" is not something the classifier can know. It proves
the output carries a PowerShell error id and nothing about the script's
integrity, and install.ps1 can sit in a user-writable directory, so a locally
modified copy can earn a genuine verdict. Telling someone to report a correct
detection to their vendor is worse than telling them to reinstall from an
official package first and only escalate if an unmodified copy is still blocked.

Two smaller ones from the same pass. The matcher tested for the field name and
the id independently, so a line naming both in prose qualified; it now requires
the id to follow the colon and end at a comma or whitespace, which is what the
comment always claimed. And the clear-pairing map is bounded: legitimate
producers use a small fixed label set, and child output must not be able to grow
it without limit.

42 install tests pass.

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

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

* Honour astral's download override, and stop Unblock-File asking

Three from the second audit round.

Unblock-File declares SupportsShouldProcess at the default Medium impact, so a
profile that sets $ConfirmPreference to Medium or Low gets a prompt from the line
I added, even for a launcher that never carried the stream. -ErrorAction does not
suppress a ShouldProcess prompt, and a noninteractive host turns it into an error
that skips shortcut setup entirely. -Confirm:$false.

UV_DOWNLOAD_URL and its older alias INSTALLER_DOWNLOAD_URL outrank the mirror
variables in astral's installer, and the merge-base path inherited that because it
ran astral's script. All four implementations now honour them first and
exclusively. My earlier comment argued they point at a version the pin would
reject, but that reasoning had it backwards: a host sets one because it cannot
reach the public endpoints, so ignoring it meant public egress first and, with no
timeout on the download, a hang instead of a fallback. The pin still applies, so a
source serving a different build fails the digest and the caller falls back to
astral's installer, which honours the same variable.

chmod 0755 on the staging file rather than +x. cp gives it the umask default and
+x then adds execute only where the umask allowed read, so a umask of 077 left uv
unusable for every other account on a shared machine. astral ships them 0755.

Four checks pin the override precedence across all four installers and the mode
across both shell ones, with the behaviour verified against a stubbed downloader.

* Validate uv before it replaces an incumbent on Windows, and bound the probe

install.ps1 and studio/setup.ps1 copied the extracted uv.exe straight over the
destination and only asked whether it ran afterwards. A host with a working older
uv and a policy (AppLocker, WDAC, endpoint protection) that refuses the new one
was left with neither. Run the extracted binary where it landed first, then keep
a copy of the incumbent across the publish and restore it if the published copy
will not run, since Windows has no atomic replace for a file that may be open.

The probe itself is bounded: Start-Process with a 20s WaitForExit and redirected
streams, and on POSIX no stdin plus a 20s ceiling where timeout exists. A binary
this installer just downloaded must not be able to hang an unattended install by
prompting or by never exiting.

install.sh and studio/setup.sh also published the pinned uvx after rejecting the
pinned uv, leaving a pairing that is never built or tested. A uv that fails to
stage, copy or run now abandons the whole placement.

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

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

* Verify each uv mirror, and persist PATH when the account has no rc file

Both PowerShell installers checked the archive digest once, after the download
loop had already broken out. A captive portal or a proxy answering 200 with its
own body is a successful download by every measure Invoke-WebRequest has, so the
first mirror consumed the only attempt and the second, healthy one was never
tried. The digest now decides whether a mirror counts as served.

install.sh picked a shell profile from .zshrc, .bashrc or .profile and did
nothing when none existed. A fresh account has none: astral's installer used to
create its own PATH setup there, the pinned path does not, so the next terminal
resolved neither unsloth nor uv. Fall back to creating ~/.profile, which every
POSIX login shell reads. The existing content guard keeps it written once.

* Remove the install.sh a Windows upgrade would otherwise keep forever

Windows bundles now carry only install.ps1, but NSIS writes the current resource
manifest and deletes nothing, and the uninstaller deletes only what is in that
manifest. An in-place upgrade from a release that bundled both installers left
install.sh in $INSTDIR permanently, which also made the non-recursive
RMDir "$INSTDIR" fail at uninstall. The pre-install and pre-uninstall hooks now
delete it, so the population most likely to upgrade actually gets the split.

Also silence the speculative mktemp -d in the pinned uv path: its failure falls
back to astral's installer, so an unusable TMPDIR printed a line the user could
not act on and that the merge base did not print.

* Remove the pinned uv temporaries when an install is interrupted

The pinned path unpacks a 40 MB archive into a work directory and stages the
binary next to the destination, but only cleaned both up when the helper returned
normally. A Ctrl-C in between left the archive behind and left a staging file
inside a directory that is on PATH. Both paths are now published to the exit and
signal traps as they are created and cleared when the helper releases them.

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

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

* Persist the PATH the way each shell actually reads it, and fail a half-published pair

Four follow-ups from review:

A uvx that the archive carried but that could not be staged or renamed left uv
published next to a stale or missing uvx and still reported success, skipping the
fallback that would have installed both. Either half failing now fails the
placement, in install.sh and studio/setup.sh.

studio/setup.sh had none of the interrupt cleanup install.sh gained: a Ctrl-C
left the unpacked archive behind and a staging file inside a directory on PATH.
It now owns HUP, INT, TERM and EXIT for the duration of the pinned install and
hands them back on the way out.

fish sources none of the POSIX rc files, so the ~/.profile fallback was a no-op
for a fish user. The persistence helper writes a conf.d drop-in with
fish_add_path there, and honours ZDOTDIR for zsh.

UV_INSTALL_DIR, UV_UNMANAGED_INSTALL, XDG_BIN_HOME and XDG_DATA_HOME can put uv
somewhere other than ~/.local/bin, and astral's installer wrote a PATH line for
whichever it picked. The pinned path now persists its own destination too, with
UV_NO_MODIFY_PATH honoured as astral honours it.

* Make the Windows uv publish a real transaction, and quote persisted paths

The companion copies ran bare: under install.ps1's Stop preference a locked or
ACL-denied destination threw past the rollback and left a mismatched set with the
backups still on disk, and under setup.ps1's Continue preference it kept a stale
companion and reported success. Both now copy under -ErrorAction Stop inside the
transaction, so any failure unwinds like the others.

A failed restore also used to delete the backup anyway, which is the one path in
this block that could leave the host with less than it started with: the two
things that make a restore fail, an open incumbent and a denied ACL, are the same
two that made the replace risky. The backup is now kept and named.

fish takes an unquoted path with a space as two directories, neither of which
exists, so the drop-in single-quotes it; and the rc line is written inside double
quotes, so a uv directory holding a dollar or a backtick is escaped. The second
test caught a doubled backslash in the escaper itself.

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

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

* Tighten the comments added by this branch

Comments only, no code touched: 161 comment lines become 106 across install.sh,
install.ps1, studio/setup.sh, studio/setup.ps1 and the NSIS hooks. Each one keeps
the reason it was written for, said once.

Verified with the PowerShell AST parser, sh -n and bash -n, the 50-check uv
pinned release suite and 114 installer tests, and by confirming the diff contains
no non-comment line.

* Tighten the install.rs comments too

Comments only: 35 lines become 27, each keeping the reason it was written for.
42 install tests pass and the diff contains no non-comment line.

* Abort on a companion that cannot be backed up, and pair clears by stream

A uvx.exe that could not be copied aside, because it is locked or its ACL denies
reads, was skipped and the new uv.exe published anyway, so the function reported
success with a mismatched pair and the fallback never ran. Any backup failure now
fails the placement and runs the rollback, in install.ps1 and studio/setup.ps1.

The ERROR_CLEAR pairing keyed only on the message, so two real clears of one label
on one stream were taken for a clear and its twin. That happens:
_install_torch_default_index emits its recovery during the install and again
during the ROCm repair. A verdict landing between them was then erased by the
genuinely later clear arriving on the other stream. The map is keyed by stream as
well, so only the opposite stream's copy can consume a pending marker.

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

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

* Do not fail an install because the uv probe could not get an answer

Three clean-machine CI legs that pass on main failed on this branch: arm64 and
two Windows containers, all three with winget unavailable, which is the only
condition under which the pinned fallback runs. Each downloaded the right asset,
passed the digest, and then failed the probe. Start-Process -NoNewWindow with
redirected streams does not behave in a container or on the arm64 image the way
it does in a desktop session, and a boolean probe reported that as a broken
binary and aborted the install.

The probe is now tri-state. Only the binary answering non-zero is a failure. A
launch that throws or a wait that times out is inconclusive, and since the digest
already proved the bytes are astral's pinned release, an inconclusive probe
publishes as the pre-pin code did. Every path prints why, with the captured
stderr and the exit code, so the next occurrence is not opaque.

Also from review: the POSIX path now stages both binaries and publishes them
together with the incumbents saved aside, so a failed uvx rename restores the
uv it replaced instead of leaving a new uv beside a stale uvx; the Windows
rollback records the destination before the copy that can truncate it; and
UV_UNMANAGED_INSTALL suppresses the profile write, as it does for astral.

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

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

* Give setup.sh the same pair publish and PATH persistence as install.sh

studio/setup.sh published uv and then uvx one after the other, so a failed uvx
rename left a new uv beside the host's stale one, and the remote fallback can be
unavailable. It now stages both, validates uv, and publishes the two renames back
to back with the incumbents saved aside, restoring them if the second fails.

setup.sh is also run directly for local and Colab setup, where astral's installer
used to write the profile line for whichever destination it chose. Without one the
PATH export died with that shell and every later run reinstalled uv. It now
persists its own destination, with fish handled on its own terms and both of
astral's opt-outs honoured.

* Treat an empty uv exit code as no verdict, not as a failure

The arm64 clean-machine leg still failed on the tri-state probe, and the
diagnostic that came with it said why: "uv --version exited ." with no number.
WaitForExit(ms) can return before the exit code is cached, so ExitCode was empty
and an empty value is not 0, which read a working uv as broken.

The parameterless WaitForExit settles it and returns at once because the process
has already exited, and a code that is still missing is inconclusive rather than
a failure, which is the same rule the launch and timeout paths already follow.
Verified against pwsh that a real non-zero exit and a real launch failure still
classify as failed and unknown respectively.

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

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

* Eight review fixes across the uv publish and PATH persistence

The fish escaper in studio/setup.sh reached sed as an invalid expression, so a
fish user running setup directly would have had setup killed under set -e right
after uv was published. It now matches the one in install.sh, and the test runs
both escapers rather than reading them.

An incumbent that cannot be hard-linked or copied cannot be restored either, so
publishing over it would be a one-way move. Both shells now decline. Writing that
test turned up that my own rollback deleted both incumbents when nothing had been
published, since the no-predecessor branch cannot tell the two cases apart; the
rollback is now reached only after a publish was attempted.

A rollback with no predecessor removes the binary it published, rather than
leaving half a pair the host never had.

A signal between the two renames left the undo copy as the only reference to the
incumbent, and the handler deleted it. It restores it now, in both shells.

setup.sh prepended ~/.local/bin unconditionally after a successful pinned
install, so a stale uv there could shadow a custom UV_INSTALL_DIR destination and
the rest of setup would run the wrong one. That prepend is now only for astral's
installer, which is what writes there.

PATH entries are compared literally rather than as case patterns, so a
destination holding *, ? or [ is not mistaken for an unrelated entry.

On Windows, a .unsloth-old left behind by a failed restore is the only copy of a
working uv, and the next run reused that exact name. It takes a distinct one.

* Keep the pinned uv first on PATH, and only count an active profile entry

install.sh prepends ~/.local/bin after the uv bootstrap, and astral's env file
does too, so a custom UV_INSTALL_DIR destination was pushed behind a stale uv
sitting in the home directory and every bare uv below picked the wrong one. The
pinned destination goes back in front. setup.sh had the same shape and was fixed
in defd2292a.

The profile check treated any occurrence of the destination text as proof the
PATH entry was already there, so a commented-out old export, or /opt/uv-old when
the destination is /opt/uv, suppressed the write and left the next shell without
uv. Comments are stripped and the directory has to appear as a whole entry.

* Close five review findings on the quarantine and stubless paths

* The installer still required Scripts\unsloth.exe to exist, and aborted the
  whole install when it did not. That reasoning held for a policy, which denies
  the file and leaves it on disk, but not for antivirus, which quarantines it out
  of a venv that still runs, and nothing past that point executes it: the setup
  handoff, the shortcuts and bin\unsloth.cmd all go through the interpreter. It
  refused to install or repair Studio for exactly the machines this change is
  for. Absence now asks the interpreter for --version through the trampoline, and
  only a venv that cannot answer fails, with the same older-unsloth guidance.

* The import probe's no-verdict fallback is now split by cause. A timeout keeps
  the on-disk layout, because slow is not broken: a cold venv under an antivirus
  scan is exactly that, and the re-exec has no timeout of its own. A failure to
  START the interpreter fails closed, because the re-exec runs that same
  interpreter and will fail the same way, and the caller strips
  .bootstrap_password before re-execing on a headless public launch.

* The updater's interpreter fallback used the launcher's 10s timeout for a call
  that has to import the entire CLI package. That is the work the import probe's
  60s ceiling is deliberately generous for, and under the antivirus scan this
  path exists to survive the short one would call a healthy update broken and
  roll it back, once per recovery candidate.

* Binary resolution now accepts an unsloth-*.dist-info alongside the package
  directory when ranking stubless venvs, matching _managed_cli_site_packages_
  layout. A PEP 660 editable install leaves a .pth and a dist-info and no
  unsloth_cli/ at all, so the directory test alone ranked a working legacy venv
  below an empty new one.

* managed_bin_fingerprint required fs::metadata on the launcher, which the
  stubless layout deliberately reports as a path that does not exist, so the
  capability cache could be neither read nor written and every preflight paid
  both probe subprocesses again. It falls back to python.exe, which is what
  starts the CLI there, while the cache key stays the launcher path.

Tests: the fail-closed/fallback split in both directions, the timeout contract
and that the two constants differ, the editable-install ranking with an
unrelated dist-info as the negative control, the stubless fingerprint and its
invalidation, and the installer gate through the extracted AST harness.

* Gate the NSIS tidy-up, and remove an orphan uv on signal

The pre-install hook runs before the user can still cancel, and $INSTDIR can be a
directory they picked in the GUI, so deleting install.sh there could take a file
that was never ours. Both hooks now only act where our own executable already is.

A signal between the two renames restored a predecessor but did nothing when
there was none, leaving a 0.12.1 uv beside whatever uvx the machine had. It now
removes what it published, which is what the ordinary rollback already does.

* Write the uv PATH entry to every startup file astral's installer wired

astral's uv installer wires ~/.profile, each of .bashrc, .bash_profile and
.bash_login that exists, .zshrc or .zshenv under ZDOTDIR, and a fish drop-in
under ~/.config. Replacing that installer with a pinned archive meant the PATH
entry only reached the one file for whichever shell happened to be running, so a
bash user whose .bash_profile does not source .bashrc, a /bin/sh login, or anyone
who later switched shells would have no uv on PATH where they used to.

Both POSIX installers now write the same set, once each, with the existing
whole-entry check keeping a re-run idempotent. Files that do not exist are not
created, apart from ~/.profile, which astral creates too.

* Cut the uv publish back to what the common case needs

The rollback machinery that grew over the review rounds covered cases a user is
very unlikely to meet: an incumbent that cannot be hard-linked, a signal landing
between two renames, a restore that itself fails, a second installer racing the
first. It was 281 net lines, and every finding in the last two rounds was in it
rather than in the hardening.

What stays is what the common case needs. POSIX stages both binaries, runs the
staged uv, and publishes the pair with two renames; a failure anywhere before
them leaves the destination untouched, and the caller falls back to astral's
installer exactly as before. Windows probes the extracted uv.exe before touching
the destination, then copies the three under -ErrorAction Stop and re-checks the
digest at the destination.

The staging files are still removed on a signal, since they live in a directory
that is on PATH. 64 shell checks and 114 installer tests cover the rest.

* Match the exact fish entry, and let a UNC launcher load

The fish drop-in is the only thing that puts uv on a fish user's PATH, since fish
reads none of the POSIX files, and its check treated any occurrence of the
directory as proof: /opt/uv-old suppressed /opt/uv. It now matches the exact
fish_add_path line it would write.

A launcher on a UNC share is a remote script to PowerShell, and RemoteSigned
refuses an unsigned one, so a roaming profile got a shortcut that exits without
starting Studio. That case, and only that case, uses Bypass, and drops
-WindowStyle Hidden with it so the pair the detections key on never appears.

* Wire every startup file on a DEFAULT install too, and give setup.ps1 a fallback

The all-profile PATH write was gated on the uv destination differing from
~/.local/bin, which is exactly where a normal install puts it, so every ordinary
machine still got the single-file write the shim path has always done. Three
independent audits found this. The gate is gone, and the idempotency check now
also matches the $HOME-relative spelling the shim block writes, so the default
case does not end up with two lines for one directory.

studio/setup.ps1 replaced astral's installer with the pinned archive and had
nothing to fall back to. A failed pinned install therefore left UseUv false and
silently ran torch, bitsandbytes, Triton and the rest through pip: a different
resolver, not just a different download. winget is the fallback, as install.ps1
already does, rather than the remote script this branch exists to remove.

* Make the quarantine case survive root inference, PATH and the reset hint

Three more from review, all the same shape: a Windows path that still treats
the generated unsloth.exe as the only evidence of an install.

* Root inference. _looks_like_installer_managed_studio_home accepted
  share/studio.conf or bin\unsloth.exe, and only install.sh writes studio.conf,
  so on a custom-root Windows install the quarantinable launcher was the only
  sentinel there was. Once antivirus took it, STUDIO_HOME fell back to
  ~/.unsloth/studio and every studio subcommand read and wrote the wrong tree
  while reporting success. bin\unsloth.cmd now counts, validated against the
  same marker pair and 8 KB ceiling Test-UnslothCmdShimFile and the
  uninstaller's recursive-delete guard use, because this decides which
  installation the CLI manages and the directory is on PATH.

* The PATH gate took any leaf named unsloth.cmd as a usable launcher.
  Write-UnslothCmdShim warns and leaves an unwritable file alone, so a foreign
  shim in a custom root survives the run, and counting it put its directory on
  PATH and advertised someone else's command as the policy-safe way in. It goes
  through Test-UnslothCmdShimFile now.

* The reset-password hint always advertised `-I -m unsloth_cli` on Windows. -I
  implies -s, so a pip install --user install was handed a command that cannot
  find its own package, and the person reading it is by definition already
  locked out. It now checks whether the package is inside the interpreter's
  prefix and otherwise prints the bootstrap unsloth_cli/__main__.py documents
  for exactly this case, which carries no double quote and so wraps identically
  for cmd and PowerShell.

Tests: root inference through a validated .cmd with four rejected impostors, an
oversized shim, POSIX unchanged; the PATH gate as a source contract; and a new
studio/backend/tests/test_reset_password_command.py covering both interpreter
shapes, the spaced-path fallback, the prefix check, and drift between the
bootstrap here and _WINDOWS_CLI_ENTRYPOINT.

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-13 07:54:51 -07:00
Daniel Han
5a5bf64130
Reduce antivirus false positives in the desktop installers (#8586)
* Windows setup: install uv from a pinned release instead of running remote script text

studio/setup.ps1 piped astral's install.ps1 straight into Invoke-Expression. That
download-and-execute shape is the single construct AMSI providers and cloud ML
scanners score hardest, and install.ps1 already replaced it with a pinned-SHA-256
archive download. Port the same implementation across.

Progress goes to the pipeline rather than the console, so the quiet path swallows
it exactly as it swallowed astral's installer output and the printed lines around
the call site are unchanged.

* Windows: stop pairing a hidden window with a bypassed execution policy

The Studio shortcut launched launch-studio.ps1 with -WindowStyle Hidden and
-ExecutionPolicy Bypass on the same command line. That pair is what Microsoft's
own detections key on, and studio/src-tauri/src/install.rs already refuses it for
the app's own launch of install.ps1.

The installer writes launch-studio.ps1 itself, so the file carries no
mark-of-the-web and RemoteSigned loads it. The hidden window is unchanged, so the
shortcut behaves exactly as before. The generated launcher's own child launch
moves to RemoteSigned for the same reason: it runs an inline -Command against an
executable, where no script file is loaded and the two policies are equivalent.

Also refresh a stale comment in studio/setup.ps1 that attributed the PSModulePath
fix to astral's uv installer, which no longer runs in-process.

* Installers: keep download-and-run command lines out of the shipped script text

AMSI scans install.ps1 in full before a single line of it runs, and generic
script classifiers read install.sh the same way inside the Linux bundle. Both
headers rehearsed the piped web one-liner five times over, plus a scriptblock
form and an execution-policy bypass, none of which anything in the scripts reads
and all of which the README already documents.

Point at the README instead and reword the in-body comments that quoted the
one-liner as shorthand. Every printed line is untouched: the remediation text the
installers show users still spells out the command in full.

Same treatment for scripts/uninstall.ps1's header.

* Windows: resolve process image paths with one Win32_Process query

install.ps1's venv-holder probe opened a handle to every running PID through
inline C# compiled at runtime. Opening a handle per process is a shape AV
heuristics score hard, and it bought nothing: Win32_Process reports
ExecutablePath for exactly the processes those handles could be opened against,
and answers for all of them in a single query instead of once per PID.

The remaining file-canonicalisation imports stay -- handle-based resolution of
linked ancestors has no faithful Windows PowerShell 5.1 equivalent, and it runs
on security-relevant paths.

Falls back to the per-process .Path when the query is unavailable, so a degraded
WMI repository degrades exactly as the old code did on a process it could not
open.

* Desktop: say who blocked the install when AMSI stops the script

PowerShell hands the whole top-level script block to AMSI while compiling it, so
a security product's verdict arrives as a parse error over the entire file before
install.ps1 runs a statement: no [TAURI:ERROR] marker, no phase log, and a stderr
tail the user cannot act on. unsloth#8523 shows what that looks like in the UI --
"Installation failed: + FullyQualifiedErrorId : ScriptContainedMaliciousContent".

Recognise the two stable error ids on either stream and append what the user
actually needs: nothing was installed, nothing was changed, it is a false
positive, update definitions and retry, do not turn off endpoint protection. The
raw id stays in the message, because the diagnostics report and any vendor
submission both need it.

Matches the id, never the message text, which is localized, and tolerates the
cmdlet suffix the Invoke-Expression form carries.

* Desktop: ship each bundle only the installer it can run

resolve_install_script picks install.sh on unix and install.ps1 everywhere else,
but the shared Tauri config bundled both into every target. The Linux AppImage
therefore carried 280 KB of Windows PowerShell it can never execute -- and it is
the largest script body a generic classifier walking the squashfs reads, which is
where Microsoft's Trojan:Script/Wacatac.B!ml verdict on 0.1.701-beta landed.

Move the resource map into the per-platform configs. The clean-machine job
already fails when a Linux bundle ships no install.sh; it now also fails when one
ships install.ps1, so the split cannot silently regress in either direction.

The .deb scanned clean with the same payload, so this is surface reduction rather
than a proven fix for that verdict.

* POSIX installers: install uv from a pinned release before falling back

install.sh downloaded astral's install.sh to a temp file, ran it and deleted the
file; studio/setup.sh piped it straight into a shell. Both are, shape for shape,
what a dropper does, and generic ML script classifiers score them accordingly --
the 0.1.701-beta Linux AppImage came back Trojan:Script/Wacatac.B!ml while the
.deb carrying the same scripts came back clean.

Fetch the pinned release archive and verify a hardcoded SHA-256 instead, matching
what install.ps1 already does on Windows. Only the four mainstream targets are
pinned: musl, armv7 and any host without a digest tool keep the path they have
today, because guessing a target triple wrong would break the install outright
and that costs far more than the heuristic score of the fallback.

Destination, PATH handling and every printed line are unchanged, so a host that
takes either path ends up in the same state it did before.

* tests: pin the installer shapes antivirus heuristics score

One file collecting what was removed, so it cannot drift back: no remote script
run in-process, no encoded or base64 payload, no hidden window paired with a
bypassed execution policy, no handle opened against another process, and no new
runtime-compiled native import outside an allowlist that carries a reason for
each entry that stays.

The last test is the other half of the contract. Hardening must not change what a
user sees, so the remediation lines the installers print -- which still spell out
the web one-liner in full -- are asserted verbatim. Removing the one-liner from
comments is the point; removing it from what the user is told to run would be a
regression.

Runs on the existing discovery-based pytest step, no workflow list to update.

* release: emit a false-positive submission packet for whatever gets flagged

The build job assembles a Microsoft submission packet, but only for the Windows
-setup.exe. The detection that actually arrived on 0.1.701-beta was
Trojan:Script/Wacatac.B!ml on the Linux AppImage, so nothing was produced for the
one asset that needed it.

The VirusTotal job already knows which assets were flagged and by which engines,
so put the packet there: hash, size and both portals, for every flagged asset
whatever platform it came from, with a note that clearance is per hash and per
vendor. Engine names are not repeated -- they are third-party text and already
appear escaped under Flagging engines.

The gate stays advisory; this only makes acting on it take seconds.

* Revert "Windows: resolve process image paths with one Win32_Process query"

This reverts commit 7897865c9.

tests/python/test_windows_installer_concurrency_guard.py bans Get-CimInstance
and $process.Path from Get-RunningStudioVenvProcesses outright, and requires the
native image-path lookup. That contract came out of #7764, which closed a set of
races where the installer inferred "in use" from something other than a confirmed
executable identity and blocked installs that should have proceeded.

Win32_Process.ExecutablePath does answer the same question, but a wrongly blocked
install costs far more than the heuristic weight of three native imports. Record
the imports in the AV-shapes allowlist with that reasoning instead, and keep the
ban on the process-memory APIs, which the installer has no use for.

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

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

* Tighten the comments added by this branch

Opening comment-reduction pass over the PR diff: same intent, fewer lines. Cut
hardest on the prose that restated the PR description rather than explaining the
code next to it. Comments and docstrings only, verified with comment_tools.py
check --strip-docstrings across every Python file in the diff.

* Drop an unused helper from the uv pinned-release test

* Fix three review findings on the installer hardening

Stray-resource check aborted the step it was meant to assert. grep exits 1 when
it selects nothing, and under this step's set -o pipefail plus the runner's
bash -e that kills the assignment outright, so every correctly split .deb failed
clean-machine CI before reaching the check. Both lookups take || true now: no
match is the passing case for the stray one, and for install.sh it was swallowing
the explicit annotation in favour of a bare exit 1.

studio/setup.sh skipped astral's XDG_DATA_HOME/../bin destination tier, which
install.sh, install.ps1 and studio/setup.ps1 all honour. A host that configured
an XDG location got uv under ~/.local/bin instead, where no later shell looks for
it. The session PATH prepend hid it at install time.

The AMSI guidance claimed nothing was changed even when the block landed on the
nested studio/setup.ps1, which install.ps1 launches through the same inherited
pipes after the venv, PyTorch and the packages are already on disk. Split the
wording on whether a [TAURI:STEP] marker has been seen: a pre-start block
produces none, so the reassurance is only given where it is true.

* Key the submission packet on the flagged count, not the engine list

stats and results are separate fields of the same VirusTotal response, so an
asset can carry a flagged count with no readable results map. The summary table
reports that asset and the packet skipped it, which is exactly the one that needs
a packet. Select on stats.flagged and keep the engine list for the Flagging
engines section, which is correctly keyed on having engines to name.

* Drop the bundle stray-resource assertion from clean-machine CI

That job downloads a published release, never a bundle built from the branch, so
asserting the new resource split there turns every run red until a release ships
with it. The split is a property of the Tauri config, and
tests/studio/test_tauri_installer_resource_contract.py already enforces it at the
right layer.

The || true on the install.sh lookup stays: it is what lets the explicit
annotation print instead of the step dying on grep's exit 1 under pipefail.

* Cover the uv host matrix and repeat application in the pinned-release test

The pinned path picks an archive per host triple, and a wrong pick installs a
binary that cannot execute, which is worse than not installing at all. Drive
_uv_pinned_asset over 20 host combinations and require each one to return its
own triple or decline to the fallback.

Also run the installer three times over one HOME and require an identical tree,
and require a stale uv at the destination to be replaced rather than joined by a
second copy: the installer is re-run on every upgrade and every repair.

* Pick the pinned uv archive off a positive libc check, not the absence of musl

An independent audit pass found the Linux selector accepts any host whose ldd
output does not say musl. That is not the same question astral's installer asks:
it checks a minimum glibc and drops to its musl-static archive below it, so
three hosts that worked before this branch now get a GNU binary that cannot exec,
and the helper reports success so the fallback never runs.

  aarch64 with glibc below 2.28 (Ubuntu 18.04)
  x86_64 with glibc below 2.17 (RHEL 6)
  a musl image with no ldd at all, where the probe simply finds nothing

Read the version instead, from ldd or getconf, and require it to clear astral's
floor for the triple. Anything unreadable declines to the fallback. Also ask the
userland for its bitness rather than trusting uname on a 64-bit kernel running a
32-bit userland, and follow astral in reading hw.optional.arm64 so a translated
shell under Rosetta 2 still gets the native macOS build.

Three more from the same pass:

Report success only when the destination uv is executable. A copy onto a busy or
read-only destination could leave a file that is not, and reporting success there
skipped the fallback. Nothing is unwound on the failure path on purpose: the
fallback installs over whatever is at the destination, and deleting there would
take out a working uv the host already had.

Clear the mark of the web on the launcher we author. WriteAllText replaces the
unnamed data stream and leaves other NTFS streams alone, so a launch-studio.ps1
that somehow carried one would keep it across the rewrite, and RemoteSigned
refuses a marked unsigned script.

Store the security-block kind and resolve its wording in message(). stdout and
stderr are read by independent threads, so a [TAURI:STEP] written before a block
can be observed after it, and freezing the wording at observation time could tell
a user nothing was changed on a run that had already installed PyTorch. Also
require the error id to appear as the value of a FullyQualifiedErrorId field, so
a scanner log that merely names it cannot attach antivirus guidance to whatever
fails next.

The host matrix in the shell test grows to 28 rows covering every case above, and
removing the new gate fails 8 of them. install.rs gains two tests: 37 pass.

* Replace a symlinked uv destination instead of writing through it

Three from the review on the previous head.

cp onto a destination that is a symlink follows the link, so installing over
`~/.local/bin/uv -> /opt/homebrew/bin/uv` rewrote the Homebrew binary in place
and left the link pointing at a file another package manager owns. Stage next to
the destination and rename over it: rename replaces the link itself, and it is
atomic, so a concurrent reader never sees a half-written uv either. The staging
file is removed when the rename fails, so a failed run leaves no debris.

Verify the Windows copy the same way the shell scripts now do. Copy-Item is
non-terminating under the caller's ErrorActionPreference, so a locked or
ACL-denied destination let execution reach `$haveUv = $true` and the function
reported success over whatever was already there. Compare the destination against
the archive we just verified, so a stale uv.exe cannot pass for the one we meant
to install. install.ps1 carried the same shape and gets the same treatment.

Point the header links at the heading that exists. The README has no "Install
Unsloth Studio"; it is "Unsloth Studio (web UI)", whose anchor is
#unsloth-studio-web-ui.

Three test cases cover the symlink: the file behind the link is untouched, the
link itself is replaced, and no staging file survives. Reverting the fix fails
two of them.

* Fail the build when the uv pin drifts from a version floor

Before the pin, astral's endpoint always delivered the newest uv, so raising
UV_MIN_VERSION was safe on its own. It is not any more: a floor above the pin
means a host with no uv gets 0.12.1 installed and then judged too old by the same
script that installed it, on the one path where the pin is what runs.

Two checks. All four installers must name the same uv, or which version a machine
ends up with depends on which script reached it first. And the pin must clear
every floor in the tree (UV_MIN_VERSION, UV_OFFLINE_MIN_VERSION, $UvMinVersion).
Raising a floor past the pin fails the first, bumping one installer's pin alone
fails the second.

* Write the shell profile entry the pinned uv path no longer gets for free

The P1 here is a real regression and it took a second look to see why.

install.sh decides whether to add ~/.local/bin to the user's shell profile with
`case ":$PATH:"`, near the end of the run. By then this process has prepended
that directory twice, once for the uv bootstrap and once for the venv, so the
guard answers yes for a login shell that would answer no and the profile line is
never written. That was survivable while astral's installer ran, because it wrote
its own profile line and its env file. The pinned path writes neither, so on a
fresh account whose login PATH lacks ~/.local/bin the install succeeds, the
current shell works, and the next terminal cannot find `unsloth` or `uv`.

Snapshot the inherited PATH before anything prepends to it and test the guard
against that.

Two more from the same review.

Honour a configured uv mirror exclusively. UV_INSTALLER_GHE_BASE_URL and
UV_INSTALLER_GITHUB_BASE_URL already win outright in both PowerShell installers
and in astral's own; the shell path ignored them and tried the public hosts
first. A restricted network sets one precisely because those hosts are
unreachable, and download() has no timeout, so it would hang rather than reach
the fallback.

Do not let the twin of an earlier clear erase a later AMSI verdict.
Clear-TauriInstallError writes one logical clear to BOTH streams
(install.ps1:198) and independent threads read them, so a block observed between
a clear and its own twin was discarded by the twin. Ignore a clear identical to
the one just processed; a genuine later recovery carries different text and still
clears. Two tests cover both directions, 39 install tests pass.

* Stage the uv copy under a per-process name

An audit pass reproduced a race I introduced with the symlink fix. Both POSIX
helpers staged through a fixed destination-side name, so two installers targeting
one directory shared it:

  A finishes copying the staging file
  B opens the same path with truncation
  A renames that inode into place as uv
  B keeps writing through its open descriptor, which is now the published uv

The published uv was observable at zero bytes until B resumed, which makes the
claim in the comment about a concurrent reader flatly wrong. install.ps1 is
covered by its named mutex, but nothing serialises the POSIX helpers, and
studio/setup.sh runs standalone on every studio update.

mktemp in the destination directory instead. Each rename then publishes a file no
other process can still be writing, which is what the atomicity argument needed
all along. The loser cleans up its own staging file and declines, so the caller
falls back rather than reporting a success it did not achieve.

* Keep a default install as quiet as it was when a uv mirror misbehaves

Two console regressions from the audit pass, both on paths the install still
recovers from.

download() runs curl -LsSf, and -S deliberately prints its own errors. The
fallback ran under run_maybe_quiet, so a failed download printed nothing before;
the pinned attempts run outside that wrapper, so an unreachable mirror now put
two curl: (N) lines on the console of a default install that then succeeded.
Redirect stderr on the speculative attempts only, leaving download() untouched
for every other caller.

[TAURI:WARN] is a marker level install.sh has never emitted, and the app forwards
unknown markers to its progress UI verbatim (install.rs:639), so a digest
mismatch would have surfaced as raw text in the desktop window. Make it a verbose
only stderr line: the next mirror or the fallback still runs, so a default
install has nothing to say here.

Printed-string diff against the merge base is back to additions inside $(...)
capture plus that one verbose-gated line, with nothing removed or changed.

* Ask the installed uv whether it runs before skipping the fallback

The libc gate reads a glibc version from ldd or getconf and treats that as proof
a GNU binary will execute. It is not. A stripped NixOS-derived image without
nix-ld reports a glibc version through getconf while its loader lives in the Nix
store, so the pinned x86_64 uv asks for /lib64/ld-linux-x86-64.so.2 and gets
nothing. Every static check passed, so the helper reported success, the astral
fallback was skipped, and the first real uv call failed with No such file or
directory. astral's installer fails its own glibc probe on that host and ships
the fully static musl archive, which runs. The user went from a working uv to
none.

The archive is digest-verified astral uv by the time it is placed, so ask it:
run --version and require it to succeed. One exec closes the whole class rather
than this one host, covering a wrong triple, a loader that is not where the
binary looks, and a destination we could not really write.

A test drives an archive whose uv cannot execute and requires the helper to
decline; removing the exec check fails it.

* Pair every clear with its twin, not just the previous one

install.ps1 clears after each recovered step, so a lagging reader can be several
clears behind when a block lands. With clears A then B on one stream and A's twin
arriving on the other after the verdict, asking only whether this is the message
just seen answers no, and the delayed twin discarded the verdict the guidance
exists to explain.

Each logical clear emits exactly two markers, so count unpaired ones by message:
the first sighting is the clear, the next pairs with it. A test drives the A, B,
verdict, A', B' ordering; 40 install tests pass.

* Validate the staged uv before it replaces a working one

My own exec check was on the wrong side of the rename. The sequence that bites:
a host has a uv good enough for UV_OFFLINE_MIN_VERSION but below UV_MIN_VERSION,
so the block runs with _uv_present_before true; the pinned path renames over that
working binary; the --version check then fails because the loader is missing or
the destination is mounted noexec; the fallback download also fails. The
installer neither restores the old uv nor reports that none is available, and
every later command runs the broken one.

Test the staging file instead, before the rename. It sits on the destination
filesystem, so it answers the noexec question too, and a binary that cannot run
here never gets to replace one that could.

Two tests: a working incumbent uv survives an archive whose uv cannot execute,
and the rejected staging file is cleaned up. Moving the check back after the
rename fails the first.

* Stop the AMSI guidance claiming more than it knows

Two of these are honesty defects in text a blocked user reads.

"nothing was changed on this machine" is false. Rust starts a diagnostics
attempt and its phase log before PowerShell is ever spawned, and spawn_script can
create ~/.unsloth first, so a pre-start block has already written to disk. The
honest claim is that no installation step ran.

"This is a false positive" is not something the classifier can know. It proves
the output carries a PowerShell error id and nothing about the script's
integrity, and install.ps1 can sit in a user-writable directory, so a locally
modified copy can earn a genuine verdict. Telling someone to report a correct
detection to their vendor is worse than telling them to reinstall from an
official package first and only escalate if an unmodified copy is still blocked.

Two smaller ones from the same pass. The matcher tested for the field name and
the id independently, so a line naming both in prose qualified; it now requires
the id to follow the colon and end at a comma or whitespace, which is what the
comment always claimed. And the clear-pairing map is bounded: legitimate
producers use a small fixed label set, and child output must not be able to grow
it without limit.

42 install tests pass.

* Honour astral's download override, and stop Unblock-File asking

Three from the second audit round.

Unblock-File declares SupportsShouldProcess at the default Medium impact, so a
profile that sets $ConfirmPreference to Medium or Low gets a prompt from the line
I added, even for a launcher that never carried the stream. -ErrorAction does not
suppress a ShouldProcess prompt, and a noninteractive host turns it into an error
that skips shortcut setup entirely. -Confirm:$false.

UV_DOWNLOAD_URL and its older alias INSTALLER_DOWNLOAD_URL outrank the mirror
variables in astral's installer, and the merge-base path inherited that because it
ran astral's script. All four implementations now honour them first and
exclusively. My earlier comment argued they point at a version the pin would
reject, but that reasoning had it backwards: a host sets one because it cannot
reach the public endpoints, so ignoring it meant public egress first and, with no
timeout on the download, a hang instead of a fallback. The pin still applies, so a
source serving a different build fails the digest and the caller falls back to
astral's installer, which honours the same variable.

chmod 0755 on the staging file rather than +x. cp gives it the umask default and
+x then adds execute only where the umask allowed read, so a umask of 077 left uv
unusable for every other account on a shared machine. astral ships them 0755.

Four checks pin the override precedence across all four installers and the mode
across both shell ones, with the behaviour verified against a stubbed downloader.

* Validate uv before it replaces an incumbent on Windows, and bound the probe

install.ps1 and studio/setup.ps1 copied the extracted uv.exe straight over the
destination and only asked whether it ran afterwards. A host with a working older
uv and a policy (AppLocker, WDAC, endpoint protection) that refuses the new one
was left with neither. Run the extracted binary where it landed first, then keep
a copy of the incumbent across the publish and restore it if the published copy
will not run, since Windows has no atomic replace for a file that may be open.

The probe itself is bounded: Start-Process with a 20s WaitForExit and redirected
streams, and on POSIX no stdin plus a 20s ceiling where timeout exists. A binary
this installer just downloaded must not be able to hang an unattended install by
prompting or by never exiting.

install.sh and studio/setup.sh also published the pinned uvx after rejecting the
pinned uv, leaving a pairing that is never built or tested. A uv that fails to
stage, copy or run now abandons the whole placement.

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

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

* Verify each uv mirror, and persist PATH when the account has no rc file

Both PowerShell installers checked the archive digest once, after the download
loop had already broken out. A captive portal or a proxy answering 200 with its
own body is a successful download by every measure Invoke-WebRequest has, so the
first mirror consumed the only attempt and the second, healthy one was never
tried. The digest now decides whether a mirror counts as served.

install.sh picked a shell profile from .zshrc, .bashrc or .profile and did
nothing when none existed. A fresh account has none: astral's installer used to
create its own PATH setup there, the pinned path does not, so the next terminal
resolved neither unsloth nor uv. Fall back to creating ~/.profile, which every
POSIX login shell reads. The existing content guard keeps it written once.

* Remove the install.sh a Windows upgrade would otherwise keep forever

Windows bundles now carry only install.ps1, but NSIS writes the current resource
manifest and deletes nothing, and the uninstaller deletes only what is in that
manifest. An in-place upgrade from a release that bundled both installers left
install.sh in $INSTDIR permanently, which also made the non-recursive
RMDir "$INSTDIR" fail at uninstall. The pre-install and pre-uninstall hooks now
delete it, so the population most likely to upgrade actually gets the split.

Also silence the speculative mktemp -d in the pinned uv path: its failure falls
back to astral's installer, so an unusable TMPDIR printed a line the user could
not act on and that the merge base did not print.

* Remove the pinned uv temporaries when an install is interrupted

The pinned path unpacks a 40 MB archive into a work directory and stages the
binary next to the destination, but only cleaned both up when the helper returned
normally. A Ctrl-C in between left the archive behind and left a staging file
inside a directory that is on PATH. Both paths are now published to the exit and
signal traps as they are created and cleared when the helper releases them.

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

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

* Persist the PATH the way each shell actually reads it, and fail a half-published pair

Four follow-ups from review:

A uvx that the archive carried but that could not be staged or renamed left uv
published next to a stale or missing uvx and still reported success, skipping the
fallback that would have installed both. Either half failing now fails the
placement, in install.sh and studio/setup.sh.

studio/setup.sh had none of the interrupt cleanup install.sh gained: a Ctrl-C
left the unpacked archive behind and a staging file inside a directory on PATH.
It now owns HUP, INT, TERM and EXIT for the duration of the pinned install and
hands them back on the way out.

fish sources none of the POSIX rc files, so the ~/.profile fallback was a no-op
for a fish user. The persistence helper writes a conf.d drop-in with
fish_add_path there, and honours ZDOTDIR for zsh.

UV_INSTALL_DIR, UV_UNMANAGED_INSTALL, XDG_BIN_HOME and XDG_DATA_HOME can put uv
somewhere other than ~/.local/bin, and astral's installer wrote a PATH line for
whichever it picked. The pinned path now persists its own destination too, with
UV_NO_MODIFY_PATH honoured as astral honours it.

* Make the Windows uv publish a real transaction, and quote persisted paths

The companion copies ran bare: under install.ps1's Stop preference a locked or
ACL-denied destination threw past the rollback and left a mismatched set with the
backups still on disk, and under setup.ps1's Continue preference it kept a stale
companion and reported success. Both now copy under -ErrorAction Stop inside the
transaction, so any failure unwinds like the others.

A failed restore also used to delete the backup anyway, which is the one path in
this block that could leave the host with less than it started with: the two
things that make a restore fail, an open incumbent and a denied ACL, are the same
two that made the replace risky. The backup is now kept and named.

fish takes an unquoted path with a space as two directories, neither of which
exists, so the drop-in single-quotes it; and the rc line is written inside double
quotes, so a uv directory holding a dollar or a backtick is escaped. The second
test caught a doubled backslash in the escaper itself.

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

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

* Tighten the comments added by this branch

Comments only, no code touched: 161 comment lines become 106 across install.sh,
install.ps1, studio/setup.sh, studio/setup.ps1 and the NSIS hooks. Each one keeps
the reason it was written for, said once.

Verified with the PowerShell AST parser, sh -n and bash -n, the 50-check uv
pinned release suite and 114 installer tests, and by confirming the diff contains
no non-comment line.

* Tighten the install.rs comments too

Comments only: 35 lines become 27, each keeping the reason it was written for.
42 install tests pass and the diff contains no non-comment line.

* Abort on a companion that cannot be backed up, and pair clears by stream

A uvx.exe that could not be copied aside, because it is locked or its ACL denies
reads, was skipped and the new uv.exe published anyway, so the function reported
success with a mismatched pair and the fallback never ran. Any backup failure now
fails the placement and runs the rollback, in install.ps1 and studio/setup.ps1.

The ERROR_CLEAR pairing keyed only on the message, so two real clears of one label
on one stream were taken for a clear and its twin. That happens:
_install_torch_default_index emits its recovery during the install and again
during the ROCm repair. A verdict landing between them was then erased by the
genuinely later clear arriving on the other stream. The map is keyed by stream as
well, so only the opposite stream's copy can consume a pending marker.

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

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

* Do not fail an install because the uv probe could not get an answer

Three clean-machine CI legs that pass on main failed on this branch: arm64 and
two Windows containers, all three with winget unavailable, which is the only
condition under which the pinned fallback runs. Each downloaded the right asset,
passed the digest, and then failed the probe. Start-Process -NoNewWindow with
redirected streams does not behave in a container or on the arm64 image the way
it does in a desktop session, and a boolean probe reported that as a broken
binary and aborted the install.

The probe is now tri-state. Only the binary answering non-zero is a failure. A
launch that throws or a wait that times out is inconclusive, and since the digest
already proved the bytes are astral's pinned release, an inconclusive probe
publishes as the pre-pin code did. Every path prints why, with the captured
stderr and the exit code, so the next occurrence is not opaque.

Also from review: the POSIX path now stages both binaries and publishes them
together with the incumbents saved aside, so a failed uvx rename restores the
uv it replaced instead of leaving a new uv beside a stale uvx; the Windows
rollback records the destination before the copy that can truncate it; and
UV_UNMANAGED_INSTALL suppresses the profile write, as it does for astral.

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

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

* Give setup.sh the same pair publish and PATH persistence as install.sh

studio/setup.sh published uv and then uvx one after the other, so a failed uvx
rename left a new uv beside the host's stale one, and the remote fallback can be
unavailable. It now stages both, validates uv, and publishes the two renames back
to back with the incumbents saved aside, restoring them if the second fails.

setup.sh is also run directly for local and Colab setup, where astral's installer
used to write the profile line for whichever destination it chose. Without one the
PATH export died with that shell and every later run reinstalled uv. It now
persists its own destination, with fish handled on its own terms and both of
astral's opt-outs honoured.

* Treat an empty uv exit code as no verdict, not as a failure

The arm64 clean-machine leg still failed on the tri-state probe, and the
diagnostic that came with it said why: "uv --version exited ." with no number.
WaitForExit(ms) can return before the exit code is cached, so ExitCode was empty
and an empty value is not 0, which read a working uv as broken.

The parameterless WaitForExit settles it and returns at once because the process
has already exited, and a code that is still missing is inconclusive rather than
a failure, which is the same rule the launch and timeout paths already follow.
Verified against pwsh that a real non-zero exit and a real launch failure still
classify as failed and unknown respectively.

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

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

* Eight review fixes across the uv publish and PATH persistence

The fish escaper in studio/setup.sh reached sed as an invalid expression, so a
fish user running setup directly would have had setup killed under set -e right
after uv was published. It now matches the one in install.sh, and the test runs
both escapers rather than reading them.

An incumbent that cannot be hard-linked or copied cannot be restored either, so
publishing over it would be a one-way move. Both shells now decline. Writing that
test turned up that my own rollback deleted both incumbents when nothing had been
published, since the no-predecessor branch cannot tell the two cases apart; the
rollback is now reached only after a publish was attempted.

A rollback with no predecessor removes the binary it published, rather than
leaving half a pair the host never had.

A signal between the two renames left the undo copy as the only reference to the
incumbent, and the handler deleted it. It restores it now, in both shells.

setup.sh prepended ~/.local/bin unconditionally after a successful pinned
install, so a stale uv there could shadow a custom UV_INSTALL_DIR destination and
the rest of setup would run the wrong one. That prepend is now only for astral's
installer, which is what writes there.

PATH entries are compared literally rather than as case patterns, so a
destination holding *, ? or [ is not mistaken for an unrelated entry.

On Windows, a .unsloth-old left behind by a failed restore is the only copy of a
working uv, and the next run reused that exact name. It takes a distinct one.

* Keep the pinned uv first on PATH, and only count an active profile entry

install.sh prepends ~/.local/bin after the uv bootstrap, and astral's env file
does too, so a custom UV_INSTALL_DIR destination was pushed behind a stale uv
sitting in the home directory and every bare uv below picked the wrong one. The
pinned destination goes back in front. setup.sh had the same shape and was fixed
in defd2292a.

The profile check treated any occurrence of the destination text as proof the
PATH entry was already there, so a commented-out old export, or /opt/uv-old when
the destination is /opt/uv, suppressed the write and left the next shell without
uv. Comments are stripped and the directory has to appear as a whole entry.

* Gate the NSIS tidy-up, and remove an orphan uv on signal

The pre-install hook runs before the user can still cancel, and $INSTDIR can be a
directory they picked in the GUI, so deleting install.sh there could take a file
that was never ours. Both hooks now only act where our own executable already is.

A signal between the two renames restored a predecessor but did nothing when
there was none, leaving a 0.12.1 uv beside whatever uvx the machine had. It now
removes what it published, which is what the ordinary rollback already does.

* Write the uv PATH entry to every startup file astral's installer wired

astral's uv installer wires ~/.profile, each of .bashrc, .bash_profile and
.bash_login that exists, .zshrc or .zshenv under ZDOTDIR, and a fish drop-in
under ~/.config. Replacing that installer with a pinned archive meant the PATH
entry only reached the one file for whichever shell happened to be running, so a
bash user whose .bash_profile does not source .bashrc, a /bin/sh login, or anyone
who later switched shells would have no uv on PATH where they used to.

Both POSIX installers now write the same set, once each, with the existing
whole-entry check keeping a re-run idempotent. Files that do not exist are not
created, apart from ~/.profile, which astral creates too.

* Cut the uv publish back to what the common case needs

The rollback machinery that grew over the review rounds covered cases a user is
very unlikely to meet: an incumbent that cannot be hard-linked, a signal landing
between two renames, a restore that itself fails, a second installer racing the
first. It was 281 net lines, and every finding in the last two rounds was in it
rather than in the hardening.

What stays is what the common case needs. POSIX stages both binaries, runs the
staged uv, and publishes the pair with two renames; a failure anywhere before
them leaves the destination untouched, and the caller falls back to astral's
installer exactly as before. Windows probes the extracted uv.exe before touching
the destination, then copies the three under -ErrorAction Stop and re-checks the
digest at the destination.

The staging files are still removed on a signal, since they live in a directory
that is on PATH. 64 shell checks and 114 installer tests cover the rest.

* Match the exact fish entry, and let a UNC launcher load

The fish drop-in is the only thing that puts uv on a fish user's PATH, since fish
reads none of the POSIX files, and its check treated any occurrence of the
directory as proof: /opt/uv-old suppressed /opt/uv. It now matches the exact
fish_add_path line it would write.

A launcher on a UNC share is a remote script to PowerShell, and RemoteSigned
refuses an unsigned one, so a roaming profile got a shortcut that exits without
starting Studio. That case, and only that case, uses Bypass, and drops
-WindowStyle Hidden with it so the pair the detections key on never appears.

* Wire every startup file on a DEFAULT install too, and give setup.ps1 a fallback

The all-profile PATH write was gated on the uv destination differing from
~/.local/bin, which is exactly where a normal install puts it, so every ordinary
machine still got the single-file write the shim path has always done. Three
independent audits found this. The gate is gone, and the idempotency check now
also matches the $HOME-relative spelling the shim block writes, so the default
case does not end up with two lines for one directory.

studio/setup.ps1 replaced astral's installer with the pinned archive and had
nothing to fall back to. A failed pinned install therefore left UseUv false and
silently ran torch, bitsandbytes, Triton and the rest through pip: a different
resolver, not just a different download. winget is the fallback, as install.ps1
already does, rather than the remote script this branch exists to remove.

* Read the pinned install's real result, and three narrower publish guards

The winget fallback I added last round read Invoke-SetupCommand's return value,
which is [int]$LASTEXITCODE rather than the function's $true, so it fired on
every run: a redundant managed install, and a second copy on a machine that asked
for UV_UNMANAGED_INSTALL. The function records its own success on the script
scope and the fallback reads that.

A directory named uv at the destination looked like a published binary: mv moves
into it and reports success, and a searchable directory passes -x, so the install
reported success and the first later uv call failed instead. Both shells refuse a
directory target, as the installer already does for its own shim.

The PATH idempotency pattern escaped only part of the ERE metacharacter set, so a
destination holding + ( or | did not match itself and every reinstall appended
another block to every profile.

* Restrict the profile duplicate check to PATH lines, and cover mapped drives for PR #8586

* Match only PATH-setting lines in the profile duplicate check for PR #8586

* Tighten the installer comments added by PR #8586

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-13 07:02:18 -07:00
Daniel Han
715535d1c5
Windows: start the backend from a usable folder on login autostart (#8575)
* Windows: start the backend from a usable folder on login autostart

"Run Unsloth at login" registers the desktop through an HKCU Run value,
which cannot carry a working directory, so Windows starts the app in
C:\Windows\system32. Every `unsloth` CLI child inherited that folder, and
the CLI refuses to run there, so a reboot produced a tray icon and no
server (issue #8510).

Desktop side: pick the working directory explicitly for every CLI child
(backend, both preflight probes, the install check, auth provisioning,
update, installer) instead of passing on whatever the launcher gave us.
The inherited folder is kept whenever it is usable, so ./models and other
cwd-relative defaults resolve exactly where they used to; only a Windows
system folder is replaced, with ~/.unsloth. A home that cannot be reached
at all now reports working_directory_unavailable rather than looking like
a broken install, which stops the pointless automatic repair and gets its
own message in the UI.

CLI side: move the System32 guard into unsloth_cli/_system_dir_guard.py
and run it before the command modules import, since commands.studio
resolves STUDIO_HOME at import time. The commands the desktop itself
issues take no path from the user, so they move to a safe folder and
carry on; everything else keeps the hard error, since relocating a
command would silently rebase the relative paths its caller typed. This
half fixes anyone whose installed desktop build predates the change,
without waiting for a desktop release.

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

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

* Only trust a Windows directory that holds System32, and tell an absent profile from an absent install

Two review findings on the working-directory work.

WINDIR is an ordinary variable, so pointing it at the user's own profile made
windows_roots() treat that profile as a Windows installation: an ordinary
project folder underneath it then looked like a system folder, the fallback
rejected the home for being "inside the Windows directory", and the backend
could not start anywhere on that machine. Candidates are now checked rather
than trusted, and a directory only counts if it actually contains System32.
With nothing on the machine looking like Windows, the check falls back to
SystemRoot or the default, never to the settable value. Same fix on both sides,
since the CLI guard reads the same variables.

The managed install lives under the user's profile, so a profile that is not
mounted yet makes find_unsloth_binary() return None and preflight reported
NotInstalled before the working-directory check could run. That is the exact
case the check was added for, and it was sending those users to reinstall.
Check whether the home is reachable before turning a failed lookup into
"not installed", and report it as its own state with no binary path and no
repair offered.

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

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

* Tighten the comments added by this PR

* Keep the preflight message choice testable

The branch that tells an unreachable profile from an outdated install sat inline
in use-tauri-backend.ts, which pulls in React and the Tauri APIs and so cannot be
imported from a test. Move the choice into its own module and drive it directly,
so a new backend reason cannot silently land in the stale-install bucket again.

* Pin relative path overrides before relocating out of a system folder

Studio resolves UNSLOTH_STUDIO_HOME and the cache overrides with Path.resolve(),
which anchors a relative value to the working directory. So
`UNSLOTH_STUDIO_HOME=.\custom unsloth studio update` from System32 moved first
and then resolved the override against the new directory, silently targeting a
folder the caller never named. That is the one thing this guard is supposed not
to do to caller-supplied paths.

Absolutise the relative overrides against the original directory before the
move, so they keep meaning what they meant. A ~ value is left alone, since
expanduser does not consult the working directory, and an environment that
cannot be pinned is one we refuse to move underneath.

Covers the Studio home pair plus the cache and llama.cpp overrides, which
resolve the same way: UNSLOTH_LLAMA_CPP_PATH, UNSLOTH_COMPILE_LOCATION, HF_HOME,
HF_HUB_CACHE, HUGGINGFACE_HUB_CACHE, HF_XET_CACHE.

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

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

* Pin every relative path override and withhold repair on an unreachable profile

- pin_relative_overrides() missed the Studio documents, projects and sandbox
  roots and the sd.cpp/whisper.cpp/llama.cpp engine paths, so a relative value
  would have been retargeted by the move, and the remaining single-path cache
  overrides are pinned for the same reason
- an owned or ownerless-spawned backend that is stale no longer offers auto
  repair when the managed profile is unreachable: the repair runs through the
  same profile and stops a backend that still answers

* Pin relative overrides on the desktop side too, and align the home checks

- the desktop moved a CLI child out of a system folder without rewriting the
  relative path overrides it inherited, so the same install placed state in a
  different folder depending on whether the desktop or the CLI guard did the
  move; both layers now anchor those values to the directory being left, with a
  test that fails if the two lists drift apart
- pin the diffusion cache dirs, OLLAMA_MODELS, DG_VISUAL_BIN and UNSLOTH_DG_SHIM,
  which are resolved against the working directory as well
- home_dir_available() accepted a home the working directory resolver then
  rejected, so a SYSTEM account was offered an install that cannot start; both
  now go through usable_home_dir()

* Report an unreachable profile as the reason a stale backend cannot repair

Withholding auto repair was not enough: the result still carried the backend's
own reason, so the frontend advised running the update, which needs the same
profile the probe could not reach. Both stale paths now report
working_directory_unavailable, and the backend's reason goes to the log.

* Resolve drive-relative overrides through the OS before moving

A value such as HF_HOME=D:cache names the current directory on drive D, so
joining it to the folder being left hands it straight back and the move
retargets it. Both layers now ask the OS to resolve it first, GetFullPathNameW
through ntpath.abspath and std::path::absolute, which is what tracks each
drive's own directory. The CLI guard refuses to move at all if that resolution
fails, rather than moving and silently changing where the value points.

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

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

* Pin the stable-diffusion binary overrides and the rest of the cache family

SD_CLI_PATH and SD_SERVER_PATH are the highest-priority binary locations for
the sd.cpp engine, so a relative value pointed somewhere else after the move.
Added alongside the llama.cpp and whisper.cpp equivalents, together with the
HF and XDG names that belong to the same families as the ones already pinned.

* Pin the GPU SDK roots, and do not relocate into a missing or shared profile

- CUDA_PATH, HIP_PATH, HIP_PATH_57 and ROCM_PATH are joined with bin/ for DLL
  discovery, so a relative value pointed elsewhere after the move
- a profile that has not mounted yet still has a writable parent, so makedirs
  built an empty second profile that would shadow the real one when it arrives;
  the guard now requires the home to exist, as the Rust resolver does
- the public profile is refused whichever variable named it: allow_public only
  kept PUBLIC out of the candidate list, so a USERPROFILE or ~ that resolves
  there still put one account's state in a folder shared by every account

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

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

* Tighten the comments added since the last pass

* Cover running the working-directory fix more than once

Both halves run repeatedly over state an earlier run touched: the desktop
resolves the directory on every spawn, the guard runs in every CLI process, and
a child's environment reaches its own grandchildren. Two tests pin the fixpoint,
one for a command configured twice and one for a value that has already been
anchored.

* Pin STUDIO_LOCAL_REPO, and blame a lost profile for the probe that failed

- "studio update" is one of the commands that relocates, and it resolves a
  relative STUDIO_LOCAL_REPO against the working directory, so the move
  retargeted the checkout the user meant to install from
- a profile can drop between the working directory check and the probes that
  follow, which reported cli_unusable or desktop_capability_probe_failed and
  offered a repair needing that same profile; both now ask again and report
  the profile when it is what went missing

* Narrow the desktop marker, and anchor the values a move could still retarget

Five independent reviews of the branch agreed on the same three gaps:

- the marker is inherited by the backend and everything below it, so treating
  it as authorisation for any "studio" subcommand let a marked descendant
  relocate "studio run --model .\local.gguf", rebasing a path the caller chose.
  It now authorises only invocations that carry no path, which is what the
  desktop actually runs
- a value like "\cache" is rooted to the drive of the current directory, not to
  a drive, so a profile on another drive moved it. Root-relative values now go
  through the OS with the drive-relative ones, and the extended prefix is
  matched case-insensitively, since the object manager accepts \\?\unc\ too
- four more single-path overrides are pinned, and two path lists are anchored
  entry by entry, so one relative entry cannot change what a whole search or
  allowlist means

The desktop also relocated a child out of any folder under the Windows tree,
while the CLI only ever refused System32 and SysWOW64. It now uses the same
definition, so a child running from somewhere like C:\Windows\Temp keeps the
directory it had.

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

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

* Offer the roaming-profile cause only where roaming profiles exist

A platform-isolation audit found this PR's working-directory-unavailable
message asserting a Windows cause on every platform:

  Unsloth cannot reach your user folder, so it has nowhere to run from.
  This usually means a network or roaming profile is not available yet.

The Rust half of this PR is deliberately not #[cfg]-gated; isolation comes
from windows_roots() returning empty off Windows, which does hold for every
env-var rewrite. But home_dir_available() is called ungated from
preflight/managed.rs, so ManagedProbe::Unavailable{working_directory_unavailable}
is reachable on Linux and macOS, where the same symptom means an unmounted
home or a permissions problem. A roaming profile is a Windows concept and
naming it there sends the reader looking for something that is not present.

The symptom and the remedy are unchanged everywhere. Only the CAUSE moves
behind a platform check, so it is offered where it applies instead of
asserted everywhere.

Test covers both directions: Windows still gets the sentence, Linux and
macOS do not, and both keep the symptom and "Reconnect and try again".

    studio/frontend/tests/backend-preflight-message.test.ts   4 passed

* Do not let the interpreter decide which folder a value names

The Windows cross-platform CI caught this: ntpath.isabs answered True for a
leading separator until Python 3.13 and False after it, so on 3.12 a
root-relative "\cache" was treated as fully qualified and left to move with the
working directory, which is the retargeting the pinning exists to prevent. Both
halves now spell the test out, a drive plus a separator or a UNC share, so the
same value names the same folder on every interpreter and on the Linux runner
that tests the Rust half.

* Run the update smoke tests when the CLI guard changes

The guard runs on every CLI invocation, "unsloth studio update" included, but
neither update workflow listed it, so the two suites that install Unsloth and
then update it twice never ran for this change.

* Do not rewrite an override the working directory never resolved

Three of the pinned names are read as something other than a path by the
code that consumes them, so anchoring one changed its meaning instead of
preserving it. MLX_HOSTFILE holds either a filename or the host list
itself, huggingface_hub expands %VAR% in HF_HOME and its neighbours after
the guard has run, and the pre-quant allowlist ignores a bare on/off
token precisely so that there is no allow-all mode: anchoring the "1"
would have turned it into a real allowlisted directory. All three are now
left alone, in the CLI guard and in the desktop twin.

Also in this pass:

- studio --frontend=.\dist carries a path inside the option token, which
  the marker gate missed because it only looked for a bare argument after
  the subcommand. A marked child running it is refused rather than moved.
- \Windows\System32\config\systemprofile names SYSTEM's profile without a
  drive, so it compared equal to no drive-qualified Windows root and was
  accepted as a home. The drive-less spelling of each Windows root is
  compared too.
- The child is only told where to run when that differs from where the
  parent already is. Reopening an inherited directory by name can fail if
  an ancestor turned unreadable after launch, where inheriting the open
  handle would have worked, so the no-move case stays exactly as it was.
- An override the OS declines to resolve now refuses the whole move on
  the desktop side rather than being dropped, which is what the CLI guard
  already did: moving with that value still relative would retarget it.
- The tests that read the ambient environment take the crate-wide env
  lock; XDG_DATA_HOME is one of the pinned names now, and the test that
  swaps it was documented as the only reader.

* Scope the non-path exemptions, and anchor the import roots too

Three follow-ups on the pinning:

The exemptions for inline JSON, %VAR% / $VAR and bare on/off tokens now
name the variables whose reader proves them, rather than applying to
every pinned name. A directory really called [llama] or %data% is legal
on Windows and UNSLOTH_LLAMA_CPP_PATH is read as exactly that, so the
blanket form left it unpinned and the move retargeted it.

PYTHONPATH joins the anchored search lists, and the guard anchors the
relative entries this interpreter is already carrying in sys.path: those
are resolved on every import, not at startup, so a move would let
whatever sits in ~/.unsloth shadow a managed import. sys.path is best
effort, unlike the environment: an import root is not worth refusing the
move over, and refusing is how the login start broke to begin with. PATH
is deliberately left out, being mostly other people's absolute entries.

Preflight asks for the whole managed context, not just the directory. An
override the OS declines to resolve fails the same spawn, and a probe
that returned false for it was read as a broken CLI, which started an
automatic repair that needed the same context and failed the same way.

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

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

* Expand a cache override before deciding, and read the real invocation

Three more from the review:

huggingface_hub expands %VAR% in HF_HOME and its neighbours, but Studio's
own hf_cache_settings._canonical() does not, so leaving such a value as
written sent the two readers to different folders once the process moved.
The guard now expands those names before deciding, writes the expanded
form back when expanding is what made it name a folder, and anchors it
when it does not. A name the machine does not set stays as written, which
is what expandvars does too. The desktop got the same, with a small %VAR%
expander since std has none.

The updater removes PYTHONPATH on Windows, and pinning an inherited
relative one put it straight back. The removal now happens after the
managed context is applied: -I only covers the first interpreter, and the
update's PowerShell and setup descendants start further Python processes
that do not clear it.

The Typer callback classified sys.argv even when the app was called as a
library, so a host whose own argv looked like a desktop command could
move the process out from under the caller's relative paths. It reads the
invocation Click is running instead, and where Click keeps the tail to
itself the invocation is refused rather than relocated. The console
script is unaffected: it is classified at import, from the real argv.

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

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

* Take the whole invocation, and the spellings that follow the process

Four more from the review:

os.path.expandvars takes %NAME%, $NAME and ${NAME} on a Windows path; the
desktop expander only took the first, so a value written $LOCALAPPDATA\hf
was anchored under System32 by the desktop while the CLI guard would have
kept it. All three forms now, with a test that walks each one.

The Typer group records the tokens it was handed. Click keeps the tail on
the child context, so the callback saw only the subcommand name and
refused a library `studio --api-only` that it should have relocated.
Reading the recorded list classifies the invocation in full, whether it
arrives through app(args = [...]) or a runner.

HF_TOKEN_PATH is pinned like the caches beside it: huggingface_hub reads
the credential file from there, and a relative value would follow the
child and lose access to gated repos while everything still looked
healthy.

PYTHONPATH has two spellings that follow the process rather than the
caller: an empty component means the working directory itself, and `~` is
never expanded there, so Python reads ~\plugins as an ordinary relative
folder. Both are anchored to the directory being left, in the environment,
in sys.path and in the desktop twin.

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

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

* Only anchor a sys.path entry that is a directory, and name the setting

Two findings from a fresh review round.

sys.path holds other people's strings as well as folders. setuptools
registers a relative sentinel for an editable namespace install and its
own path hook accepts that sentinel by exact equality, and a relative
.zip keeps the spelling its already imported packages hold in their
loaders; rewriting either breaks the import the pinning was meant to
protect. Both were reproduced: an editable namespace stopped importing
after the rewrite, and a package loaded from a relative archive lost its
submodules. Only an entry that is a directory right now is anchored, plus
the empty entry, which is the working directory by definition.

A value that cannot be pinned now says so. Windows caps an environment
variable at 32767 characters, so a long enough list can stop fitting once
every entry names its folder in full, and a drive with no current
directory of its own cannot be resolved either. Both were reported as
"check that the user profile for this account exists and is writable",
which sends the reader looking in the wrong place.

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

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

* Expand exactly as ntpath does, and say which context failed

The desktop expander read $NAME as far as the first non-word character,
where ntpath counts a hyphen as part of the name. With CACHE and
CACHE-ROOT both set, HF_HOME=$CACHE-ROOT\hf resolved to C:\right\hf in
the CLI and C:\wrong-ROOT\hf in the desktop: one install, two folders. It
now mirrors ntpath's pattern outright, including the single-quoted run
that is copied through unexpanded, %% and $$ standing for one character,
and anything unterminated staying as written. The guard's own tests call
os.path.expandvars against the environment they describe rather than a
stand-in that only knew %NAME%.

Preflight tells its two context failures apart. A probe that could not be
configured returns that instead of false, so a context which recovers
between the failed apply and the check afterwards can no longer make an
untested CLI look broken and start a repair. And an override the OS
cannot resolve is reported as path_setting_unresolvable rather than as an
unreachable user folder: the profile is fine, the value is not, and the
frontend now says so instead of advising a reconnect.

* Never panic on the spawn path, and leave a host's state as it was

Five more from the review.

The backend spawn unwrapped the managed context, so a drive that went
between the preflight check and the spawn took the desktop down with it.
It reports through the same diagnostics path as every other start failure
now.

UNSLOTH_STUDIO_HOME and STUDIO_HOME are removed for every managed child,
because Tauri uses the legacy root whatever the environment says. Trying
to resolve them could only invent a failure for a value the child never
sees, so they are skipped when pinning and removed by the context helper
itself rather than only at the call sites.

A child was moved with its relative overrides untouched when the original
directory could not be read at all. That silently retargets each of them
at the new directory, so it is refused unless there is nothing relative
left to preserve.

The Typer callback no longer relocates. It runs after the command modules
are imported, and commands.studio resolves STUDIO_HOME at import time, so
a host that reached that point cannot be moved without leaving the cached
root behind. The console script is unaffected: it is checked before any
command module loads.

The environment and sys.path are put back if the move does not happen.
Inside a host process both belong to the caller, and a chdir that fails
after pinning left them rewritten as though it had succeeded.

* Write a ~ value out, and pin the uv cache

Two more from the review round.

A `~` value was left alone on the grounds that expanduser does not
consult the working directory. That is true of expanduser and false of
the readers: llama_cpp.py hands UNSLOTH_LLAMA_CPP_PATH and
LLAMA_SERVER_PATH straight to Path(), as the whisper and stable diffusion
overrides do with theirs, so `~\llama.cpp` was an ordinary relative path
for them and followed the child to the new directory. It is written out
now, on both sides, which is what the caller meant and what the readers
that do call expanduser would have computed for themselves. That also
covers the PYTHONPATH case more honestly than anchoring it did: `~` names
the profile rather than a folder called "~" beside the old directory.

UV_CACHE_DIR joins the pinned names. uv reads it as written, Studio
treats a non-blank value as authoritative, and `unsloth studio update`
runs uv through setup.ps1, so a relative one moved the install cache.

The guard's tests and the simulations now call the real expanduser
against the environment each case describes; the stand-in returned the
profile for every input, which is what hid this.

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

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

* Judge a list entry by entry, and keep the checkout out of the children

Three more from the review.

The lost-directory check read a whole path list as one value, so
PYTHONPATH=C:\vendor;plugins looked qualified because of the drive at the
front while `plugins` still depended on the directory that was gone. Each
entry is judged on its own now, with the empty PYTHONPATH component
counting as the directory itself.

MLX_IBV_DEVICES is pinned beside MLX_HOSTFILE and exempted from anchoring
the same way. `_json_rank_count_from_env` reads the two identically:
either the device list inline as JSON, or a filename.

STUDIO_LOCAL_REPO is read by the update and installer path alone
(install_python_stack.py), so a stale drive-relative value was failing
preflight, backend startup, capability probes and auth provisioning over
a setting none of them look at. It stays pinned for the update child and
is dropped for the rest.

* Anchor an import root that is really there, and read /var/cache as absolute

Two more from the review.

The lost-directory check judged every value by Windows rules, and it is
the one part of the pinning that runs off Windows: a desktop on Linux or
macOS whose launch directory was deleted read XDG_CACHE_HOME=/var/cache
as relative and failed every managed spawn with path_setting_unresolvable
over a value that depends on no directory at all. The native spelling
counts there too now.

sys.path entries are anchored when they name something on disk rather
than only a directory. Skipping every non-directory kept setuptools'
editable sentinel safe, which is what it was for, but it also left a
relative importable archive behind: os.environ is not sys.path, so after
the move the next import from that archive looked for it beside the new
working directory. A folder or an archive is anchored; a string that
names nothing on disk is still left exactly as written.

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

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

* Read a POSIX path list with POSIX rules, separator included

The lost-directory fallback is the one part of the pinning that also runs off
Windows, and absoluteness there was already fixed. The separator was not: the
list was still split on ';', so "/opt/vendor:plugins" stayed a single entry,
started with '/', and passed as absolute. The relative entry behind it was
never seen, and the child got it resolved against the wrong directory.

Deciding both from a parameter rather than cfg! is what makes this testable.
The existing entry-by-entry test feeds a Windows list and runs on every
platform, so a compile-time cfg would either break it off Windows or leave the
POSIX path untested. Now each test names the rules it means, and the POSIX one
also pins that a Windows-shaped value stays Windows-judged.

Supersedes the narrower POSIX test added alongside the absoluteness fix: same
fixture, and the new one additionally covers the separator and the
Windows-stays-Windows direction.

* Normalize overrides before refusing a lost-directory move

When the launch directory is gone, the pin scan judged each override
exactly as written, without the expansion and non-path exemptions the
moving path applies. HF_HOME=%LOCALAPPDATA%\hf, an inline JSON
MLX_HOSTFILE and UNSLOTH_ALLOW_LOCAL_PREQUANT_PATH=1 were all read as
relative, so preflight and every managed spawn failed with
path_setting_unresolvable over values that never depended on a
directory. The same normalization now runs before the check.

* Shorten the comments added by this branch

Same intent, fewer lines: the reasons that are not obvious from the code stay,
the restatements of it go. No code or behaviour change.

* Pin the model paths llama-server reads for itself

llama-server takes LLAMA_ARG_MODEL, LLAMA_ARG_MMPROJ and the two draft-model
spellings straight from the environment and resolves a relative one against its
own working directory, and Studio reads them back when it sizes a launch
(llama_cpp.py). A managed child moved out of System32 with one of those still
relative would look for the model or the projector beneath ~/.unsloth. Both
mirrored lists now carry them. The URL and HF-repo spellings stay out: they name
no local file.

* Settle an expansion before anchoring it, and restore the real sys.path

Two defects found by a fresh round of idempotency review.

A value whose expansion needs a second pass, LOCALAPPDATA holding
%USERPROFILE% and HF_HOME holding %LOCALAPPDATA%, was anchored while still
half expanded, so the reader that expands saw a folder name with a second
drive in the middle of it. Expansion now runs to a fixpoint, and a value that
never settles, HF_HOME holding itself, is left exactly as written rather than
anchored or grown. Both layers, with a test each.

The console script passes no list, so the guard pinned the real sys.path with
no snapshot to put back: a chdir that then failed left the process carrying
import roots it never agreed to, while the environment was restored. The
snapshot is now taken from the list actually being pinned. The existing
rollback test passed its own list and so missed the one path production
takes; the harness can now leave it out.

* Never let a lost directory fail a spawn, and let the installer build its profile

Two success-to-failure changes found by the same review round.

A process whose working directory has been deleted or unmounted can still
spawn children from the handle it holds. Pinning cannot anchor anything to a
directory it cannot name, and the answer was to refuse, which took the
capability probe, the backend start, the auth provision and the update down
over a setting the command may never read. The pins still report what a move
would lose; that report now decides whether the child moves rather than
whether it runs, so it stays where it is, exactly as it did before this file
learned about working directories.

The installer shared the managed resolver, which requires the home to exist so
that a child never builds an empty folder shadowing a roaming profile that has
not mounted yet. install.ps1 and install.sh detect a SYSTEM profile
themselves, and before they shared the resolver a home that did not exist was
simply created along with ~/.unsloth, so they get that policy back.

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

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

* Pin the pinning decision as a table

* Expand a value the way its reader does, and never leave the process elsewhere

Four findings from a fresh round of review, all in code this branch added.

Expansion now runs exactly one pass, which is what every reader runs, and the
result is written back only when expanding it again would change nothing. The
fixpoint loop that was here consumed an escaped %%NAME%% on the first pass and
then expanded what the escape was protecting, and a nested reference came out
half expanded. Both are now left exactly as written, alongside the
self-referencing case they were added for.

Python never expands ~ in PYTHONPATH, so neither does the pinning: `~\plugins`
is anchored as the relative folder the interpreter actually reads rather than
turned into a profile folder it was never reading, which also stops an inert
entry from becoming an importable one.

Click reads `-f.\dist` as a value exactly as it reads `--frontend=.\dist`, so
an invocation carrying one is refused rather than rebased under the new folder.

A chdir can succeed and still land somewhere the guard refuses, through a
junction or a profile inside the Windows tree. The process now goes back where
it started, so the values written for the move are put back with it instead of
being restored under a directory nobody chose.

* Resolve a tilde the way ntpath does, and pin two more paths

More from the same review round, all cross-layer.

The two layers expanded a tilde and a variable in opposite orders, so a value
that names a folder through both reached different folders. Rust now does the
tilde first and the variables second, as the CLI guard does.

Rust also resolved ~someone-else as a sibling of this profile unconditionally.
ntpath declines to guess unless the profile is named after the current user,
because C:\Users\me.DOMAIN is not me's sibling, and now so does this.

AMDGPU_ASIC_ID_TABLE_PATH and VLLM_CACHE_ROOT are read straight from the
environment as file paths, by import_fixes.py and by Studio when the caller set
one themselves, so both lists carry them.

The update no longer pins PYTHONPATH on Windows, where build_update_command
drops it anyway: pinning it could only refuse an update over a value the child
never receives.

* Never refuse an update over a setting it drops, and name the one that blocks

Two review items, both about what a failure costs.

STUDIO_LOCAL_REPO is now pinned best effort in both layers. A bare
`unsloth studio update` drops it before anything reads it, and `update --local`
is refused rather than relocated, so a stale drive-relative value could only
refuse the one update form the System32 fallback exists for. It is still
written out whenever it can be, so a reader that appears later still finds the
folder the caller meant; every other setting still stops the move, because
something does read those.

A context failure now carries the setting that caused it, as
`path_setting_unresolvable:HF_HOME`, and the window names it. "One of Unsloth's
folder settings" is not something anyone can act on, and every pin failure
already names the setting it could not preserve. The name only, never the
value, since this reaches the screen.

* Refuse the move when one pass leaves the folder up to the caller

Leaving an unsettled expansion as written and moving anyway takes the value
with the process: the reader resolves what one pass gives it against whatever
directory it is standing in, so a nested %NESTED%, an escaped %%NAME%% or a
self-reference quietly followed the child to ~/.unsloth.

One pass is still what the reader does, so it is still what decides. If that
result names a folder on its own, C:\\cache\\%UNSET%\\assets, the value means the
same thing from anywhere and is left alone. If it does not, the setting cannot
be preserved across a move and the move is refused, naming it. Both layers, a
test each.

* Read a path list the way the host does, and report what will not fit

Four more from review, all in the moving path.

The list loop split and rejoined on ';' and judged absoluteness by Windows
rules, while the lost-directory branch had already learned the native ones. A
POSIX PYTHONPATH of plugins:/opt/vendor was therefore one entry, and both
import roots left with it. Both loops now read the host's separator and the
host's idea of absolute.

A pinned list that no longer fits in a Windows variable is now reported as the
setting that did not fit, rather than discovered by CreateProcess, where the
window would have offered a repair that hits the same wall.

The tilde now follows USERPROFILE, which is what ntpath.expanduser answers and
what the CLI guard uses. dirs::home_dir() reads the known folder, which a
portable or overridden environment moves, and the two layers have to name the
same folder.

GGML_BACKEND_PATH joins both lists: llama_cpp.py preserves it into the
llama.cpp child, which resolves it against wherever it is standing.

* Expand a POSIX value the POSIX way, and check a scalar against the limit too

The lost-directory branch is the one part of the pinning that runs off Windows,
and it still read %HOME%/hf as an expandable reference. posixpath.expandvars
leaves that literal and expands $HOME instead, so a value that depends on where
the process is standing was read as one that does not, and the child moved out
from under it. Expansion now follows the host: $NAME and ${NAME} off Windows,
%NAME% and the rest on it.

The oversized check covered the joined list but not a scalar, which crosses the
same limit once it names its folder in full. Same check, same message, both
layers.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-13 06:32:58 -07:00
Wasim Yousef Said
e95fd2bea5
Studio: keep running when the main window closes (#8675)
* feat(studio): add close to tray setting

* fix(studio): make close to tray opt-in
2026-08-13 15:23:31 +02:00
oobabooga
42c86719bc
Studio: import Open WebUI chat exports (#8643)
* Studio: import Open WebUI chat exports

* Address review comments

* Convert deep chat histories iteratively and stop misframing pretty-printed rows

* Keep literal prompts, legacy dates, and partial reads honest on import

* Hold the picked file open and tighten record and format detection

* Resume the scanner after a damaged row and keep built-in tool items

* Update the desktop contract test for the .json import extension

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

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

* Studio: keep multimodal turns and out-of-range stamps on Open WebUI import

A Chat Completions record carrying a per-message id and timestamp satisfies
isOpenWebUIRecord, and messageParts read only string content, so a turn whose
content was the OpenAI array form was dropped entirely. Detection cannot be
made perfect, so the array form is handled here as well, the same way
oaiMessagesToRecords handles it.

epochMs now rejects a stamp past the range Date accepts. Beyond 2^53
previousTs + 1 stops advancing, which collapsed every later message in the
chat onto one createdAt and destroyed the depth-first order the surrounding
comment exists to preserve.

unescapeHtml decodes numeric character references. Open WebUI decodes those
attributes with a full html-entities pass, so an apostrophe arrives as &#x27;
as readily as &#39; and the five-entity replacement left it in the rendered
tool card.

Also: tool args are always an object, since a malformed arguments attribute
parsed loosely to a bare string where the renderer indexes an object; an
image_generation_call whose result is already a url is no longer wrapped into
a broken data url; and a tool that returned only images no longer carries an
empty result, which drew a Result heading over an empty block.

* Studio: report a truncated import in the importer's own words

An array cut inside a record reached JSON.parse on the tail before the
closing-bracket check, so the user saw "Unterminated string in JSON at
position 42" rather than the message written for exactly this case. The
records read before the cut are still yielded, so the count of what was saved
is unchanged.

Records leave the buffer as they are emitted, so the one way to reach the
engine's maximum string length is a single record that long. Untranslated
that surfaces as a bare "Invalid string length", which says nothing about
what to do; it now names the oversized chat and suggests splitting the export.

* Studio: advance import progress by bytes as well as conversations

Progress was reported only every 25 conversations, so an export made of a few
very large chats sat at "Importing chats: 0 so far (0%)" for the whole read,
which is the case the toast exists for. bytesRead was already tracked per
chunk and only needed to be surfaced.

* Studio: clamp the chat import range read inside read_range

The chunk bound was applied to Vec::with_capacity but not to take, so the
allocation was capped while the read itself was not. The command clamps before
calling, so nothing was reachable from the webview, but the function did not
hold its own invariant and the tests call it directly.

* Keep portable tool output and stop a cycle stealing the open branch

* Studio: anchor an unclosed code fence to the start of a line

Treating any unclosed ``` as a fence running to end of message made a single
stray backtick run quote everything after it, so every tool call later in the
same assistant turn was rendered as literal markup instead of a tool part. A
model writing ``` mid-sentence, or a stream cut inside one, is common enough
that this traded a rare loss for a frequent one.

Markdown opens a block fence only at the start of a line, so the unclosed form
is anchored there. The interrupted-code-block case it was added for still
holds, because that fence does begin a line.

* Bound the import to the file that was opened and refuse trailing records

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
2026-08-13 06:20:55 -07:00
alkinun
a1e96ece88
Studio: require managed backend for linked folders (#8536)
* Studio: require managed backend for linked folders

* Studio: keep terminal-started backends adoptable, and cover the gate

The capability check landed inside backend_capability_stale_reason, which
backend_desktop_auth_status consults for every same-root backend, not only
before a folder lease. The lease secret is a per-process environment variable
that only the desktop spawn sets, so an ownerless same-root backend is a server
the user started from a terminal and can never report support. Measured on the
full probe matrix, four cells moved from Ready to
ExternalConflict:native_path_leases_unsupported, which stops the app booting
against a backend it can otherwise drive, with a message telling the user to run
`unsloth studio update` for a cause an update cannot fix. Attaching to a server
you started yourself is deliberate behaviour in use-tauri-backend.ts.

Split the check into backend_native_lease_stale_reason and apply it only where a
restart is ours to perform: a backend this app owns, or one it just spawned. Both
get Old, so the existing repair restarts them with the secret. An ownerless one
stays adoptable and the picker degrades in the UI instead, which is what the
frontend half of this PR already does.

Two knock-on effects, both now pinned by tests. The lease check ran before the
version check, so a genuinely outdated backend reported
native_path_leases_unsupported rather than desktop_backend_version_too_old and
the reason string reaching diagnostics named the wrong cause. And a backend
predating the field reports it absent, not false, which is the same branch.

Fixtures: an ownerless health payload cannot advertise lease support, so tie the
field to the owner instead of hardcoding true, or the tests assert against a
backend that cannot exist.

Adds studio/frontend/tests/linked-folders-lease-gate.test.ts. The frontend change
is the half users see and had no coverage; the new test fails on three of four
assertions at the merge base.

* Studio: tighten the comments on the linked-folder lease gate

* Studio: key native path leases per install, not per app process

The capability bit only proves the backend holds some secret. When the previous
app pid is dead, probe_verified_owned_backend adopts the surviving backend
instead of restarting it, while new_native_intake_state mints a fresh secret for
the new process. The adopted backend then verifies with the old key, so the
picker stays live and every grant fails on the signature rather than on the
capability, which is the same 400 issue 8416 reported.

Persist the secret once per install, beside the owner metadata in the same 0700
directory at 0600, and reuse it. That file already carries the desktop owner
token, which buys a desktop-login and is the stronger credential of the two, so
this adds no exposure class. A short or undecodable file is replaced: the old
secret is unrecoverable either way.

Restarting the adopted backend would also close the gap, but the adoption path
exists precisely so a survivor is not killed, and it can be mid-training.

MIN_LEASE_SECRET_BYTES is now named on the Rust side and documented as lockstep
with _MIN_LEASE_SECRET_BYTES in native_path_leases.py, so a shorter key cannot
advertise leases the backend refuses.

* Studio: close native lease adoption gaps

* Studio: trust an adopted survivor only when the lease key predates us

Writing the key is not the same as sharing it. On the first launch after
upgrading from a release that kept the key in memory, the file is absent, so
this process mints one and the previous flag called that persisted. A backend
that release left running still verifies with the key it was spawned with,
advertises lease support because it holds one, and is adopted, so every grant
fails on the signature.

ensure_native_path_lease_secret now reports whether the key was LOADED, which is
the only case in which anything already running can verify what we sign, and the
preflight marks an adopted survivor stale otherwise. A minted key covers both the
first launch after the upgrade and the replacement of a short or undecodable
file, so the earlier not-persisted case folds into it; the reason string becomes
native_path_lease_secret_not_shared, which is what it always meant.

* Studio: report lease usability instead of persisting the signing key

The persisted key was my suggestion and it has cost three rounds: the upgrade
window where the file is minted rather than loaded, a launch that creates the
file and dies before restarting the survivor, and a key that now outlives every
backend so a consumed lease can be replayed against a replacement inside the two
minute TTL, since spent nonces are only remembered in memory. Each fix added
state without removing the reason the next one was needed.

Drop it. The key goes back to per process, which is the only lifetime under which
the replay guarantee holds, and the one case it cannot cover, a survivor adopted
from a dead previous app, is answered where the answer actually lives: the app
knows which backend it spawned. native_path_leases_usable reports that, and
use-native-readiness ands it into the health bit, so an adopted survivor greys
the picker with the message the panel already has.

That also drops the forced repair. An adopted backend was being marked stale, and
owned_stale routes through startRepair, which stops the backend and runs the full
update with an installer fallback, so a healthy offline install could fail to
launch to inject an environment variable. The survivor now keeps running and only
loses lease-backed actions until it is restarted.

* Studio: answer lease usability from a spawned backend, not from an absent one

attached_ready installs no owned snapshot at all, and the predicate read that as
usable. A terminal-started backend carrying any lease secret of its own then
advertises support on /api/health and the picker goes live, while every grant
signed here fails on the signature. Require the positive fact instead: a spawned,
non-adopted backend.

* Studio: tighten the native lease comments after convergence

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-12 09:59:46 -07:00
Daniel Han
e2a80439ce
Studio: stop building test scratch paths inside a macOS sensitive root (#8485)
* Do not put test scratch paths inside a sensitive root on macOS

std::env::temp_dir() is /var/folders/... on macOS, and its real path is
under /private, which the document-folder policy treats as a sensitive
root deliberately. Every scratch path these tests built was therefore
rejected for that reason rather than the one under test, and two of them
failed on macOS for a policy that is behaving correctly.

The test binary's own directory is writable, sits in the build tree, and
is sensitive on no platform, so the scratch paths hang off that instead.
No product behaviour changes: linking a folder under /private is still
refused, which is the point of the rule.

* Pick the test scratch root by asking the policy

* Prove the scratch root instead of asking one clause of the policy

* Create the test scratch directory exclusively
2026-08-12 01:45:12 -07:00
Daniel Han
2ca3f660c0
Read macOS zombie status from sysctl, the call that answers (#8493)
* Read zombie status from sysctl, the call that answers on macOS

* Wait for the test child to exec before judging it foreign
2026-08-11 21:51:50 -07:00
Daniel Han
27d9e654a1
Ask macOS for zombie status with the flavor that answers (#8484)
PROC_PIDTBSDINFO refuses a zombie and returns nothing, so asking it
whether a process is a zombie could only ever answer no. The check
therefore did nothing on macOS: a crashed backend the desktop app has not
reaped still read as live, and its record still blocked every repair
until the app exited, which is the failure the check was added for.

PROC_PIDT_SHORTBSDINFO does report a zombie, and libc exposes SZOMB, so
no local constant is needed either. Caught by running the suite on a real
macos-14 runner, where a_zombie_is_not_a_live_process and
a_record_for_an_unreaped_process_is_ignored both failed; a cargo check
for the Apple target compiles this code but never runs it.
2026-08-11 12:05:14 -07:00
Daniel Han
ba282168ae
Studio: let repair proceed past a backend that is not ours (#8459)
* Studio: let repair proceed past a backend that is not ours

A stale managed install auto-runs a repair on launch, and the repair
refused whenever any id-less backend answered anywhere in 8888..8908.
Skipping the port on launch was not enough: the app never got as far as
launching, because the repair it runs first errored on every attempt.

An HTTP probe cannot say whether an id-less backend is a local process
out of the tree we are about to rewrite. The per-port record files the
server writes into the studio home can, so a mutation now consults them
and refuses only when a live backend of this install is recorded on the
port. A remote Studio behind a port forward records nothing locally, so
it no longer strands the install.

* Attribute a recorded pid by the executable it runs

Two review points on the record check, with one answer. A pre-upgrade
server is recorded only in the legacy studio.pid, and the per-port write
is best effort, so the absence of a per-port record was never proof that
the responder is remote. And a record outlives a crash, so a reused pid
would have vetoed a repair on a stale file alone.

Reading the executable behind a recorded pid settles both: the question
a mutation actually has is whether a live process runs out of the tree
it is about to overwrite. The legacy record is now consulted as well,
and since it names no port it blocks only on positive attribution; a
per-port record naming the port stands unless the process is provably
from another tree.

* Correct the probe doc: attribution, not the per-port record alone

* Do not read a symlinked venv interpreter as a foreign process

uv builds the venv with bin/python symlinked to the base interpreter,
which install.sh documents and depends on, and both /proc/{pid}/exe and
proc_pidpath report the resolved target. A live backend of this install
therefore looked like it was running from outside the tree, and a repair
would have rewritten the venv underneath it. Verified on a real install:
the image path is /usr/bin/python3.13 while argv[0] is still the venv.

Attribution now takes argv[0] as positive evidence, which keeps the venv
path the process was launched with, and treats the interpreter our own
venv resolves to as no evidence either way rather than as proof of a
foreign process. Windows is unaffected on both counts: its venv holds a
real python.exe.

* Cover the Windows venv trampoline, and test against real processes

Windows is the platform in the report and the one the executable check
was weakest on. uv does not symlink there: Scripts/python.exe is a
trampoline that spawns the base interpreter as a CHILD process, so the
backend's image path is the base interpreter and the venv appears in it
nowhere. Canonicalizing the trampoline yields the trampoline, so a live
local backend would have read as a foreign process and a repair would
have rewritten the venv underneath it.

pyvenv.cfg ties the two together: its home key names the directory the
venv was built from, so those interpreters now count as no evidence
either way. That covers the symlink layout and the trampoline layout
with one mechanism.

Also adds system tests that drive the record check with real processes
and real files rather than injected answers, including a symlinked venv
interpreter reproduced with a real symlink, since what is under test is
what the OS reports.

* Decide a record by start time and a four-state origin

Four review points, all on the same decision. Attribution was two-valued
plus a maybe, which forced each caller to guess about the maybe, and the
comparison it rested on was wrong on Windows.

Origin is now four-valued: inside the tree, a shared interpreter, plainly
elsewhere, or unreadable. A record naming the port blocks on anything but
the third; the legacy record, which names no port, no longer needs a
positive answer, since on macOS a backend behind the symlinked venv can
never give one and a pre-upgrade server leaves no other record.

An unreadable pyvenv.cfg now counts as a shared interpreter rather than
proof of a foreign process. A damaged install is the state a repair runs
in, and Windows has no argv[0] to fall back on.

Windows path comparison was broken outright: canonicalize returns the
extended-length form while QueryFullProcessImageNameW returns a plain
path, so the same interpreter never compared equal. Both sides are now
simplified first.

Blocking more often needs a way back, so the recorded start time is now
checked against the process actually wearing the pid, over /proc stat,
proc_pidinfo and GetProcessTimes. A pid reused after a crash is settled
by the clock even when the executable cannot settle it, which is what
keeps a stale record from stranding a repair.

* Flag an unresolvable base interpreter, and reap-check a record

Two review points, both on reading a live process wrongly.

A venv config that names a base interpreter which is no longer there left
base_unknown false, because only an unreadable config set it. A uv venv
whose base was deleted or moved is a damaged install, which is the state
a repair runs in, and on Windows there is no argv[0] to fall back on, so
the running backend read as a foreign process. Whether any candidate
actually resolved is what decides now.

A zombie answers kill(pid, 0) while its socket is long gone. The desktop
app spawns the backend and does not wait on it, so a crash leaves one for
as long as the app lives, and the record would have blocked every repair
until the app exited. Liveness now checks the process state, over /proc
stat and proc_bsdinfo; Windows signals an exited process, which the
existing check already reads as dead.

* Do not let the portless record answer for every port

The app owns a backend on an ignored port, an id-less one answers on
another candidate, and the exact-port lookup correctly finds nothing. The
legacy record then claimed the owned backend for that unrelated port, so
the guard refused before the app ever reached the step that stops its own
backend, and no mutation could start.

A current build writes a per-port record too, so being named by one
settles the legacy record either way: a record that stands has already
been judged under the port it names, and one contradicted by its start
time says the pid was reused, which an untimed record cannot see.
_legacy_studio_on_port skips the same case on the Python side.
2026-08-11 11:43:12 -07:00
Daniel Han
9d5f54b335
Desktop: let a launch step over a backend it cannot attribute (#8452)
An Unsloth server on any candidate port that reports no studio_root_id is
classified AmbiguousRoot, and backend.rs turned that straight into a terminal
ExternalConflict. Two ways to hit it, both from support reports:

  - an `ssh -L 127.0.0.1:8888` forward to a box that also runs Studio, so the
    health probe answers as Unsloth but the id belongs to another machine;
  - any install without share/studio_install_id, which _studio_root_id() reports
    as "". main.py already documents that as "the launcher accepts any healthy
    backend", which is the opposite of what preflight did with it.

probe_existing_backends folds with first_conflict.or(first_ready).or(first_old)
over 8888..=8908, so one such server anywhere in the range failed the whole
launch. One report is a conflict on 8899 with 8888 free and the install probing
Ready; the app never reached a spawn decision, and the backend never got to run
its own 8888 -> 8889 fallback.

An id-less backend is not evidence that this install is serving. It now probes
as BackendProbe::Unrelated: the launch logs the port and skips it, the backend
picks the next free one and reports it through TAURI_PORT as before.

Mutations keep today's behaviour exactly. A repair or update rewrites the shared
venv, and an id-less backend could still be ours started from a terminal, so
mutation_blocker_from_probe treats Unrelated as a blocker. It gets its own
selector over the full probe list, so an Unrelated on a later port is no longer
swallowed by an Old on an earlier one.

ForeignRoot stays Old: a valid different id is already non-fatal on both paths.

cargo test --no-fail-fast: 234 passed.
2026-08-11 08:26:12 -07:00
Daniel Han
501a6ac48e
Desktop: stop the loopback client following redirects (#8421)
`loopback_http::client` is the client that posts `.desktop_secret` to
/api/auth/desktop-login, and it was built without a redirect policy. reqwest
follows up to 10 redirects by default, and its cross-host protection strips
headers rather than bodies, so a responder answering 307 (which preserves the
method and the body) would carry the secret to whatever the Location header
names, after the loopback URL had already been checked.

Its sibling `streaming_client` already refuses redirects for exactly this
reason: "Redirects are refused so a loopback URL cannot be bounced off-host
after the check." Give `client` the same policy.

No behaviour change for any real backend, which never redirects these routes.

Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-08-11 06:27:50 -07:00
Wasim Yousef Said
f567ae8f39
Studio: skip redundant packaged frontend rebuilds (#8326)
* Desktop: skip frontend rebuild during updates

* Tests: tolerate rustfmt in updater UTF-8 contract

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

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

* Studio: use packaged frontend for PyPI installs

* Studio: keep the packaged frontend skip off source checkouts

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

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

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-11 03:02:17 -07:00
Daniel Han
c3de39652e
Source the update popup's release notes from the GitHub releases (#8352)
* Source the update popup's release notes from the GitHub releases

CHANGELOG.md had to be hand-edited before every release, and it was keyed by
the PyPI backend version while the announcement itself lives on a release
tagged with the Studio version. The two drifted: the file carried three-bullet
stubs while the release page carried the write-up.

The newest published release is the source now, so the popup follows each new
release with no file to edit and no rebuild. Only the announcement is shown:
the install instructions, the generated What's Changed list, New Contributors,
the Full Changelog line and the appended build provenance are stripped out,
wherever in the body they were written.

* Strip an install block introduced by a paragraph, not just by a heading

v0.1.471-beta writes the same sentence v0.1.43-beta puts in a `###` heading
with no hashes in front of it, so _is_upgrade never saw it and the popup kept
a stale version pin and both install commands. A paragraph has no level, so
the block it opens runs to the next heading that is not one of the platform
headings holding its commands. Run over every published release body, exactly
one output changes and the removed lines are exactly that install block.

Also raise the releases page to the endpoint maximum of 100, which costs the
same single request, and fix three assertions that could not fail: the draft
case used a tag the tag filter rejects first, so the draft filter had no
coverage; no published body carries a provenance section, so asserting its
absence proved nothing; and "refresh" appears throughout the hook, so the
retry path could be deleted with the test still green.

The comment justifying the removed desktop fallback was wrong about what
latest.json's `notes` holds. It is the static download blurb the release
workflow writes, the same text every release, not this release's body.

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

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

* Bound the rate-limit deadline, not just the first wait on it

The 15 minute cap was applied to the returned TTL while the raw reset epoch
went into _rate_limited_until, so the fetch after that TTL expired answered
from the uncapped deadline and blocked release notes, Retry included, for the
whole server-supplied interval.

GitHub says not to request again before X-RateLimit-Reset, so the reset still
wins over the back-off rather than being cut to 15 minutes, which would retry
into the limit. It is now held to the hour that the unauthenticated window
actually is, so a skewed or proxied header cannot park the popup.

* Record a deadline for every refusal, not just the primary rate limit

A secondary rate limit answers 403 or 429 without X-RateLimit-Remaining: 0, so
that branch returned a 15 minute TTL and left _rate_limited_until unset. Retry
saw no lockout, dropped the cached failure and requested straight back into the
limit, which is what GitHub warns against.

Every refusal now records a bounded deadline, in the order GitHub documents:
Retry-After, which is how a secondary limit states its wait, then the primary
limit's reset, then the plain back-off when the response says neither.

* Size the releases page against the read cap, and split platforms on a slash

A release entry carries its whole body and the newest ones run about 40 KiB,
so the endpoint's maximum of 100 puts the response near 4 MiB against a 2 MiB
cap. The fetch would then fail outright with the release it wanted sitting at
the top of the page it just threw away. Back to 30, which is about 1.2 MiB at
that rate, with the arithmetic recorded in a test that fails if the page grows
past what the cap allows.

_is_platform replaced a slash with a comma but not the spaces around it, so
neither "macOS / Linux / WSL" nor "macOS/Linux/WSL" matched and an install
block written that way would have kept its commands. The separators are now
read as one thing.

* Tighten the comments this change added

Comments only, no code. Mostly the test docstrings, which had grown into
several lines of bug narrative each and now lead with the rule and keep at
most a clause of what broke, and the blocks in release_notes.py that only
restated the line below them.

The reasons bought during review are kept, shorter: why the releases page is
30 and not the endpoint maximum, why the rate-limit deadline is bounded and
why GitHub's reset beats the back-off, why releases are ordered by
published_at, and why the install block is excised where it stands.

* Refuse a desktop tag that goes backwards for PR #8352

Both updater paths compare SemVer, so v0.1.60-beta published after
v0.1.527-beta reads as older and no client on the 5xx series is ever offered
it. Desktop builds have only shipped at four tags, v0.1.526-beta through
v0.1.61-beta, so this is the first time the tag numbering has stranded
anyone, and it strands everyone installed before Aug 10.

Check the new tag against the version in the published latest.json, which is
the file a running build actually reads, and refuse a release that would
strand its own users. Warns rather than fails when the manifest is
unreachable, so a network blip cannot block a release.

* Compare full SemVer in the tag guard, and drop a bare platform install block

The guard compared only the numeric triple, so v0.1.528 or v0.1.528-rc after
v0.1.528-beta read as equal and were refused, though both are newer and the
updater treats them that way. Compare full SemVer precedence instead.

Separately, a platform heading only ended an install block that an 'Updating'
heading or paragraph had already opened, so a release heading its commands
with a bare 'MacOS, Linux, WSL:' kept them. v0.1.0-beta and v0.1.41-beta do
that, and they are the only two of the 24 published bodies this changes; all
seven platform headings in that corpus head an install block.

* Resolve the published desktop version from the newest Studio release

The guard read /releases/latest/download/latest.json, but this repo also
publishes llama.cpp prebuilts as ordinary releases: 25 of them, non-draft and
non-prerelease, so any new one becomes GitHub's latest release. Those carry no
manifest, so the read would 404 and the guard would skip itself silently,
which is the same hazard release_notes.py already documents and avoids.

Take the newest non-draft Studio tag that actually ships a latest.json, and
authenticate with the workflow token rather than spending a shared quota.

* Tighten the comments the last two rounds added

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-11 00:28:01 -07:00
alkinun
86b3ee1853
Studio: sync linked folders into RAG (#8014)
* Studio: sync linked folders into RAG

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

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

* Fix linked folder lifecycle edge cases

* Fix linked folder sync review issues

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

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

* Fix linked folder sync lifecycle

* Fix linked folder sync review findings

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

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

* Handle linked folder availability and KB cleanup

* Harden linked folder filesystem reconciliation

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

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

* Retire project RAG scope before deletion

* Skip project RAG cleanup when unavailable

* Stabilize linked folder reconciliation

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

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

* Guard linked folder deletion races

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

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

* Guard project RAG uploads during deletion

* Fix linked folder scope deletion recovery

* Fix deletion transaction and native lease calls

* Avoid Studio write locks during folder sync

* Harden linked folder startup and scans

* Preserve linked folder scans with weak identities

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

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

* Make linked folder reconciliation crash-safe

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

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

* Preserve linked folder lifecycle intent

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

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

* Make linked folder retirement cleanup durable

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

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

* Close linked folder lifecycle races

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

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

* Close RAG visibility and lease gaps

* Coordinate durable RAG workers

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

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

* Refine linked folder lifecycle and reconciliation

Preserve signed folder identities through registration and revalidate them before persistence.

Use durable scope tombstones, owner-first project deletion, file-first cleanup, and lease-based job recovery.

Run linked-folder ingestion within the folder worker and retain prior mappings when reconciliation is incomplete.

Remove compatibility paths for unreleased linked-folder schemas and reuse the shared scoped single-flight request helper.

Consolidate regression coverage while preserving lifecycle, concurrency, and cleanup scenarios.

* Align Windows path identities and preserve leased RAG jobs

* Encode 128-bit file identities for linked-folder mappings in PR #8014

CPython 3.12+ fills st_ino from FILE_ID_INFO, so on ReFS and Dev Drive volumes
a file id is 128-bit and does not fit SQLite's signed 64-bit INTEGER. The root
identity was already hex-encoded through _store_identity; the per-file mappings
still wrote st_dev/st_ino raw, so _install_mapping raised OverflowError for
every file and nothing on those volumes could ever be indexed.

Route the per-file identity through the same encoding and compare change
detection on the encoded pair, so existing integer rows keep matching.

* Reap linked-folder document orphans during periodic scheduling

_recover_startup_state() runs once, before the worker loop. When a second
backend crashes between a completed ingestion and _install_mapping(), the
surviving process reclaims the expired folder job and reindexes the file, but
its own startup pass is long past, so the earlier document, its chunks and its
snapshot stay unreferenced forever and keep answering searches alongside the
replacement.

Extract the orphan reap and run it from _enqueue_periodic() as well. The live
ingestion and folder-sync lease guards are unchanged, so work another backend
still owns is left alone until its lease expires.

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

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

* Remove replaced linked-folder snapshots only after the mapping commits

_install_mapping() unlinked the previous snapshot inside the transaction. A
commit failure, for example SQLITE_BUSY under the multi-process contention this
feature already handles, rolls the mapping and the document deletion back but
cannot restore the file, leaving the prior document searchable while preview and
download fail on a stored_path that no longer exists.

The delete paths keep file-first ordering on purpose, since there the surviving
row is the retry queue. Here the surviving row is live data, so the removal now
runs after the commit through _remove_snapshot(), which logs rather than failing
an installed mapping.

* Release skipped folder-sync claims and keep the worker alive on queue errors

_next_job() claims and activates a job before dispatching it, and
reconcile_folder() then re-claims. When an unlink lands in between it fails the
job, the second claim returns None, and the early return skipped the finally
that releases the lease. The heartbeat renewed that claim every 5 seconds for
the process lifetime, so _delete_retired_folder() never saw it expire and
delete_folder() spun on its 50 ms wait until restart.

Separately, _next_job() ran outside any retry block, so a writer-lock timeout
unwound the worker through the outer finally and left nothing to relaunch it,
stopping every linked-folder scan for the process lifetime. It now backs off and
retries like the initialization and periodic paths already do.

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

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

* Defer deleted-file snapshot removal and release exhausted folder polling

_delete_mapping() unlinked the snapshot inside its transaction. A failed commit
rolls the mapping, document and chunks back but cannot restore the file, so the
document stays searchable while preview and download fail. Unlike the retired
folder queue, which _reconcile_retired_folder_deletions() retries every cycle,
nothing re-drives this path on a folder with auto_sync off, so the broken state
can persist until someone syncs by hand. Removal now runs after the commit, as
_install_mapping() already does.

The folder job poller also only released its controller from the catch block, so
exhausting all 600 attempts left the id in controllers.current; the refresh then
treated the job as tracked and never restarted polling, freezing progress until
unmount.

* fix linked-folder deletion, sync coalescing and buffered progress streams

Six defects found while exercising the linked-folder pipeline end to end.

Deletions no longer depend on a clean ingest. `_reconcile_folder` gated its delete
pass on `failed == 0`, so a single permanently unparseable file kept every removal
from ever applying: a document the user deleted from the folder stayed indexed and
kept coming back in retrieval, with no way out, since managed documents cannot be
deleted by hand. Each vanished path now gets one grace pass, recorded in
`linked_folders.withheld_paths`, which preserves the guarantee that a rename or
replace keeps its prior document while its replacement fails to index.

A sync requested during a running sync is no longer dropped. `_request_sync` folded
it into the running job and returned that job's id, but that job had already fixed
its file list, so the change was never indexed and the user was shown a completed
sync. `rebuild_requested` becomes `successor_kind`, one column covering both kinds,
and `_fold_into_active_job` queues a follow-up whenever the running job cannot
cover the request.

Folder-sync progress survives a reverse proxy. A Cloudflare tunnel buffers the whole
event stream until the response ends, which no origin header, padding or keepalive
prevents, so the UI sat at zero for the entire sync. `readSseJsonEvents` abandons a
stream that goes silent for 12s and the caller reconciles by polling, and
`job_events` now emits a keepalive so only a genuinely buffered stream trips it.

Also fixed: the job error names the files that could not be indexed instead of only
counting them; `Content-Disposition` carries the base name rather than the
folder-relative path; and `rag_home` places its studio home outside the macOS
denylist, where `/private/var/folders` made 48 of the 67 linked-folder tests fail.

Refactoring: `streamJobEvents` and `streamFolderSyncJobEvents` share one reader,
`sse-framing.ts` moves to `src/lib` so its frame splitter is the only one, and
`ensure_linked_folder_columns` upgrades both the vector and metadata connections.

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

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

* do not retire a project rag scope that was recreated during delete

The row delete commits before the workspace work, so another client can create a
project with the same id in that window. Retirement writes a permanent tombstone,
which left the new project unable to link folders or upload documents at all.

Retirement now rechecks ownership under the scope lock, and periodic reconciliation
drops the tombstone of any scope whose owner exists again, so a scope retired in the
remaining race recovers instead of staying dead.

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

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

* check project ownership under the scope lock before periodic retirement

The listing and the ownership check ran outside the lock that create_folder and
upload admission take, so a project recreated with the same id could link a folder
that periodic reconciliation then marked retired with auto_sync off. Dropping the
tombstone afterwards never restored those fields, leaving the folder unusable.

The check now happens inside the scope lock, so a link either precedes retirement
and blocks it, or is rejected by the tombstone.

* bound scope retirement to folders linked before the ownership check

The project rows live in studio.db and the folder rows in rag.db, so the ownership
check and the retirement write cannot share a transaction. A second backend process
sharing the same home can commit a folder link in that window, and retiring it left
auto_sync off with no path back: dropping the tombstone never restored those fields.

Retirement now only reaches folders created at or before the moment the scope was
found ownerless, so a link from another process keeps its own state.

Also close the source descriptor when the uploads root cannot be prepared. os.open
succeeded before ensure_dir raised, outside the try that closes it, so a read-only
or full uploads directory leaked one descriptor per file in the pass.

* [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: oobabooga <112222186+oobabooga@users.noreply.github.com>
Co-authored-by: danielhanchen <elliegouldingstuff@gmail.com>
Co-authored-by: Maheswar Kumar <110882203+mahiatlinux@users.noreply.github.com>
2026-08-10 05:21:09 -07:00
Daniel Han
ed656020cd
Start the bottom taper under the icon so the disc still reads round (#8321)
Easing from the icon centre line took the halo down to 75 percent of its
sideways value at 40pt and 47 percent at 60pt, which is inside the disc a
viewer actually sees, so the glow read as flattened along the bottom. The
taper now waits until 50pt, inside the icon's own lower half, and runs
over 30pt: 99 percent of sideways at 40pt, 80 percent at 60pt, and the
icon label sits on 9.3 percent mean tint against 10.0 before.

Regenerated on macOS with tiffutil, so the asset keeps the same two-page
structure and sRGB profile.
2026-08-10 01:06:59 -07:00
Wasim Yousef Said
67af9aa825
Desktop: unify normal release updater flow (#8298)
* CI: point desktop updater test at fork

* Studio: simplify desktop setup progress UI

* CI: limit updater A/B build to macOS and Windows

* CI: publish macOS and Windows updater test assets

* CI: build Linux and Windows updater test assets

* CI: pin updater test to Windows 2022

* Fix latest-main updater test workflow merge

* Resolve latest-main startup message merge

* Desktop: unify normal release updater flow

* Desktop: validate updater signatures

* Desktop: preserve generated updater signature

* Restore macOS and target the existing v release for PR #8298

Restores the macOS leg that was dropped from the release pipeline: the
macos-latest matrix entry, the .dmg and .app.tar.gz assets, the
darwin-aarch64 platform entries in latest.json, and darwin-aarch64 in the
required families of both release-desktop.yml and publish-desktop-updater.yml.
Without them no macOS bundle is published and macOS clients find no matching
platform in the manifest, so they stop updating entirely.

Targets the v{version} release that already exists instead of creating it.
The tag is cut when main is tagged, before this workflow is dispatched, so
the old "tag already exists" guard failed every run on this repository; it
only passed on a fork where the tags were absent. The guard now requires the
release to exist and refuses only when it already carries desktop assets,
naming the delete-asset commands to recover a failed publish.

Provenance is appended to the release body rather than replacing it, since
that body is the changelog. Windows step conditions follow the restored
windows-latest matrix entry.

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

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

* Address the review on PR #8298

Gate the asset and manifest uploads on the draft input. The target release is
already public, so a validation-only run was publishing unapproved binaries
and latest.json to it.

Move the provenance edit after the uploads and replace any earlier section
instead of skipping it, so a retry that follows a partial upload records the
digests that actually shipped rather than the previous build's.

Reject a prerelease target in both guards. GitHub cannot mark a prerelease
latest, so catching it only at promotion left the bundles already public.

Re-read GitHub latest immediately before promotion. The downgrade check runs
before a build that can take an hour, and promoting past a newer release would
hand every client an older manifest.

Build from the release tag rather than the dispatch ref. The release is
published before the workflow runs and main keeps moving, so the bundles could
come from unrelated source and provenance could record a SHA that is not the
tag's.

Skip the updater validation when the release carries no latest.json. The v
release is published before the bundles land, so the release event fired first
and failed on every release; it now fails closed only when the release cannot
be read. Scope the signature sweep to the desktop bundles, since the release
is shared.

Add the AGPL-3.0 header to the two new files, in the style the repository uses.

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

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

* Keep updater discovery on desktop metadata and record the built commit for PR #8298

* Send make_latest as the documented string for PR #8298

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

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

* Repair the release-creation tests and the publish-side guard for PR #8298

* Fail closed on promotion, order numbered prereleases and keep the pointer forwardable for PR #8298

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

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

* Resolve the newest desktop release lazily and record the updater pointer gap for PR #8298

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-10 01:01:39 -07:00
Maheswar Kumar
7ef653d1b7
soften the dmg background glow and clear it off the icon label (#8296)
* soften and even out the dmg background glow

The halo behind the app icon in the macOS install window read as a hard
saturated disc with a soft rim, and it fell off faster downward than in the
other three directions.

scripts/make_dmg_background.py:

- GLOW_STRENGTH 1.85 to 1.48 and GLOW_SIGMA 47 to 50, so the fully saturated
  core shrinks from about 52pt to 40pt radius and stays hidden under the 128pt
  icon Finder draws at the default DMG icon size
- GLOW_MIX_RADIUS 130 to 110, keeping the core-to-edge hue blend in step with
  the wider sigma
- drop GLOW_BOTTOM_FLOOR and GLOW_BOTTOM_SPAN and the downward easing in
  render_glow, so the halo is now radially even: 21.4 / 21.4 / 21.7 / 21.2
  percent at 90pt left, right, up and down

Removing the downward easing puts more tint behind the "Unsloth" icon label,
29.0 percent mean against 18.3 percent before. Black label text on that
background still measures 13:1 luminance contrast, so it stays legible.

studio/src-tauri/dmg/background.tiff is the regenerated two-page output, 660x400
plus the 1320x800 hidpi page.

Verified by building a real DMG with the tauri bundler's own bundle_dmg script
and the same arguments tauri-bundler passes, then opening the mounted volume in
Finder. tests/studio/test_tauri_branding_contract.py passes and ruff is clean.
No GPU checks apply.

* Pin the dmg background asset to its renderer

Add a contract test that rebuilds the two TIFF pages from
scripts/make_dmg_background.py and compares them to the checked-in
studio/src-tauri/dmg/background.tiff, so a constant change without a
regenerated asset fails instead of shipping.

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

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

* ease the dmg glow off the icon label

The even halo put 29.0 percent mean tint behind the "Unsloth" label under the
app icon. GLOW_BOTTOM_FLOOR 0.70 over a GLOW_BOTTOM_SPAN of 120pt takes that to
22.8 percent and leaves left, right and top at the full falloff, so the halo
still reads as round.

* Tighten the comment on the background art tolerance

* clear the dmg glow off the icon label

GLOW_BOTTOM_FLOOR 0.70 to 0.30 over a GLOW_BOTTOM_SPAN of 90pt instead of 120pt.
The "Unsloth" label under the app icon now sits on 10.0 percent mean tint,
down from 22.8, while left, right and top stay at the full falloff.

* Hold the dmg icon label to AAA contrast over the halo

The halo tint under the "Unsloth" label is the accessibility-relevant
part of this artwork, and the asset-matches-renderer check passes happily
if both sides move together. Assert the darkest pixel in the label band
keeps 7:1 luminance contrast against black text.

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

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

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-10 00:40:12 -07:00
Nilay
aab5c2c980
Desktop: fix audio files being rejected when dropped on the chat (#8271)
* Desktop: accept audio files dropped on the chat window

* Desktop: hold the send gate for dropped audio and reject multi-clip drops

* Desktop: keep dropped audio with its chat and cap image reads at the image limit

* Desktop: cancel parked sends when audio registration fails and hold dictation sends

* Desktop: stop double-toasting audio add failures and cancel on document failures

* Cover audio in the drop extension disjointness test for PR #8271

* Tighten the audio drop comments for PR #8271

---------

Co-authored-by: danielhanchen <elliegouldingstuff@gmail.com>
2026-08-09 21:51:19 -07:00
Daniel Han
c6ad59a7d1
Studio: stop leaking child processes on an abnormal exit (#8170)
* Studio: stop leaking child processes on an abnormal exit

A Windows user could not update Studio until they killed a stray python by hand:
the tool sandbox runs its payload under a shell wrapper, the kill path reaped
only the wrapper, and `unsloth studio update` then refused to run because a
process still held the managed environment.

- taskkill /T on Windows, so a tool payload cannot outlive its wrapper
- a console-close handler, since CTRL_CLOSE_EVENT never becomes a Python signal
  and the graceful shutdown was skipped entirely when the window was closed
- the desktop updater drains the app job before standing down crash cleanup, and
  re-arms it when the install never happens
- children are recorded on disk and swept at the next startup, which is the only
  reaper macOS has after a crash or a force quit
- the job status is logged instead of failing silently

Also fixes a liveness probe that used os.kill(pid, 0); on Windows that is
TerminateProcess, so it killed the process it was asking about.

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

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

* Review fixes: console handler must not touch signals, one child record per owner, drop the job drain

- the console handler ran _signal_handler on the thread Windows creates for the
  event, where signal.signal raises, so closing the window did no cleanup at all
- bound that work to the ~5s Windows allows before it kills the process
- one record file per owner pid: two Studios can share a home, and a single file
  let the second erase the first's children
- add a Windows process identity (creation time) and refuse to signal a pid that
  cannot be verified
- drop the whole-job drain: it would also terminate the WebView2 hosts, and
  cleanup_child_processes already taskkills the backend tree

* Harden the child record against a malformed or older file

A record that is not an object, or whose children are not dicts, raised out of
the startup sweep and would have stopped Studio from starting. Pair the Linux
start time with the command name as well, since start time alone has 10ms
granularity.

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

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

* Close the gaps the first pass left

Ctrl+C on Windows raised UnboundLocalError inside the console callback, where
the BOOL result is then undefined, so the event could be reported as handled and
Studio would not stop. The updater no longer re-arms kill-on-close before
relaunching, which would have made the old process kill the replacement it just
started, and it resets the exit-cleanup guard so a retry after a failed
installer still reaps the backend. The RAG embedder and cloudflared are recorded
like the other sidecars, a llama-server that survived a failed kill stays
recorded, the Windows kill path checks the captured creation time before
taskkill, and record writes are serialised.

* Give the Popen doubles the pid a real one always has

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

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

* Fail closed where the answer is not certain

The delayed Windows kill skips a captured pid it cannot verify, since the job
object still takes the tree at exit. An owner whose identity cannot be read
counts as live rather than gone, so a momentary ps failure no longer costs a
running Studio its sidecars, and that lookup pins TZ so a timezone change does
not read as a different process. A child that outlived terminate_all keeps its
record instead of losing the only handle on it, with zombies told apart from
survivors. The whole relaunch handoff is inside the recovery scope, so any path
that leaves this process running re-arms cleanup.

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

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

* Never signal a pid the graceful sweep cannot verify

terminate_all now applies the same test the startup sweep does: a pid whose
identity cannot be read is left alone and kept in the record for the next launch
to retry, rather than signalled on the chance it is still ours. The startup
reaper re-checks liveness before dropping a record, so a kill that did not take
stays reapable, and the breadcrumb unlink happens under the record lock so a
concurrent adopt cannot have its record deleted from under it.

* Only claim the guarantee when it is actually there

Linux startup probes prctl with the read-only PR_GET_PDEATHSIG before reporting
the parent-death signal as in force, so a seccomp or container policy that
blocks it is reported as such rather than as a guarantee nothing keeps. The
backstop sweep takes its snapshot under the lock the writes already hold.

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

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

* Record every lifetime-bound child, and reach a Windows tree without its leader

The DiffusionGemma runner and sd-cli were spawned with lifetime kwargs that
are empty on macOS and never recorded, so nothing could reap them. Both now
adopt at spawn.

The Windows tool capture also revalidated a pid that no longer exists once the
wrapper exits, which is the case it was added for; each tool tree gets its own
job object instead, with the pid path as the fallback. The startup breadcrumb
sweep takes the tree too, not just the leader.

Probe PR_SET_PDEATHSIG itself, since seccomp can filter prctl per operation,
and stop the post-update backend restart when kill-on-close cannot be re-armed.

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

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

* Give the diffusion runner its own group, and gate every restart path

The runner shares Studio's process group, so the startup sweep could only
signal the runner itself and its visual server kept the GPU. start_new_session
makes it a group leader, which is what _posix_terminate needs to killpg.

Skip and Restart is offered on every error, so gating only the recovery path
still let a user start a backend while kill-on-close was disarmed. The flag
moved to a ref both paths check.

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

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

* Reap a group whose leader is gone, and drop comm from pid identity

A recorded leader can exit first and leave its group running, and the
sweep then skipped the entry and deleted the record. The child's own
process group is recorded at adopt time (only when it leads one, never
Studio's) and signalled when the leader is gone. The group id is the dead
leader's pid, which the kernel holds while any task still references it
as a group, so it cannot belong to anyone else.

comm is mutable, so a worker calling prctl(PR_SET_NAME) or setproctitle
read as a recycled pid and was dropped unsignalled. Identity is the start
time alone; records written with starttime:comm still compare equal.

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

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

* Keep a group's record until the group is gone

forget_pid dropped the recorded group as soon as its leader was reaped, so
a shim exiting before its visual server took the only handle on that group
with it. The record is kept while the group still has members, and the
backstop then reaps it.

The Windows backstop now takes the tree, matching the startup sweep, and
the identity probe prototypes CloseHandle like every other handle-width
call in this module.

* Put a retained child's group back with it

terminate_all pops the recorded group before deciding what to do, and both
paths that put the pid back dropped it. A leader exiting later then left
nothing able to reach its descendants.

* Believe only confirmed kills in the sweep

taskkill returning nonzero was treated as success, so the documented
single-pid fallback never ran. A group that survived SIGKILL reported
itself resolved, which deleted the last handle on it. And one sweep pass
could skip a worker record whose owner was terminated later in the same
pass, so it repeats while it keeps finding things.

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

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

* Keep records the sweep could not resolve

A group that survived termination left unresolved false, so the breadcrumb
was deleted with the group still running; the backstop had the same gap. A
zombie owner also read as a live Studio and shielded all of its sidecars.

The retry path re-enters installUpdate and spawns the backend updater, so
every path that starts a child goes through the same re-arm check. The
revisit pass now reconsiders only records deferred for a live owner, so
nothing is signalled twice.

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

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

* Record tool subprocesses, and do not mistake zombies for a live group

The tool subprocesses lead their own session on POSIX but were never
recorded, so a force quit mid-call left them with nothing able to find
them. They are adopted at spawn and forgotten on confirmed exit.

A leader terminated while alive can leave its group behind, which is the
same loss of the only handle as the dead-leader case. Checked in both the
sweep and the backstop.

killpg(pgid, 0) succeeds for a zombie, so a finished group read as alive
and would have kept its record forever. Membership now ignores zombies.

cloudflared is adopted under the lifecycle lock, before a concurrent stop
can reap it and leave the adoption to record a recycled pid.

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

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

* Record the component installer, and never wait on a zombie

stream_installer passed the lifetime kwargs but never adopted, and those
kwargs are empty on macOS, so an installer outliving its owner kept
rewriting files under the next launch.

A zombie answers every liveness probe, so terminating one burned the full
grace period per record and reaped nothing. Zombies take the dead-leader
path, which still reaps a group they left behind.

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

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

* Reset the record lock after a fork, and ask the job whether it is armed

A fork while another thread was inside adopt_pid leaves the child with
the record lock held and no thread to release it, so the next adoption
there blocks forever. The child-side reset already rebuilt the spawner
lock and now rebuilds this one too.

A webview reload rebuilds the update hook with its re-arm flag back at
its initial value while the Windows job can still be disarmed, so the
first gate after a mount reads the job's own limit flags instead.

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

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

* Retry a child's identity, and treat an unanswerable query as disarmed

A ps that timed out once was recorded as no identity at all, and an
entry without one is never signalled, so that child survived every
later launch. The capture is retried while the process is still there.

On the desktop, a failed desktop_update_cleanup_armed is not evidence
that kill-on-close is in force, so the gate now fails closed and
re-arms.

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

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

* Start the component installer in its own session, and pin the owner identity

The installer spawns a validation llama-server, and PDEATHSIG reaches
only the direct child while the startup sweep can signal only what the
record names. Leading its own group puts the whole installer tree
within reach of both.

The owner identity is captured once through the same retry a child's
goes through: recorded as None, any process that later reuses the pid
reads as the owner still running and the children in that record are
never reaped. A fork child drops it, since its pid is not the
parent's.

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

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

* Keep the installer in the group the desktop kills, and heal a missing identity

Reverting the new session from the last round: the desktop stop path
force-kills this backend's process group, and a session of its own took
the installer out of it, so a wedged or SIGKILLed backend left the
installer still rewriting files. Group membership is the stronger
guarantee of the two; the record still names the installer for the
macOS sweep.

A child whose identity could not be read is retried on every breadcrumb
write while it is alive, so a probe that failed once no longer leaves an
entry nothing will ever signal.

A fork child also drops the inherited pid registries: adopting anything
would have written a record claiming its parent's children.

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

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

* Install the fork reset where the first record is written

It was registered only from the Linux spawn path, which returns before
that everywhere else, so on macOS a fork child kept this process's
children and a record it wrote later claimed them.

* Take the diffusion group down on stop, and do not wait on a zombie

The desktop shutdown only waits for the ordinary stop path, so that is
where the shim's own group has to go: the group is captured before the
wait reaps the leader and killed once the leader is gone. The session
of its own stays, since it is what lets the startup sweep reach a
visual server after a crash.

A group holding nothing but a zombie answers killpg(pgid, 0), so
without a members check every stale record cost the full grace period
where pid 1 does not reap.

A ps that exits nonzero is not an empty group: reporting it as one let
forget_pid drop the only record of a live descendant.

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

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

* Studio: keep the re-arm failure message off the Studio branding

* Studio: do not spend the shutdown budget waiting on a child that has exited

An exited child nobody has waited on answers signal 0 exactly like a live
one, so the terminate wait spent its full timeout on a process that was
already gone, once per tracked child and in series. Measured 15.1s for three
of them, 0.16s after this.

The group path keeps waiting while a member is still running, since that is
what holds the GPU. The state read costs a fork off Linux, so it happens
twice a second rather than at the poll rate.

* Studio: answer the group check from the leader instead of scanning every process

Enumerating a process group reads the state of every process on the machine,
which on a busy box is 60ms+, and forget_pid did it on each stop. A leader
that is running already settles the question, and a pid that was never
recorded has neither a group to check nor a record to rewrite. Measured
62ms -> 0.01ms per stop; the leader-has-gone case still falls through to the
scan, which is what finds a child holding the GPU behind it.

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

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

* Studio: reach the installer's validation server, and never start a backend under a disarmed job

The installer starts a llama-server to validate a build. It is a grandchild
of the backend, so a parent-death signal reaches the installer alone and the
sweep had nothing recording where the server was: an abnormal exit left it
holding the GPU and the staged files. The installer now announces it on
stdout, in its own process group, and the update flow adopts it for as long
as it runs.

On Windows the armed check moves to the path that spawns: the UI gate runs
per update action, but a webview remount can start a backend on its own,
which is exactly the orphan the job object exists to prevent.

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

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

* Studio: clear the cleanup guard with the re-arm, and survive a malformed record

Re-enabling the Windows job on the spawn path left TERMINATION_CLEANUP set,
so the next update attempt read cleanup as armed, skipped the resume, and its
pre-exit hook suspended kill-on-close without stopping the backend first.

An identity read back from a record can be any JSON value; reaching split()
with a number raised through the whole startup sweep, so one bad file left
every other orphan running. Non-strings now read as unverifiable, which keeps
the pid unsignalled rather than trusted.

A group whose members exited on the SIGTERM keeps answering killpg(pgid, 0)
where pid 1 does not reap, so the reap now rechecks membership instead of
waiting out the grace period.

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

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

* Studio: keep the record when taskkill leaves the tree standing

The leader-only fallback runs when the job object was unavailable, which is
exactly when the record is the only handle on those workers. Both callers
read the dead leader as the tree being gone and dropped it, so anything that
survived became unreachable. The tree kill now reports whether it took, and a
failure keeps the pid tracked and its record on disk for the next launch.

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

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

* Studio: wait for the group, not the leader, before dropping a record

The installer announced its validation server as stopped as soon as the group
leader exited, which drops the record while a child that ignored the SIGTERM
is still holding the GPU. It now waits for the group to empty, escalates to
SIGKILL, and only announces the stop once nothing is left.

An installer timeout killed the installer alone and left the announced server
for a sweep that never runs while this process lives; those children are now
terminated with it.

The diffusion group id is kept from the spawn, so a shim that exited before
the kill path (a failed health check, a crash before a reload) no longer
leaves its visual server with nothing able to reach it.

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

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

* Studio: make the diffusion group assertion formatting-agnostic

* Studio: stop announced validation servers on any installer exit

The timeout path took them; a nonzero exit or a stream that ended mid-line
left them running. This process stays up after an update failure, and its own
live record shields those pids from a sweep that would not run anyway, so the
cleanup now happens in the finally that covers every way out.

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

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

* Studio: reach a validation server whose leader or tree kill is gone

terminate_pid falls back to the recorded process group when the leader has
already exited, keeps the record when a Windows tree kill could not be
confirmed, drains the announced children under a lock so the watchdog and the
reader thread cannot race, and the installer arms the parent-death signal on
the validation server it puts in a session of its own.

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

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

* Keep the diffusion runner in the group the desktop stops, and check identity before a single-pid kill

The desktop stops this backend by signalling its process group and force-kills
it five seconds later, so a runner in a session of its own survives a backend
that is slow to shut down and keeps the GPU until the next launch sweeps it.
Put it back in that group, as the component installer already is, and reach the
visual server by walking the runner's children instead of killpg. The cached
group id goes with it: a pid is reusable once nothing holds the number as a
process group any more, so an id kept past its group eventually names a stranger.

terminate_pid signalled on the pid alone. An announced validation server can
exit without the line that clears it, so run the same identity test terminate_all
does before either termination branch.

* Keep a macOS validation server in the installer's process group

The server was started in a session of its own everywhere, but only Linux can
pair that with a parent-death signal. On macOS it left the group Studio
force-kills while the only record of it is the announcement the backend has yet
to read, so a kill in that window orphaned it with nothing able to find it.

It stays in the inherited group there, and the kill path only reaches for killpg
when the server actually leads a group, so a shared group is never signalled.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-09 08:06:29 -07:00
Daniel Han
eb892d6716
Desktop: keep probing the announced backend port instead of discarding it (#8211)
* Desktop: keep probing the announced backend port instead of discarding it

The backend announces its port once, on a TAURI_PORT stdout line. That line
arrives while it is still importing torch, and validate_candidate_port probed
exactly once with a 2 s per-request budget. When the backend could not answer
in time the only announcement was dropped, and the window went on waiting for
a backend on the port the backend had already reported it could not bind,
until BACKEND_START_DEADLINE expired 300 s later.

Seen on a CPU-only Windows machine where 8888 was taken and the backend moved
to 8889:

  16:51:59 [backend] TAURI_PORT=8889
  16:52:01 [WARN] Ignoring unverified TAURI_PORT candidate 8889
  16:56:37 [error] The Unsloth backend did not start within 300 seconds

The backend was up the whole time. Its own log for that second records
/api/liveness taking 2156 ms against the 2000 ms LOCAL_HTTP_TIMEOUT. Healthy
runs on the same machine validated in 735 ms, 792 ms and 2295 ms, so this is a
race the fast path wins most of the time and loses on a cold start.

Retry until the backend answers or BACKEND_START_DEADLINE passes, rather than
giving the slowest moment of startup a single attempt. The loop stops early if
the generation changes or another path claims the port, so a restarted backend
does not leave an old probe running.

* Desktop: back off between port-verification probes

A probe is a liveness request plus a desktop-login request, each up to
LOCAL_HTTP_TIMEOUT, aimed at a backend that is by definition busy. Retrying at
a flat 2 s would have put roughly 90 requests into a 100 s torch import, adding
load to the slow start being waited on.

Double from 250 ms to a 5 s cap instead. The common race, where the backend is
a few hundred ms from ready, now resolves in about 2.2 s rather than 4 s, and
the long waits cost far less: about 18 probes rather than 26 across a 100 s
import, and 46 rather than 75 if the backend never answers at all.

* Desktop: share one start deadline between the watchdog and port validation

Port validation started its own 300 s clock when the TAURI_PORT line arrived,
which is well after the process starts: 23 s later in the report that prompted
this. So the retry loop could outlive the watchdog, and a validation that
succeeded in that gap emitted server-port after server-start-timeout had
already put the window in an error state. The frontend's server-port handler
sets the api base but does not clear backendError or startingRef, so that
combination leaves a healthy validated backend behind a permanent error screen.

Capture the deadline once at spawn and pass it to both. It is taken before
start_watchdog spawns its thread, so validation can only ever end at or before
the timeout it must not outlive.

* Desktop: check the start deadline before accepting a validated port

The deadline was only consulted after a failed probe, so a success could still
be emitted past it by two routes: the TAURI_PORT line itself arriving late, and
a probe that starts just under the deadline but finishes over it. The watchdog
does not kill a timed-out backend, so both are reachable on a slow start, and
either one puts server-port after server-start-timeout, which strands the
window in an error state it has no handler to leave.

Check before each probe and again before accepting one, and cap the sleep to
the time left. A port verified too late is now dropped with its own message
rather than reported as unverified, and an announcement that arrives after the
deadline says so instead of claiming zero attempts.

* Desktop: let only one of the start timeout and the port claim reach the window

Checking the clock and claiming the port were still two steps with a gap. A
probe could succeed just inside the deadline and then be descheduled, or wait
on the state mutex, long enough for the watchdog to observe port == None and
commit to server-start-timeout; validation would then assign the port and emit
server-port on top of it, which is the stranded error screen again.

Make the two outcomes mutually exclusive in the state they already share. The
watchdog sets start_timed_out while still holding the mutex it used to decide,
and port validation refuses to claim when that flag is set. Both generation
bumps clear it alongside the other per-start fields, so a restart starts clean.
2026-08-09 05:56:21 -07:00
Nilay
4c6aa87779
Desktop: confirm quit during training (#7790)
* Desktop: confirm quit during training on Cmd+Q

* Desktop: confirm Dock quit and logout during training

* Desktop: defer termination reply until quit confirmation resolves

* Desktop: keep overlapping macOS termination pending

---------

Co-authored-by: Etherll <61019402+Etherll@users.noreply.github.com>
2026-08-09 14:09:21 +03:00
oobabooga
4532df20e0
Desktop: stop accidental reloads in the Windows WebView (#7945)
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-08 23:43:38 -07:00
Wasim Yousef Said
161a3017df
Studio: eliminate Cargo and Clippy warnings (#8192) 2026-08-08 19:01:20 +02:00
Daniel Han
b65cae5bd8
Studio: make the gallery video link absolute so the desktop app can play and save it (#8173)
* Studio: make the gallery video link absolute so the desktop app can play and save it

The signed MP4 link is minted relative so it survives whatever proxy serves the
page, but its consumers are <video src> and the download anchor, neither of which
goes through authFetch. Under Tauri the document origin is the webview, so the
relative path resolved there instead of against the backend and returned the SPA
shell: a black player, a blank gallery thumbnail, and a Download that saved
index.html renamed to .mp4.

Route the link through apiUrl(), which is what the RAG document preview already
does for the same reason and is a no-op in the browser, so the proxy behaviour the
backend docstring protects is unchanged.

Two things fell out of that. The MP4 save used a hand-rolled anchor rather than
the native-files helpers every other download in the app moved to, so it bypassed
the OS save chooser on desktop; both it and the WebM/GIF export now go through
downloadUrl/downloadFile and report a cancelled save as a cancel. And the CSP let
127.0.0.1 through connect-src but not media-src, so an absolute link would still
have been blocked in the player even once it pointed at the right origin.

Fixes #8168

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

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

* Name the MP4 and WebM save filters for video as well as audio

Routing the gallery's Download through the native chooser sends .mp4 and .webm
into save_filter for the first time. Both arms were labelled audio-only, from when
the audio player was their only caller, so the desktop save dialog offered "MPEG-4
audio" for a video clip. The extension lists are unchanged, so what the dialog
accepts is exactly what it accepted before.

* Stream the gallery MP4 save instead of taking it through IPC

save_native_file carries the bytes across the IPC boundary, so the caller has to
buffer the whole body and the chooser only opens once that finishes, with Rust then
holding the request for as long as the dialog is up. A clip is capped at 2048x2048
by 1024 frames, so that is the wrong shape here: save_native_file_from_url opens the
chooser first and streams the response to the chosen path, leaving nothing resident
on either side. The URL is pinned to the loopback backend so the command cannot be
aimed at an arbitrary host, and the write is staged beside the destination so a
failed download never replaces a real file.

WebM and GIF go back to the blob and anchor they already used. Moving them onto the
native helper added an IPC copy of a VP9 export that transcode_to_file had
deliberately kept off the heap, and nothing about this fix required touching them.

* Tighten the video save comments and drop a stale filter assertion

The save-filter test still claimed WebM and GIF reach the native dialog, which stopped
being true when they went back to the blob and anchor. It now covers the MP4, the one
export that does; the WebM filter is still exercised by the chat-attachment case.

* Parse the streaming save URL and pin the client to loopback

Three holes in the loopback guard, all on the new command:

The host was taken by slicing the authority, so http://127.0.0.1:8888@evil.test
passed: everything before the '@' is userinfo, and the client would have connected
to evil.test. It now parses the URL, rejects credentials and a non-http scheme, and
tests the parsed host, which also accepts a bracketless [::1] the slice mishandled.

reqwest::get honours HTTP_PROXY, so a desktop launched behind a proxy without a
matching NO_PROXY would have sent the signed link there and failed after the user
picked a destination. It also follows redirects, so an accepted loopback URL could
bounce the download to any host once the check was already behind it. Both come
from loopback_http::streaming_client now: no_proxy like the client beside it, no
redirects, and a connect-only deadline since a whole clip outlasts a total one.

* Bound each read of a streamed save, not just the connect

connect_timeout only covered establishing the socket, so a backend that accepted the
request and then went quiet left chunk() waiting forever: the command never resolved
after the user picked a destination, and the staging file sat beside it. A total
timeout is the wrong tool, since a large clip legitimately outlasts one, so the
streaming client now sets read_timeout, which bounds the headers and then restarts
per chunk. A save that is still receiving is never cut short; one that stalls fails
and drops the staging file with it.

* Trim the streaming save comments

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-08 08:44:48 -07:00
Daniel Han
68eefd3ccf
Desktop: show a closing overlay while the backend shuts down (#8151)
* Desktop: show a closing overlay while the backend shuts down

Quitting blocks for up to ~15s reaping the backend, and the window sat
there looking frozen for all of it. request_quit now raises an overlay
before the confirmations run, so every quit entry point that reaches it
is covered, and retracts it again if a confirmation declines or the reap
panics.

The overlay layers over the app rather than replacing it: a declined quit
has to hand back the tree the user had, in-flight generations and
debounced drafts included.

* Desktop: raise the closing overlay after the quit confirmations, not before

The overlay went up as the first thing on the quit thread, so a quit that
hit a confirmation dialog painted "Closing Unsloth Desktop... Shutting
down the backend" behind a question asking whether to keep training. It
now goes up once all five confirmations have passed, which still puts it
ahead of the reap, and the reap is the whole of the wait.

The close button loses its optimistic raise for the same reason: it fires
before Rust has decided anything. What it saved was one IPC hop against a
teardown measured in seconds.

quit_sequence carries the ordering so it can be tested. app-closing-cancelled
survives for the one path that still needs it, a panicking reap, which
leaves the app up under an overlay that covers the titlebar.

That cover is also why the overlay now offers a force quit after 20s, past
stop_backend's own worst case: a teardown that wedges past its timeouts
left the window with no controls and nothing to click.

* Desktop: raise the closing overlay on Windows only

macOS closes to the tray and Linux quit without an overlay before this branch. Only Windows showed the freeze the overlay covers, where stop_backend spends its liveness, shutdown and CTRL_BREAK budgets in series.

quit_sequence takes the decision as an argument rather than reading cfg! inside, so both platforms stay covered by tests wherever they run. Off Windows the guard is None: nothing raised, so an unwind has nothing to retract.

* Desktop: keep the closing overlay clickable and make force quit safe

The overlay is the only thing on screen during a quit, and its Force quit
button was dead whenever a modal was open behind it. Radix's DismissableLayer
parks pointer-events:none on <body> for as long as any modal layer is open and
restores it only on unmount, and pointer-events inherits, so the overlay
inherited none and went click-through onto the dialog it was covering. It is
reachable: the titlebar keeps the window controls pointer-events-auto, so the X
is clickable while a modal is open, and no quit path dismisses the modal first.
Give the overlay an explicit pointer-events-auto, which is the same escape
hatch Radix uses for its own layer.

Raise FORCE_QUIT_AFTER_MS from 20s to 35s. The bounded Windows teardown is
about 18s, not the 15s the comments claimed: stop_spawned_backend spends up to
2 liveness and 2 shutdown requests at LOCAL_HTTP_TIMEOUT, then waits twice for
the child to exit either side of the CTRL_BREAK, while the installer's and
updater's 5s graceful waits are cfg(unix) and cost Windows nothing. At 20s the
escape hatch could be offered during a shutdown that was merely slow and about
to succeed. Correct both comments to the Windows shape.

force_quit now takes the AppHandle and calls cleanup_before_exit() first.
std::process::exit runs no destructors and the tray icon only sends its
NIM_DELETE from Drop, so the icon outlived the process as a ghost until the
user hovered it. Tauri pairs the same two calls in AppHandle::exit.

Guard force_quit to Windows. There the app job object is kill-on-close with
this process as a member, so exiting takes the backend tree down with it.
Elsewhere the backend is its own process-group leader with nothing holding it,
which is why setup_unix_termination_signals exists, and exiting would orphan
it. Unreachable from the shipped frontend, but it is a registered command.

Also reword the ClosingOverlay comment. It justified the guard with a panic
under the reap, and there is none: the one .expect in cleanup_child_processes
reads a ShutdownFlag managed on the line after the BackendState gating it, and
everything below recovers poisoned locks rather than unwrapping them. The guard
stays, it is free and the right shape.

* Desktop: terminate the backend tree when force quit has no job object

Force quit gated its exit on cfg!(target_os = "windows"), reading that as
"the kill-on-close job object will reap the children". windows_job::initialize
does not guarantee that: a failed CreateJobObjectW or AssignProcessToJobObject
is logged and swallowed, the handle is stored as None, and cleanup is left to
the explicit stop paths. std::process::exit runs none of those, so on a machine
where the job could not be created, Force quit after a wedged shutdown orphaned
the whole backend tree with its port and GPU memory still held.

Branch on the reaper instead of the platform. windows_job::has_active_job
reports whether a job object is really holding this process, and when it is not,
force quit terminates the backend tree itself before exiting. Gating alone was
not an option: force quit is only offered once the reap is past its own timeouts,
so declining there would paint a button that does nothing.

The pid cannot come from BackendState. stop_backend_inner takes the spawned
handle out of the state under the lock and only then blocks on the reap, so at
force-quit time the state holds None and the pid sits in a local on the stuck
thread. Mirror it into an atomic at spawn instead, retire it by compare-exchange
once the process is confirmed gone so a recycled pid never becomes a target, and
read it lock-free on the way out. The taskkill wait is bounded and gives up
rather than block, since the user is already done waiting.

* Desktop: give force quit a kill target for every child cleanup stops

cleanup_child_processes runs stop_install, then stop_update, then
stop_backend, and on Windows the first two go straight to
force_kill_process_tree, whose taskkill wait and follow-up child.wait()
are both unbounded. So a quit can wedge on the installer or the updater
and never reach the backend at all. The no-job force-quit fallback knew
only about SPAWNED_BACKEND_PID, which install and update children never
populate, so it exited and left the tree it was actually stuck on
running, with its temp files and its half-written venv.

Give the installer and the updater the same lock-free treatment the
backend already had. TrackedChildTree generalises the mirrored pid: it
is recorded at spawn beside the handle going into the state, read on the
way out with no lock to contend for, and retired by compare-exchange so
a late stop cannot clear a restarted child's record. Force quit sweeps
all three in cleanup order.

The taskkill sweep launches every kill before waiting on any of them and
shares one budget rather than handing each tree its own, so the escape
hatch cannot be talked into a longer stall by having more children to
clean up, and a slow first tree cannot cost the rest their reaper.

Retire each pid at the point its child stops being a live target, not
after the whole stop returns. The record is only sound while we hold the
handle: on Windows the kernel keeps a process object, and its pid, alive
exactly as long as a handle to it is open, so a record that outlives the
handle can name a pid that has since gone to a stranger. stop_spawned_backend
now retires in every terminal branch before removing the owner metadata,
which is a filesystem delete that can stall, and install and update
retire in their stop paths and wherever wait_for_exit lets the handle go.

* Desktop: drop force quit and skip the overlay when no window is showing

Remove the force quit escape hatch from the closing overlay. It never
restored anything the overlay took away: begin_quit already turned a
second close press, Alt+F4 and the taskbar close into silent no-ops for
the life of the reap, so a wedged teardown had no escape before this
branch either. The overlay leaves it strictly better by explaining the
freeze instead of just showing it.

Going with the button: the force_quit command and its registration, the
per-tree pid records mirrored out of the state mutexes, the taskkill
sweep over the installer, updater and backend trees, and
windows_job::has_active_job. install.rs, update.rs, process.rs and
windows_job.rs are back to untouched.

Gate the overlay on the main window being visible. The tray's Quit
reaches request_quit without going through the window, and an autostart
launch passes --hidden, whose window is built "visible": false and never
shown, so the overlay would paint into a window nobody can see. Skip it
rather than showing the window: there is no frozen window to explain,
and one that pops open because you asked the app to go away is a
surprise. A minimized window still counts as visible, because
IsWindowVisible reports WS_VISIBLE and minimizing does not clear it, so
restoring mid-reap finds the overlay rather than the freeze.

The visibility is read only after the confirmations pass and only on
Windows, and an unreadable one raises the overlay anyway.
2026-08-08 08:43:18 -07:00
oobabooga
bb2bab70a3
Studio: surface API errors and align audio limits (#7946)
* Studio: surface chat errors and align audio limits

* Studio: align desktop audio paste limit

* Studio: skip empty native clipboard files

---------

Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
2026-08-08 13:38:27 +02:00
dependabot[bot]
61f6beed4c
Bump the cargo-tauri group across 1 directory with 18 updates (#8107)
Bumps the cargo-tauri group with 17 updates in the /studio/src-tauri directory:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.11.2` | `2.11.5` |
| [tauri-plugin-single-instance](https://github.com/tauri-apps/plugins-workspace) | `2.4.2` | `2.4.3` |
| [serde](https://github.com/serde-rs/serde) | `1.0.228` | `1.0.229` |
| [serde_json](https://github.com/serde-rs/json) | `1.0.150` | `1.0.151` |
| [base64](https://github.com/marshallpierce/rust-base64) | `0.22.1` | `0.23.1` |
| [reqwest](https://github.com/seanmonstar/reqwest) | `0.12.28` | `0.13.2` |
| [tokio](https://github.com/tokio-rs/tokio) | `1.52.3` | `1.53.1` |
| [log](https://github.com/rust-lang/log) | `0.4.32` | `0.4.33` |
| [regex](https://github.com/rust-lang/regex) | `1.12.4` | `1.13.1` |
| [open](https://github.com/Byron/open-rs) | `5.3.3` | `5.4.0` |
| [tauri-plugin-dialog](https://github.com/tauri-apps/plugins-workspace) | `2.7.1` | `2.7.2` |
| [rand](https://github.com/rust-random/rand) | `0.10.1` | `0.10.2` |
| [time](https://github.com/time-rs/time) | `0.3.47` | `0.3.55` |
| [libc](https://github.com/rust-lang/libc) | `0.2.186` | `0.2.189` |
| [signal-hook](https://github.com/vorner/signal-hook) | `0.3.18` | `0.4.4` |
| [elevated-command](https://github.com/vangork/elevated-command) | `1.1.2` | `1.1.3` |
| [winreg](https://github.com/gentoo90/winreg-rs) | `0.10.1` | `0.55.0` |



Updates `tauri` from 2.11.2 to 2.11.5
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.11.2...tauri-v2.11.5)

Updates `tauri-plugin-single-instance` from 2.4.2 to 2.4.3
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/fs-v2.4.2...fs-v2.4.3)

Updates `serde` from 1.0.228 to 1.0.229
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.228...v1.0.229)

Updates `serde_json` from 1.0.150 to 1.0.151
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.150...v1.0.151)

Updates `base64` from 0.22.1 to 0.23.1
- [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.1...v0.23.1)

Updates `reqwest` from 0.12.28 to 0.13.2
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.12.28...v0.13.2)

Updates `tokio` from 1.52.3 to 1.53.1
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.3...tokio-1.53.1)

Updates `log` from 0.4.32 to 0.4.33
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.32...0.4.33)

Updates `regex` from 1.12.4 to 1.13.1
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.12.4...1.13.1)

Updates `open` from 5.3.3 to 5.4.0
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.3.3...v5.4.0)

Updates `tauri-plugin-dialog` from 2.7.1 to 2.7.2
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/log-v2.7.1...dialog-v2.7.2)

Updates `rand` from 0.10.1 to 0.10.2
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/0.10.1...0.10.2)

Updates `time` from 0.3.47 to 0.3.55
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](https://github.com/time-rs/time/compare/v0.3.47...v0.3.55)

Updates `libc` from 0.2.186 to 0.2.189
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.189/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.186...0.2.189)

Updates `signal-hook` from 0.3.18 to 0.4.4
- [Changelog](https://github.com/vorner/signal-hook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vorner/signal-hook/compare/v0.3.18...v0.4.4)

Updates `elevated-command` from 1.1.2 to 1.1.3
- [Commits](https://github.com/vangork/elevated-command/commits)

Updates `winreg` from 0.10.1 to 0.55.0
- [Release notes](https://github.com/gentoo90/winreg-rs/releases)
- [Changelog](https://github.com/gentoo90/winreg-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gentoo90/winreg-rs/compare/v0.10.1...v0.55.0)

Updates `tauri-build` from 2.6.2 to 2.6.3
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v2.6.2...tauri-build-v2.6.3)

---
updated-dependencies:
- dependency-name: base64
  dependency-version: 0.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-tauri
- dependency-name: elevated-command
  dependency-version: 1.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: libc
  dependency-version: 0.2.189
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: log
  dependency-version: 0.4.33
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: open
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-tauri
- dependency-name: rand
  dependency-version: 0.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: regex
  dependency-version: 1.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-tauri
- dependency-name: reqwest
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-tauri
- dependency-name: serde
  dependency-version: 1.0.229
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: serde_json
  dependency-version: 1.0.151
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: signal-hook
  dependency-version: 0.4.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-tauri
- dependency-name: tauri
  dependency-version: 2.11.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: tauri-build
  dependency-version: 2.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: tauri-plugin-dialog
  dependency-version: 2.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: tauri-plugin-single-instance
  dependency-version: 2.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: time
  dependency-version: 0.3.55
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-tauri
- dependency-name: tokio
  dependency-version: 1.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-tauri
- dependency-name: winreg
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-tauri
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 12:53:01 +02:00
Souravrajvi0
f8497dc328
Fix desktop image drops for chat attachments (#7982)
* Fix desktop image drops routed to chat attachments (Fixes #7963)

Give the native drop classifier its own image extension whitelist
matching the chat composer, without widening RAG_UPLOAD_ACCEPT.
Extend Rust native attachment intake so register_native_attachment_path
accepts the same image types end-to-end.

* Route native image drops to chat vision attachments

Classify image drops separately from RAG documents, queue them for the
composer, and read dropped image bytes through Tauri so vision
attachments no longer fail on the document upload path.

* fix(studio): bind native image drops to target chat composer

Verify the attachment target key before each addAttachment and re-queue
remaining intents when the user switches chats mid-import. Align the
desktop reliability contract with the 30px titlebar control size.

* fix(studio): harden native image drop attachment handling

Re-queue remaining intents on composer unmount or target change, and
handle per-image failures without dropping later valid attachments.
Align the desktop reliability contract with the 30px New chat button.

* fix(studio): stabilize native image drop drain and send gating

Subscribe to the image queue instead of tying the drain worker to
hasPendingImageAttachments, return after requeueing failed suffixes,
block send/queue while images materialize, and allow image drops without
backend path leases.

* fix(studio): close the remaining native image-drop send-gate and read holes

- dictation: the recording bar's send now waits on materializing image drops
  instead of spending the held send on a submit that shouldBlockSend bounces
- drain: a drain for a target the composer has already left no longer clears
  the shared materializing flag out from under the live target
- rust: cap the attachment read itself, so a file that grows after the stat
  can't get past the 20 MiB limit

* fix(studio): stop a rejected image drop toasting once per file

VisionImageAdapter.add already toasts before it throws, and every rejection
it can reach on this path is chat-wide (no model, or not multimodal) rather
than per-file, since Rust enforces the same 20 MiB cap. Requeueing the rest
of the batch just read each image over IPC to reject it again, so a 3-image
drop onto a text-only model produced six toasts. Stop at the first failure,
matching how the document drop path reports a batch-wide refusal once.

* test(studio): guard the drop MIME seam the same way as the extension seam

#7963 was two features sharing one list until it drifted. The image drop path
has a second seam of that shape: native_intents.rs stamps the File's MIME type
and the composer routes to an adapter by MIME, with nothing tying the two
together. Pin them, so a format added to one side can't silently stop being
droppable.

* fix(studio): hold a parked send for a late image drop

A send parked behind document indexing fired the moment indexing cleared,
without re-checking the gate it passed on the way in. Drop an image onto
that draft and the text goes out without it, and the image attaches to the
next draft instead.

* fix(studio): close the last two image-drop send-gate holes

- registration: the gate only saw the queue, which the intents don't reach
  until Rust has answered. An Enter in that window sent the text alone. The
  count is deliberately not keyed: until the intents land there is no settled
  target, since an implicit single chat re-keys the moment its thread exists.
- failure: a parked send fired when a dropped image failed to materialize,
  turning a failed drop into a send of the text without it. Cancel the parked
  send instead, so the user keeps their text and the toast explains why.

* test: update runtime gate contract for launcher transaction

The update() path now wraps setup in _WindowsLauncherUpdateTransaction
instead of _release_self_exe_lock_windows. Align the ordering assertion
with the current studio.py structure.

* test: align titlebar contract with main's 4px/9px tokens

provider.tsx now uses --studio-titlebar-navigation-offset-y 4px and
--studio-chat-header-padding-top 9px; update the desktop reliability
contract assertions to match.

* Close the remaining image-drop failure paths for PR #7982

Registration failures, listener teardown and partial batches each lost a
dropped image or sent the text without it. Also binds the native read to
the fingerprint the token was validated against.

- store: imageDropFailures, bumped when a drop fails before it queues, so
  the composer can drop a parked send instead of sending the text alone.
- use-native-drop: register per path (allSettled) so one bad file no
  longer discards its siblings' leases, and enqueue registered intents
  even when the listener was disposed mid-flight.
- thread: requeue a drained batch under the key the same composer moved
  to, so a fresh chat persisting mid-batch no longer strands it; guard
  the parked send on pendingSendRef for a cancel in the same commit.
- native_intents: open with O_NOFOLLOW and recheck size and mtime against
  the entry, so a file swapped in after validation is not read.
- tests: extension to MIME arm parity, composer picker parity, doc/image
  disjointness, and four Rust tests for the read path.

* Fix the new Rust attachment tests for PR #7982

unwrap_err needs the Ok type to be Debug, and NativeAttachmentFile is not:
it carries the base64 image, which a panic should never print. Match on the
Err instead. Caught by cargo test on all three platforms.

* Tighten the comments added by PR #7982

Comment-only. Trims the multi-sentence blocks this PR introduced down to the
load-bearing why, and drops two that the code already says. AST-gated: no code,
string literal or test name moved.

* Coalesce image-drop failures and fix the rekey ownership test for PR #7982

- A read failure now carries on to the next image and reports once at the end
  of the drain, instead of one toast per file when a whole batch is unreadable.
- The addAttachment catch drops its toast: VisionImageAdapter.add already
  toasts before it throws, and its only other throw is the 20 MiB cap that
  Rust enforces first.
- Both cancels test composer identity, matching requeueKey, so a fresh chat
  rekeying mid-read still cancels its own parked send.

* Clear held dictation sends and fix the drop overlay copy for PR #7982

- cancelQueuedSend clears the dictation hold as well: a transcript held behind
  the same block otherwise submits itself once the block clears, without the
  image the drop failed to attach.
- The drag overlay claimed "Indexed for this chat" for images, which are never
  indexed. The attach state now carries its kind and the copy follows.

* Hand image batches across a composer remount, and park sends behind them for PR #7982

- A fresh chat persisting remounts Composer, so the outgoing instance cannot
  hand its queue to the new key: it tags the batch with its composer identity
  and the next instance with that identity claims it on mount.
- interceptSend parks the send while images materialize instead of swallowing
  the click, with wait-toast copy that names what it is waiting for.

* Claim late image handoffs and route both send gates through one park for PR #7982

- The replacement composer's claim ran once at mount, so a predecessor that
  requeued after that point left its batch stranded. The claim is reactive now,
  watching imageDropOwners for an entry tagged with this composer.
- handleSubmit has its own gate ahead of interceptSend, so the image park was
  unreachable from a click or Enter. Both gates call one parkIfWaitingOnImages.

* Tighten the code comments added in this PR for PR #7982

---------

Co-authored-by: LeoBorcherding <borchborchmail@gmail.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-08 02:28:14 -07:00
oobabooga
fbf923e5b0
Desktop: ensure backend ownership for Tauri installs (#8034)
* Studio: recover desktop backend ownership without a root ID

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

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

* Read an existing desktop ownership id before taking the install lock

ensure_studio_root_id_at took the advisory lock before reading, so an
unwritable share/ (read-only mount, full disk, restrictive ACL) made
start_backend fail even when a valid id was readable. Before this branch
that path only read the file, so this was a new way to block startup.

Read first and only lock when an id has to be created, then re-read under
the lock so a concurrent creator still wins. The directory is still
hardened on the read path. Also pin rust-version, since std::fs::File::lock
needs 1.89.

cargo test --no-fail-fast: 145 passed.

* Harden the ownership id path for Windows and pin down preflight classification

write_private_file now flushes on non-unix too, so a crash right after
publishing cannot leave a zero-length id where the unix path is durable.

Two test fixes for the Windows CI leg: the lock contender handle is dropped
before remove_dir_all, since Windows refuses to delete a file with an open
handle, and the concurrency test filters share/ by name instead of asserting
a bare entry count.

Adds backend_root_status coverage for the case that looks riskiest about
creating an id at startup: a backend predating ownership reports no id or an
empty one, so it stays AmbiguousRoot and is still reported as a conflict
rather than being adopted or double-started. Only a valid different id is
ForeignRoot.

cargo test --no-fail-fast: 192 passed.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-07 23:58:38 -07:00
oobabooga
69d555b98e
Studio: check llama.cpp cache access before setup (#8032)
* Windows: preflight managed llama.cpp cache access before setup and update

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

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

* Fix denied llama.cpp cache update errors

* Trim canonical paths on both branches and probe listing on POSIX for PR #8032

Follow-ups from reviewing the denied llama.cpp cache preflight.

Get-CanonicalDir only trimmed a trailing separator on the lexical branch.
Resolve-Path preserves one, so "...\studio\" compared unequal to "...\studio"
and Test-StudioHomeIsCustom called the default home custom. Get-ManagedLlamaCppDir
then handed the preflight ...\studio\llama.cpp instead of the real cache, so a
denied cache went undetected. The trim now runs after both branches, still
guarded so a path root keeps its separator.

setup.sh gated the prebuilt install on _studio_dir_unsearchable, which probes
search (+x). Mode 111 passes that and still raises PermissionError inside
install_llama_prebuilt.py, which lists the tree. Added _studio_dir_unreadable
for call sites that list or replace rather than probe a known child.

setup_fail only emitted [TAURI:ERROR] under UNSLOTH_TAURI_MODE. update.rs sets
UNSLOTH_TAURI_UPDATE on every platform, so macOS and Linux desktop updates still
showed "Update exited with code N" while Windows showed the reason.

A blank USERPROFILE threw a raw binding error from the relocated resolver before
Exit-SetupFailure could report it, so no [TAURI:ERROR] reached the app.

Tests: the marker-readable, listing-denied case was asserted only as a literal
substring, so one extra space reintroduced the early return with all 49 Python
tests and all 125 PowerShell checks green. That assertion is now a regex, and
the PowerShell suite builds the shape for real (icacls /deny :(RD) on Windows,
mode 111 on POSIX) with a negative control. The shell suite gains the same
mode 111 case and no longer compares empty grep output as an integer.

* Fix the Tauri marker gate and narrow the USERPROFILE guard for PR #8032

Two defects in my previous commit, both found by simulating the changes rather
than reading them.

setup_fail joined both variables into one case subject, which is not an exact
match. "*,1" matches any subject ending in ",1", so an unrelated
UNSLOTH_TAURI_UPDATE=a,1 printed a stray [TAURI:ERROR] on a plain CLI run, and
"1,*" did the same for a comma in UNSLOTH_TAURI_MODE. Testing each variable
separately matches setup.ps1, which uses exact membership. Verified over the
144-case product of both variables: no false positives, no false negatives, exit
codes preserved, and byte-identical to main for every value of
UNSLOTH_TAURI_MODE when UNSLOTH_TAURI_UPDATE is unset or 0, so no existing CLI
invocation changes. Added a regression test that fails on the old gate.

The USERPROFILE guard used IsNullOrWhiteSpace, but Join-Path only rejects null
and empty; it accepts a whitespace-only value. The guard therefore also stopped
a run that previously completed, USERPROFILE="   " with a fully qualified
UNSLOTH_STUDIO_HOME. IsNullOrEmpty keeps the clean message for null and empty,
which is where the raw binding error was, and changes nothing else.

* Run the llama.cpp access guard before the prebuilt and source branches

The guard sat inside the prebuilt else-branch, so UNSLOTH_LLAMA_FORCE_COMPILE=1,
a llama.cpp PR or source override, or anything else setting _SKIP_PREBUILT_INSTALL
bypassed it. Those paths reach the phase 9 swap, which only probes access after
`rm -rf "$LLAMA_CPP_DIR"` has already failed, so a denied cache stranded a
completed source build instead of failing before it started.

_assert_studio_owned_or_absent does not cover it either: it returns early unless
the studio home is custom, so a denied default cache had no guard on that path at
all. Verified by driving both helpers against a mode-000 tree: the ownership guard
returns 0 on a default home while the access probe reports denied.

Hoisted both checks, in the same order so the custom-home wording still wins, to
just before the branch. The local-link paths are excluded because they already
replaced or reused the tree. The late checks stay as defense in depth.

* Use the read probe in both llama.cpp replace postconditions

Mode 111 defeats `rm -rf` but stays searchable, so both postconditions fell
through `_studio_dir_unsearchable` to the generic "could not be replaced" text
and the user got no recovery guidance. Reproduced against a mode-111 tree: the
rm fails, the search probe does not fire, and the run exits with the generic
message.

The local-link site sits above the hoisted guard, so it is the one that is
reachable. The source-build site is only reachable when a tree becomes
unreadable during the build, but that is the most expensive path to end with the
wrong message, and it is the same one-word probe. Both now use
`_studio_dir_unreadable` and print the permissions block.

* Tighten the comments added by this PR

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com>
2026-08-07 22:33:42 -07:00
oobabooga
0ca37e57ad
Desktop: block HTTP loopback requests from untrusted images (#8046)
Some checks are pending
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Unsloth export capability / capability (ubuntu-latest) (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Unsloth GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Unsloth GGUF CI / Tool calling Tests (push) Waiting to run
Unsloth GGUF CI / JSON, images (push) Waiting to run
Unsloth load-orchestrator CI / test (push) Waiting to run
Mac Studio GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Waiting to run
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Waiting to run
Mac Studio UI + API + Update CI / Chat UI, API and Update Tests (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Unsloth GGUF CI / GPU prebuilt resolves without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Waiting to run
Windows Unsloth GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
2026-08-07 18:56:26 -03:00
Michael Han
6f4cce68d1
Setup: clear stale WebView caches on install and update, keep user settings and data (#7361)
* Setup: clear stale WebView caches on install/update, keep user data

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

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

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

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

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

Three review findings, all verified:

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

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

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

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

* Tighten the comments added for PR #7361

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

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

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

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

* Tighten the comments in the WebView cache clear

* Clear WebView caches after validating the override on Windows too

* Only clear WebView caches for a real Studio installation

* Tighten the comments added since the last pass

* Clear the WebView cache after single-instance arbitration

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

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

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

* Shorten the comments added in the last commit

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-07 06:35:01 -07:00
oobabooga
48ab41e144
Desktop: fit windows within the monitor work area (#8047)
* Desktop: fit windows to the monitor work area

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

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

* Desktop: guard stale layout before showing

* Desktop: settle restored window geometry

* Desktop: preserve hidden restored geometry

* Desktop: wait for restored monitor geometry

* Desktop: resume layout after tray reveal

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
2026-08-07 14:46:59 +02:00
oobabooga
302cf1b995
Desktop: prevent thin AppImage environment failures (#8055)
* Desktop: prevent thin AppImage environment failures

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

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

* Accept the unversioned AppIndicator names in the tray preflight

* Desktop: harden AppIndicator preflight

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

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

* Stop the AppIndicator preflight at the first candidate the loader would use

The loader commits to the first file it finds for a dlopen name, so a broken
copy ahead of a working one fails the dlopen rather than falling through to a
later directory. The search now walks one name at a time across
LD_LIBRARY_PATH, the ldconfig cache and the default directories, and judges
only the first readable match, so a host whose tray still crashes no longer
passes the guard.

* Tighten the AppIndicator preflight comments

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-07 05:27:54 -07:00
Etherl
d495a09bf0
Guard Windows Studio installs against active runtimes (#7764)
* Fix Studio installer runtime race

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

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

* Close remaining Studio installer races

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

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

* Keep Windows installer tests Windows-only

* Stabilize Windows process guard test

* Coordinate terminal Studio launches

* Guard all managed Studio launches

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

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

* Coordinate custom Studio roots

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

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

* Handle direct GGUF settings rows

* Apply repository formatter

* Close remaining Studio update races

* Handle Windows runtime gate CI edge cases

* Verify the updater parent shim by image

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

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

* Allow verified updater shim chains

* Stabilize Windows process guard test

* Handle Windows console-script redirectors

* Close remaining Windows updater guard gaps

* Handle spaced and repeated Windows update shells

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

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

* Resolve Tauri root aliases before validation

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

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

* Fix custom-root locks and updater ancestry

* Align Windows runtime identity checks

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

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

* Scope desktop fallback to the current user

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

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

* Preserve drive-root mutex identity

* Use ordinal semantics in Studio idle scans

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

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

* Guard standalone Studio setup mutations

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

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

* Restore the setup gate handoff independently

* Version the Windows installer native helper

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

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

* Use ordinal path comparison in Studio runtime scan

* Tighten Studio runtime gate test comments

* Exempt the venv Python redirector in the Studio runtime gate

Windows venv Scripts\python.exe is a redirector that runs base Python as a
child, so `unsloth studio setup` runs as unsloth.exe -> python.exe -> us. The
ancestor walk only exempted the unsloth.exe shims and stopped at the redirector,
so the installer flagged its own launcher and every Windows install failed with
"The managed Studio environment is in use by unsloth.exe".

Carry one redirector as pending and exempt it only when a shim sits directly
above it, so a managed backend that spawns an update still blocks. Also stop
gating the protected shim paths on exists(), so a shim renamed out of the way
mid-update is still recognised.

Drop "Studio" from the two runtime-lock messages so process.rs satisfies the
desktop branding contract.

* Close the redirector exemption when the updater is the managed image

Only a base interpreter runs under a venv redirector. If our own executable is
inside the managed root there is no redirector above us, so a managed parent is
a real consumer and must keep blocking. Adds the regression to the redirector
test.

* Key the redirector exemption on sys.executable, not on a shim above it

The Tauri updater runs `<venv>\Scripts\python.exe -I -c ... studio update`
directly, so its chain is tauri.exe -> redirector -> base Python with no
unsloth.exe in it. Requiring a shim above the redirector made every desktop
update block on its own launcher.

A venv redirector starts base Python as a child and waits, so when we are the
base image and sys.executable still names the managed interpreter, our direct
parent is that launcher. Exempt exactly that hop; ancestors above it must still
be shims, and a managed image at depth two or more keeps blocking.

* Stop the x86 guard test racing its own probe

The 32-bit leg fired one scan against a probe that lives about five seconds,
while a WOW64 shell start plus the Add-Type compile regularly costs more than
that, so it read an empty list on a Windows runner. Give the probe a long life
and retry like the 64-bit sibling already does. The assertion is unchanged.

* Tighten comments in the Studio runtime gate changes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-08-07 05:24:40 -07:00
Wasim Yousef Said
0eb2d13e44
Desktop: avoid WebKitGTK dmabuf crashes on Wayland (#8028)
* Desktop: avoid WebKitGTK Wayland dmabuf crash

* Desktop: handle GDK wildcard Wayland selection

* Desktop: cover explicit and older Wayland WebKit
2026-08-07 12:55:07 +02:00
Daniel Han
8b34acebcd
Studio: stop the macOS launcher killing a warming backend, and the Mac tab blackout (#8076)
* Studio: recover the MLX self-heal when uv cannot resolve the venv interpreter

On Apple Silicon the self-heal reinstalls mlx/mlx-lm/mlx-vlm to re-enable
Train/Export. It ran uv with --python sys.executable and nothing else, so when uv
declined that path the repair gave up and Train stayed disabled for good:

  MLX self-heal failed (staying chat-only):
  error: No virtual environment or system Python installation found for path
  `<studio_home>/unsloth_studio/bin/python`; run `uv venv` to create an environment

A venv's bin/python is a symlink into the base interpreter, and macOS breaks that
link routinely: a Homebrew or python.org point upgrade moves the target and the
venv keeps a dangling symlink. The running process never notices, because it
mapped the binary at exec time, so uv is the first thing to fail.

Name the environment as well as the interpreter. _uv_python_target prefers
sys.executable and falls back to the venv root when the interpreter no longer
re-resolves, _mlx_install_env sets VIRTUAL_ENV from sys.prefix, and a uv run that
still reports an unresolvable interpreter is retried once against the venv
directory. Only that specific failure retries; an ordinary resolution failure
stays a single run.

VIRTUAL_ENV is set from sys.prefix rather than forwarded from os.environ, matching
how UV_OVERRIDE is already handled: it names the install target, so inheriting it
would let a caller redirect the install.

When both attempts fail the venv itself is broken rather than merely missing MLX.
uv's own text says to run `uv venv`, which would build an environment Unsloth does
not manage, so the warning now names `unsloth studio update` instead.

Reported on macOS 0.1.524-beta.

* Studio: stop double-logging exceptions and sqlite-vec spam

A macOS diagnostics bundle came back at ~300KB, and roughly half of it was the
same tracebacks written twice.

LoggingMiddleware logs request_failed with exc_info, which structlog renders as
a full traceback inside the JSON "exception" field, then re-raises. Uvicorn logs
that same exception again on stderr as "Exception in ASGI application", and the
desktop shell mirrors every stderr line into tauri.log individually, so one
failure cost about 90 log lines. Mark the exception once request_failed has
reported it and filter uvicorn's duplicate on the uvicorn.error logger, the same
technique run.py already uses for the startup line. An exception raised above
the middleware carries no marker and keeps its traceback, and --verbose restores
both copies.

The bundle's own failure was a missing sqlite_vec/vec0.dylib, which the import
check does not catch: every /api/rag/knowledge-bases poll opened a connection,
failed to load the extension and 500ed. rag_db now warns once per process and
raises RagExtensionUnavailable (a RuntimeError subclass, so existing handlers
are unaffected), and list_knowledge_bases degrades to an empty list for that
case only. A locked or corrupt database still surfaces as before.

Also quiet the 2xx line for four boot-burst catalog reads (/api/providers/
registry, /api/providers/, /api/models/loras, /api/settings/personalization);
4xx/5xx and every mutation still log. And tauri.log only checked its 5MiB
rotation threshold at startup, so a long session grew unbounded: the file logger
now writes through a size-tracking handle that rotates in place.

On the reported bundle this removes 945 of 2063 lines (89KB of duplicate stderr
traceback) plus the 9 sqlite-vec request_failed events (48KB), leaving one
warning line.

* Studio: stop Mac launches blacking out the Train and Video tabs

The platform store seeds chatOnly from the browser user agent, so on every Mac
both rows rendered disabled from first paint, visually identical to a measured
"this machine cannot do that", until /api/health answered. Since the hardware
detection went lazy that reply can take seconds to minutes.

Add capabilitiesUnknown() next to isChatOnly(), derived from the fetched flag
that already means "a server-reported verdict is stored" (a deferred reply
counts as settled: under the torch-warm kill switch nothing else is coming).
NavRowDef gains a pending field, folded in by a small import-free resolver both
render sites go through, so an unmeasured row stays enabled and reuses the
existing spinner column instead of graying out. The root guard lets /studio and
/video wait the verdict out rather than one-way redirecting them to /chat, and
each page shows its own loading state while it does.

Video also gets a real capability answer. There is no Apple path in the video
backend, but a healthy Apple Silicon host is not chat-only, so the tab was
enabled and would just fail at load. video_capability() mirrors
export_capability() and is spliced into GET /api/system and
GET /api/system/hardware as additive fields; the page renders a coming-soon
panel on an authoritative false, and the sidebar tooltip is now derived from the
reason instead of hardcoding "needs an NVIDIA or AMD GPU".

Also retry a failed hardware probe in useHardwareInfo: it resolved to the
unloaded default with nothing scheduled to run again, which would have left the
new Video gate spinning for the session.

* Studio: add a macOS tab-capability UI smoke and nav row test hooks

Covers the two things reported on Apple Silicon 0.1.524-beta in one live run:
Train and Video rendering blacked out for minutes after launch, and the desktop
launcher killing the backend about a minute in with "Server stopped unexpectedly".

The nav button now carries data-testid="nav-row-<id>" and data-spinner, so the
smoke can tell a spinning row from a greyed-out one. Nothing reads them at
runtime; resolveNavRowState still owns the behaviour.

tests/studio/playwright_mac_tab_capabilities.py drives a live Studio: it samples
the Train and Video rows from first paint and fails if either renders disabled
while /api/health still reports hardware_detecting, walks Chat, Hub, Images,
Train, Video and Export clicking each row and screenshotting the result, and
polls /api/liveness and /api/health on a background thread for the whole run.

The poll window is 330s by default, deliberately past the launcher's 300s startup
grace: the reported crash landed at t+66s, so a backend that dies to the watchdog
fails here rather than looking like a slow boot. Redirects away from /studio and
/video are allowed only once the verdict is measured.

* Desktop: stop the health watchdog killing a backend that is still importing torch

The macOS "Server stopped unexpectedly" report was three probe timeouts inside the
first 64s of a normal cold start. #7958 fixed the grace period being bypassed; this is
the rest of it, on the probe itself.

Probe /api/liveness instead of /api/health. Health awaits hardware detection through
_await_hardware_detection on purpose, so probing it every 15s bills the watchdog for the
warm thread's torch import. Liveness reads module-level caches only, which is what it was
added for. Backends older than the route answer 404, so fall back to health, in the same
order process::generic_backend_health_ok and desktop_backend_owner::fetch_liveness use,
and accept "alive" or "healthy" so a downgrade still validates.

Raise the per-probe budget from 2s to 10s. The C-extension imports hold the GIL and the
process can go quiet for seconds at a time on a cold start (3735ms measured on /api/health,
with a ~27s silence around it). preflight::backend keeps its own 2s: a timeout there
dead-ends the launch instead of retrying, and the backend derives _HEALTH_DETECT_BUDGET_S
from that number.

Stop one early reply ending the startup grace for good. A backend that answers now can
still miss the next three probes while it loads a large model, so has_seen_healthy is set
only once a reply says the hardware verdict has settled. /api/liveness now carries the same
hardware_detecting marker health publishes, plus hardware_detection_deferred when the warm
is switched off and nothing will ever settle it; a backend too old to send either reads as
settled, which is what the launcher assumed before.

Adds the regression test #7958 landed without: the failure policy replayed against the
reported timeline, and the probe covered against a stub backend on both routes.

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

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

* Studio: accept either password env var in the macOS tab smoke

The repo's macOS workflow exports STUDIO_OLD_PW; the staging harness exports
STUDIO_PW. Reading only the first made the script KeyError before it reached the
backend under staging CI.

* Studio: make the new liveness and RAG tests hold on a macOS runner

Two assumptions that hold on this dev box but not on a bare macos-15 runner:

studio_root_id is environment-derived and is legitimately empty on a fresh
runner, so the liveness test asserts the key is present (which is what the
launcher reads) rather than that it is truthy.

python.org macOS builds ship a sqlite3 without enable_load_extension, so the
healthy-path RAG test cannot open a connection at all and errored in fixture
setup. It skips there. The unavailable-path tests, which are the ones this PR
changes behaviour for, still run everywhere.

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

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

* Studio: drop the MLX self-heal venv-root retry, which cannot work

Codex was right that passing the venv root to uv does not recover a dangling
bin/python. Confirmed against uv directly, with a venv whose interpreter symlink
was pointed at a missing target:

  --python <venv>/bin/python    error: No virtual environment or system Python
                                installation found for path ...
  --python <venv>               error: No virtual environment or system Python
                                installation found for directory ...
  VIRTUAL_ENV set, no --python  error: Failed to inspect Python interpreter from
                                active virtual environment

All three fail the same way, so the fallback and the retry were a placebo: when
the first attempt already resolved to the venv root the retry was skipped, and
when it ran it repeated a call that cannot succeed.

Keep the part that does work. uv refusing the environment means the venv is
broken rather than merely missing MLX, and nothing this process can pass to an
install command fixes that, so the warning names 'unsloth studio update' (which
rebuilds the environment) instead of uv's own 'uv venv' suggestion, which would
build one Unsloth does not manage.

_venv_root stays: it names the environment in that message.

* Studio: hold the startup grace for the whole warm, not just hardware detection

The health watchdog ends its five minute startup grace once a liveness reply says
the backend is no longer warming, and that signal was `hardware_detecting`. But
hardware detection is only the first of utils/torch_warmup.py's stages: the marker
disappears while inference_backend, transformers, datasets and unsloth_zoo are still
importing, and those are the C-extension imports that hold the GIL longest. So the
grace could end mid warm and a stall spanning three 10s probes would count as three
dead probes against a backend that was starting normally, which is the
unresponsive_health_check kill the grace exists to prevent.

Publish `torch_warm_in_progress` on /api/liveness and /api/health, derived from
warm_status(), and read that in the launcher.

A new field rather than a wider `hardware_detecting`: that marker also means "this
hardware verdict is provisional, re-read it", and config/hardware-verdict.ts keeps
the UI provisional and polling while it is set, so keeping it lit through datasets
would hide Train for the whole warm over a verdict that settled seconds in.

Additive and backwards compatible both ways. The launcher keeps its
`hardware_detecting` path as a fallback, so a backend that predates the new field
still gets the grace it gets today, and a backend that sends it talks to an older
launcher exactly as before.

The deferred case is preserved by construction: the field is published only while a
warm thread is actually running, so it is absent both when the warm finished and
when none is coming. UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1 never starts one, and a
warm retired mid stage by a shutdown never sets finished; deriving the field from
"not finished" would have reported either as warming forever and held the grace
open until it expired on its own.

* Studio: poll the hardware verdict out of its unknown state on every platform

Train and Video now spin instead of graying out while the verdict is unmeasured,
so something has to end the spin. fetchDeviceType spends its bounded wait at most
once per page load, so a host that detects slower than that keeps the provisional
reply and `fetched` stays false. The sidebar's recovery poll returned early unless
the host was chat-only or deferred, which off macOS it is not: the store seeds
chatOnly from the user agent, so on Linux and Windows nothing re-read /api/health.
A cold GPU host importing torch sits squarely in that window, and there the rows
spun and /studio held its loading panel until the user navigated or reloaded.

Poll while the verdict is unknown too, on any platform, and keep the mlx_unavailable
and deferred cases as they were. The poll re-reads with force, which is what gets
past the cached provisional reply and the spent wait latch, and the effect re-runs
when the verdict lands, so the interval is cleared as soon as it is known. Re-reads
are skipped while one is outstanding, bounded so a request that never settles cannot
hold the poll off.

studio-page and video-page gate on the same store value, and the sidebar is mounted
on both routes, so they recover with it rather than growing a second poll.

Covered by tests/hardware-verdict-recovery.test.ts, which drives the real store
through a slow non-Mac detection and asserts the guard arms on the unknown state.

* Studio: teach the run-module test stub about the new loggers export

run.py imports install_uvicorn_duplicate_exception_filter at module scope, and
load_studio_run_module replaces 'loggers' with a stub module carrying only
get_logger, so importing run raised ImportError before any test body ran:

  ImportError: cannot import name 'install_uvicorn_duplicate_exception_filter'
  from 'loggers' (unknown location)

A no-op is the right stub here. The filter only de-duplicates uvicorn's copy of a
traceback and this module never starts a server.

* Studio: run the tab-capability smoke in the macOS UI job

Codex was right that the script was dead code: nothing under .github invoked
playwright_mac_tab_capabilities.py, so the regression coverage it claims never
executed and the tab blackout could come back unnoticed.

It gets its own boot on a cold port. The assertion is that Train and Video spin
rather than grey out while the verdict is unmeasured, and that window only exists
on a backend that has not warmed yet, so reusing the already-warm 18897 server
would have passed vacuously. For the same reason this phase deliberately does not
wait for a healthy backend first, unlike every other phase here: waiting is how
you miss the window. The script does its own wait, and health answers
provisionally inside a 1s budget.

The liveness poll is cut from its 330s default to 120s here. The full window
exists to outlive the launcher's 300s startup grace, and nothing in this job runs
that watchdog; it boots 'unsloth studio' directly. Spending five macOS-runner
minutes to prove something this job cannot observe is not worth it, and the
watchdog is covered by the Rust tests in commands.rs.

* Studio: let /video reach its own gate, and stop stale polls freeing the guard

Two review findings on this branch.

/video was still outside CHAT_ONLY_ALLOWED, so on a measured chat-only host, a
CPU-only box or a Mac without usable MLX, a direct link or a reload bounced to
/chat before VideoPage could render. That is exactly where the unsupported
explanation this branch added has something to say, so the message was
unreachable in the only cases it was written for. Video now follows /export: the
route is allowed through and the page self-gates on the backend's video verdict,
so nothing loads on a host that cannot run it.

The recovery poll's no-stacking guard could be freed by a read that no longer
held it. A read outliving the 30s stall window is abandoned and the next tick
starts a replacement, but the abandoned read's finally still zeroed the shared
marker, so every following tick saw a free guard and fired another forced
/api/health. On the backend this poll exists for, one still importing torch, that
is a read every three seconds piled onto the process being waited for. A
generation counter now means only the owning read can clear it.

Both regressions verified to fail without the fix: 3 of 754 tests go red.

* Studio: make the tab-capability smoke fail instead of passing vacuously

The first staging run went green having tested nothing. The log tells the story:

  [mac-tabs] spinner observed during warm: {'train': False, 'video': False}
  [mac-tabs] Train: redirected to http://127.0.0.1:8888/login ... (allowed)
  [mac-tabs] Train: nav row not pinned inline; reached by route instead
  [mac-tabs] PASS

The login form takes a username as well as a password and the script filled only
the password, so the submit was a no-op and the browser stayed signed out. Every
later check then read an empty shell: no sidebar, so no nav row, so no assertion
had anything to act on. The redirect check called the bounce to /login 'allowed'
because it only asked whether the verdict was measured, and the row checks
downgraded a total miss to an info line.

Three changes, all so this run would have gone red:

log_in fills the username, then proves the session took by navigating to /chat
and checking it stayed; a failure there aborts rather than continuing into checks
that cannot mean anything.

A bounce to /login, /onboarding or /change-password during the tab walk is a
failure. The session was proven live before the walk started, so losing it
mid-walk is never an allowed redirect.

A walk that never locates a single nav row now fails. That is the shape a
signed-out or unrendered run takes, and it has to be loud.

The survival poll was the one part that did mean something: 65 samples on each of
/api/liveness and /api/health, zero non-200, backend alive past 319s.

* Studio: make RAG unavailability one coherent state across the router

Degrading the KB list to an empty response when sqlite-vec's native library
cannot load stopped the 500-plus-traceback on every poll, but it left the rest
of the router behind: the frontend read the empty list as a working empty
state, offered Create, and the POST went straight to get_connection() and
raised. So the fix for the log spam reintroduced the log spam one click later,
and told the user nothing.

One contract now. The polled KB list still degrades, but carries ragAvailable
and ragUnavailableReason so a client can tell "no knowledge bases yet" from
"RAG cannot run on this machine". Every other endpoint answers 503 stating the
same reason, via a rag_available() gate up front and a _rag_connection()
wrapper that catches the case where the first request of a session is the one
that discovers the library is missing. That wrapper also reaches the
connections ingestion opens for itself, and removes an upload it had already
saved rather than orphaning it in the uploads root.

The two new fields are additive and the document listings are deliberately
left erroring rather than degrading, so a frontend that has not learned to read
the marker keeps every error surface it has today.

rag_available() remembers only that the extension loaded, never that it failed,
so a one-off cannot latch RAG off until restart. The warn-once stays: a 503
path that logged per request would be the same regression in a new place.
Genuine database errors are untouched and still surface as real errors.

reconcile_orphaned_ingestion_jobs() gates on rag_available() too, so it is the
no-op its docstring already claimed instead of raising out of startup to be
logged as a reconcile failure.

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

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

* Studio: let the UI read the RAG availability the backend already reports

routes/rag.py answers a host where sqlite-vec will not load as a contract: the
polled KB list degrades to 200 with ragAvailable/ragUnavailableReason beside an
empty list, and every other endpoint answers 503 with the same reason. The client
dropped both, so a broken-RAG Mac still showed an apparently working, empty
Knowledge bases page whose Create button could only 503.

features/rag/api/rag-availability holds the verdict, modelled on the platform
store in config/env: optimistic until the backend has actually answered, so a
slow first poll, an unreachable server and a backend that predates the marker
all render exactly as they do today. Only a measured unavailable gates anything.

Wired in at every response path, including the two that bypass ragRequest
(ragUpload, streamJobEvents), so a user who lands on a mutating route first gets
a coherent UI instead of waiting for the list poll. listKnowledgeBases reads the
marker, which is the only way to tell an empty store from a host where RAG cannot
run. The dialog then disables New knowledge base and Create/Save, guards
submitForm for the keyboard path, and shows the backend's reason in place of
"No knowledge bases yet.".

Also breaks a hang this condition triggers. targetHasIndexingDocuments answers
"still indexing" whenever its listThreadDocuments probe throws, and
dispatchQueuedPrompt reschedules on that with no cap, so on a broken-RAG host a
queued prompt on a thread using documents was never dispatched at all. A 503 is
now distinguishable, and there are no documents to wait for, so it sends. A
transient failure still holds the prompt back as before.

This is deliberately not folded into useRagToolDisabled: that is a model
capability gate and is false when no model is loaded.

* Studio: report video as macOS-unsupported on Intel Macs too

video_capability() keyed the macOS branch on is_apple_silicon(), so an Intel Mac
fell through to pytorch_not_installed or no_accelerator and was told to install
PyTorch or add a GPU. Neither enables video: the diffusers pipelines have no
supported macOS path at all, so both Macs get the same honest answer.

The accompanying AST test also asserted is_apple_silicon() was named in the
function, which kept passing on the word surviving in a comment. Strip comments
before asserting so it tests the gate rather than the prose.

* Studio: only the backend's own 503 detail is a RAG capability verdict

A 503 is what a reverse proxy, Cloudflare or a briefly overloaded server returns,
and those bodies say nothing about sqlite-vec. Recording one as unavailable gated
the Knowledge bases dialog for the rest of the session behind a transient outage,
explaining it with an extension failure that never happened -- and only a 2xx from
a gated endpoint could clear it.

Match the RAG router's own wording instead. A bodyless or unrelated 503 now leaves
availability unknown, which is what the store was built to represent.

* Studio: fix the macOS tab smoke signing in, which made every assertion vacuous

The helper read the password field with count() straight after domcontentloaded.
auth-form.tsx returns null while the auth-status request is in flight, so there is
no form in the DOM yet, count() does not wait, and the run fell through to
'assuming desktop auth' and checked an empty signed-out shell. It also filled a
username the login form does not have and clicked a button labelled 'Sign in'
when the label is 'Login'.

Wait for #password, click Login, and wait for the post-auth route. Verified against
a live Studio: the helper now reports 'signed in' where it previously did not.

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

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

* Studio: make the tab-capability smoke create the window it asserts on

Second hole of the same shape in this file. assert_row_never_greyed_while_unmeasured
computed seen_spinner and only logged it, so when the verdict settled before the
browser arrived the loop broke on its first iteration, nothing was sampled, and the
run went green having observed nothing.

Requiring the window would not have fixed it, because the window is not there to be
required. hardware_detecting covers stage 0 of the warm, and on the macOS runner's
--no-torch install with no MLX that stage is a failed `import torch` plus one failed
metadata lookup: it settles inside a second of the port binding, while Playwright is
still launching Chromium. Add the login and two navigations, one of which spends the
frontend's own 5s wait on the verdict, and the sampler arrives 15 to 20s late on every
host, fast or slow. The workflow comment claiming a cold boot keeps the window open
was wrong; it is corrected to say what boot ordering actually buys, which is a
provisional reply to the first /api/health probe and nothing past it.

So the script opens its own window instead of racing that one. It answers the
browser's /api/health with a real reply that has the measurement taken back out, holds
it there, and requires nav-row-train to render enabled with data-spinner="true", the
way pending beats disabled in resolveNavRowState. That window is open for as long as
the check needs, on any host, and a missing row fails rather than skips, so there is
no path through it that reports success without having read the row. The real warm is
still sampled and a real grey-out still fails, but nothing is required of it.

Two more things in here could observe nothing:

The Video half of this file was structurally empty. Video is not pinned inline
(SIDEBAR_NAV_DEFAULT_PINNED), so it renders inside the More dropdown, which mounts
nothing until it is opened and carries no data-testid even then. Every query for
nav-row-video returned null on every host, so seen_spinner["video"] could never be
true and "Video: nav row not pinned inline" was a permanent info line, not a finding.
Train carries the same pending flag through the same resolver, so it is the observable
end of that wire; the rows the sidebar does pin are now named, an inline row that does
not render is a failure, and the More rows are documented as expected misses.

_saw_any_row was satisfied by any row on any route, and the sampler swallowed a dead
page with a bare `except Exception: break`. Now the walk requires every default-pinned
row to have been seen, and a page that cannot be evaluated at all fails.

tests/studio/test_mac_tab_capability_warm_window.py drives all of this with the page
and the backend stubbed, so the red cases are checked in the tests/ walk rather than
only on a macOS runner. Against the previous file, its first case reproduces the
staging log exactly: "spinner observed during warm: {'train': False, 'video': False}",
then PASS.

* Studio: give an adopted backend the startup grace when it is still warming

The watchdog already knows that one healthy answer is proof of life and not proof
that startup is over, but only on the path where this app spawned the backend. An
adopted backend starts with the latch already set, on the reasoning that it was
serving before the app attached. That is the same fallacy: a force-quit during a
cold start leaves the backend running and still importing the ML stack, and the
relaunched app adopts it. Three GIL-stalled probes later it was cleared and the
user got a crash screen for a host that was starting normally.

The ownership probe carries no warm-up signal, so ask the backend directly once
ownership is verified, and clear the latch on a warming reply rather than merely
declining to set it. The grace stays bounded at 300s either way.

This path predates the fix on the owned side; it is the other half of the same
report rather than a regression.

* Studio: fix five guards in this PR's own tests that could not fail

Each was verified by mutation: the regression the test names was applied, the
suite stayed green, and it now goes red.

- provisional-hardware-verdict: the slice end was located by searching for the
  latch it then asserted was absent. String.search returns the first match, so
  moving the latch into the loop moved the boundary with it and the assertion
  passed against exactly the regression its header describes. Anchor on the
  loop's closing brace, and require the latch to exist after it so deleting it
  outright is not a pass either.
- liveness warm state: the subprocess harness builds its result with
  body.get(studio_root_id), so the key is present whether or not the reply
  carried it. Deleting the field from liveness_check() left the test green.
  Report presence explicitly, as the two neighbouring fields already do.
- warm window, DEVICE guard: asserted a count of at least two when the function
  has three comparisons, so deleting the poll loop's requirement still left two.
  Bind to the sites instead of counting them.
- warm window, boot silence: scanned only for _fetch_top_models, but the fetch
  is started through the public wrapper now, so putting _start_top_models_fetch
  back in the constructor restored the huggingface.co call on every boot
  undetected. Check both names.
- rag availability: asserted only after noteRagAvailability, which writes
  unavailable unconditionally, so the exemption under test could be deleted.
  Assert between the two calls.

Also replaces a dead 'disabled: chatOnly;' string check: that is an object entry,
so the regressed form ends in a comma and the literal could never match.

* Studio: deliver the hardware verdict to a component that subscribed a tick late

useHardwareInfo seeds its state from the module cache during render, but joins
the listener set in the effect. A probe resolving between those two points
notifies the listeners registered at the time, which does not include this one,
and leaves the cache set, so 'if (!cached) load()' skipped the fetch as
redundant. Nothing remained that would ever call setInfo, and the component sat
on the unloaded default for its whole life.

That was survivable while callers read individual fields. This PR gates whole
pages on 'loaded', so it now reads as 'Checking this machine for video support'
for the rest of the session, which is the stuck-loading state the PR exists to
remove. Hand the cache straight to the listener instead.

Also stops a successful 200 that a later invalidate superseded from resolving as
the unloaded default, which load() reads as a failed probe.

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

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

* Studio: complete the forced password change in the macOS tab smoke

The repo's own macOS smoke hands this script the raw bootstrap password, and a
backend that still holds one injects it into the page and signs itself in, landing
on /change-password with no login form ever rendered. This script treats that as a
signed-out route, since it has no sidebar to assert against, so the run failed with
'could not sign in'. The staging harness rotates over the API before driving, which
is why the same script passed there and failed here.

Rotate it in the browser instead of giving up, and check for that screen before
waiting on the login field so an authenticated session does not spend a minute
waiting for a form that is correctly absent.

Verified against a live Studio on both paths: a fresh instance holding its
bootstrap password now reports 'password rotated' then 'signed in' where it
previously reported 'could not sign in', and an already-rotated instance still
signs in through the login form.

* Studio: give the adopted ownership probe the watchdog's probe budget

The warm-up read added for adopted backends is gated on ownership verifying, and
that probe runs every request at the 2s default. During the multi-second GIL stall
the watchdog exists to ride out, both requests inside it time out, the backend
comes back unverified, and the warm-up read never runs at all, so the grace never
reopens and three stalls still clear the backend. The longer budget has to be
applied before verification, not after it.

probe_owned_backend_state keeps its signature and delegates to a variant taking an
explicit budget, so only the watchdog changes. A guard binds that call site to
HEALTH_PROBE_TIMEOUT and fails if it drifts back to the default.

Also uploads the tab-capability phase's log and Playwright evidence, which the
artifact list did not name, so a red macOS run discarded the only record of it.

* Studio: match the RAG 503 on the extension name only

Matching either fragment meant the loose half carried the same weight as the
specific one: anything RAG-aware in front of the backend can answer a transient
503 saying RAG is unavailable without meaning the extension, and that persisted a
capability verdict which only a later successful gated request could clear.

Keep sqlite-vec and drop the English phrase. Nothing upstream emits a package name
by accident, and the capability being gated is exactly that extension, so this is
narrower than requiring both fragments would be while still tolerating a reworded
backend detail. Requiring both would have made the matcher brittle to precisely the
rewording the fragment match exists for.

Two proxy phrasings added to the generic-503 case, which restoring the loose marker
now fails.

* Studio: stop the MLX install env claiming a recovery it does not perform

_mlx_install_env's docstring still described VIRTUAL_ENV as the second half of a
dangling-symlink recovery, and named the helper that performed the first half.
That helper was deleted earlier in this PR when the retry was found not to work,
but the claim outlived it and is false: an explicit --python outranks VIRTUAL_ENV,
so uv reports the same unresolved-interpreter error either way.

It is not a harmless stale comment. It is the tree asserting a repair that does not
happen, and a reviewer reading it asked for the mechanism to be restored. State
what uv actually does, and why --target and --prefix are not the escape hatch they
look like: both exit 0 against a broken venv but resolve against whatever ambient
interpreter uv finds, writing a wrong-ABI or off-sys.path install that leaves
mlx_stack_available() False while looking like success.

Two guards: the deleted helper must stay deleted and the claim must not come back,
and the unresolved-interpreter path must stay one attempt plus a diagnosis, never a
second install with a different target.

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

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

* Studio: make the watchdog-budget guard line-ending agnostic

include_str! embeds the file exactly as checked out, so on Windows the source is
CRLF and the \n}\n search for the end of check_watchdog_health never matched. The
guard panicked on the Tauri CI runner while passing on every Linux and macOS job.
Normalise before searching.

Verified both ways: 149 tests pass on the LF tree, and the guard still passes after
converting the file to CRLF, which reproduced the runner failure before this.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-07 02:45:06 -07:00
Nilay
ea67c53957
Desktop: start at login, hidden in the menu bar (#8033)
* Add launch at login setting to the desktop app

* Hide the Dock icon for hidden login starts on macOS

* Guard the Linux autostart entry with TryExec so uninstall leaves it inert

* Only index the startup settings for desktop search

* Quote the Linux autostart Exec binary so AppImage paths with spaces work

* Match the plugin's autostart path and refresh stale entries at startup

* Quote the Windows autostart Run value so spaced install paths work

* Harden autostart entries: escape Exec field codes, plist XML, respect DE-disabled entries

* Double the Exec escaping backslash for PR #8033

The Desktop Entry spec applies the string escape rule before the Exec
quoting rule, so an escaping backslash has to be doubled in the file. With
a single one GLib reports an invalid escape sequence and drops the whole
Exec value, so a path containing $, a backtick or a quote silently never
starts. Confirmed against GLib 2.80 and desktop-file-validate.

* Tighten launch-at-login comments

Comment-only pass over PR #8033: collapse the multi-line doc and inline
comments in the autostart code to single lines where they still read
clearly, keeping the non-obvious reasons for the doubled Exec escaping
backslash, the hardcoded ~/.config path, and the debug-build reconcile
skip.

* Stop an in-app relaunch inheriting the hidden login start for PR #8033

tauri's restart re-execs with the original argv (process.rs: Command::new(path)
.args(env.args_os.iter().skip(1))), so a login start's --hidden survived an
update installed from the tray. The restarted app then took every hidden path
again and went back to the tray with no window, and no Dock icon on macOS.

The updater now writes a marker before relaunch(), consumed once at setup. It
fails toward showing: a marker that cannot be written or removed at worst shows
a window that would have stayed hidden.

* Scan argv without requiring Unicode and clear the relaunch marker if the restart fails

* Stop the update restart when the relaunch marker cannot be written

* Tighten the launch-at-login comments

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-07 02:39:23 -07:00