mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-23 15:53:46 +00:00
119 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
de102032ee
|
Desktop: ship a complete Linux AppImage (#9113)
--------- Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com> |
||
|
|
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. |
||
|
|
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. |
||
|
|
83e163f13c
|
Revert "Desktop: ship a complete Linux AppImage (#8695)" (#8823)
This reverts commit
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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. |
||
|
|
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
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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 ' as readily as ' 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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
4532df20e0
|
Desktop: stop accidental reloads in the Windows WebView (#7945)
Co-authored-by: danielhanchen <unslothshared@gmail.com> |
||
|
|
161a3017df
|
Studio: eliminate Cargo and Clippy warnings (#8192) | ||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |