mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-17 04:43:52 +00:00
* 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>
415 lines
15 KiB
Python
415 lines
15 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""The macOS tab-capability smoke has to be able to fail.
|
|
|
|
tests/studio/playwright_mac_tab_capabilities.py needs a live Studio and a browser, so
|
|
CI is the only place it runs and nothing else checks that a red case comes out red.
|
|
Twice now it has gone green having observed nothing: first by authenticating with
|
|
nobody, then by computing `seen_spinner` and only logging it, so a backend that
|
|
settled before the browser arrived skipped every assertion.
|
|
|
|
This drives the same functions with the page and the backend stubbed, over the exact
|
|
shapes that used to pass: the warm window already shut, the row absent, the row greyed
|
|
out. It is a plain pytest file so it runs in the Backend CI walk over tests/, where
|
|
neither playwright nor a Studio is installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import re
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
SCRIPT = REPO / "tests/studio/playwright_mac_tab_capabilities.py"
|
|
APPEARANCE_STORE = REPO / "studio/frontend/src/features/settings/stores/appearance-custom-store.ts"
|
|
|
|
BASE = "http://127.0.0.1:18893"
|
|
# A settled reply: no hardware_detecting at all. This is the state the runner is in by
|
|
# the time the browser is authenticated, and the one the old code passed vacuously on.
|
|
SETTLED = {"status": "healthy", "service": "Unsloth UI Backend", "device_type": "mac"}
|
|
UNMEASURED = {"status": "healthy", "service": "Unsloth UI Backend", "hardware_detecting": True}
|
|
|
|
# Spelled out rather than read off the script, so these cases run unchanged against a
|
|
# build of it that does not define the constant yet. test_inline_row_ids_match_the_
|
|
# frontends_default_pinned_set is what keeps the spelling honest.
|
|
TRAIN = "train"
|
|
|
|
GREYED = {"disabled": True, "spinner": False}
|
|
SPINNING = {"disabled": False, "spinner": True}
|
|
SETTLED_ENABLED = {"disabled": False, "spinner": False}
|
|
|
|
|
|
def _load(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
"""Import the script with playwright and its env contract stubbed out.
|
|
|
|
Fresh per test: the script keeps its failures and its row sightings in module
|
|
globals, so a shared instance would carry one test's verdict into the next.
|
|
"""
|
|
if "playwright.sync_api" not in sys.modules:
|
|
pkg = types.ModuleType("playwright")
|
|
api = types.ModuleType("playwright.sync_api")
|
|
api.sync_playwright = lambda: None
|
|
pkg.sync_api = api
|
|
monkeypatch.setitem(sys.modules, "playwright", pkg)
|
|
monkeypatch.setitem(sys.modules, "playwright.sync_api", api)
|
|
monkeypatch.setenv("BASE_URL", BASE)
|
|
monkeypatch.setenv("STUDIO_OLD_PW", "stub-password")
|
|
monkeypatch.setenv("PW_ART_DIR", str(tmp_path / "art"))
|
|
# The real default gives the row 15s to settle; the stub answers instantly, so the
|
|
# only thing the wait would buy here is 15s of a red test.
|
|
monkeypatch.setenv("STUDIO_MAC_FORCED_PENDING_S", "0.2")
|
|
spec = importlib.util.spec_from_file_location("mac_tab_capabilities_under_test", SCRIPT)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
class FakeLocator:
|
|
def __init__(self, present: bool) -> None:
|
|
self._present = present
|
|
self.clicked = False
|
|
|
|
def count(self) -> int:
|
|
return 1 if self._present else 0
|
|
|
|
@property
|
|
def first(self):
|
|
return self
|
|
|
|
def is_enabled(self) -> bool:
|
|
return True
|
|
|
|
def click(self, timeout = None) -> None:
|
|
self.clicked = True
|
|
|
|
|
|
class FakePage:
|
|
"""Enough of a Playwright page for the pending-state checks.
|
|
|
|
`rows` maps a nav row id to the DOM state the stub reports, or None for a row that
|
|
is not in the document. `row_missing` makes wait_for_selector time out the way a
|
|
sidebar that never rendered does.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
rows: dict,
|
|
*,
|
|
row_missing: bool = False,
|
|
) -> None:
|
|
self.rows = rows
|
|
self.row_missing = row_missing
|
|
self.url = f"{BASE}/chat"
|
|
self.routed: list[str] = []
|
|
self.unrouted: list[str] = []
|
|
self.gotos: list[str] = []
|
|
self.screenshots: list[str] = []
|
|
|
|
def evaluate(
|
|
self,
|
|
script: str,
|
|
arg = None,
|
|
):
|
|
return {rid: self.rows.get(rid) for rid in (arg or [])}
|
|
|
|
def route(self, pattern, handler) -> None:
|
|
self.routed.append(pattern)
|
|
# Prove the stub body is valid JSON and reaches the browser, rather than only
|
|
# that route() was called: a body the frontend cannot parse would leave the row
|
|
# in its pre-fetch state and the check would read the wrong thing.
|
|
handler(_RecordingRoute(self))
|
|
|
|
def unroute(
|
|
self,
|
|
pattern,
|
|
handler = None,
|
|
) -> None:
|
|
self.unrouted.append(pattern)
|
|
|
|
def goto(
|
|
self,
|
|
url,
|
|
wait_until = None,
|
|
timeout = None,
|
|
) -> None:
|
|
self.gotos.append(url)
|
|
self.url = url
|
|
|
|
def wait_for_selector(
|
|
self,
|
|
selector,
|
|
timeout = None,
|
|
) -> None:
|
|
if self.row_missing:
|
|
raise TimeoutError(f"waiting for {selector}")
|
|
|
|
def wait_for_timeout(self, ms) -> None:
|
|
pass
|
|
|
|
def screenshot(
|
|
self,
|
|
path = None,
|
|
full_page = None,
|
|
) -> None:
|
|
self.screenshots.append(str(path))
|
|
|
|
def locator(self, selector: str):
|
|
rid = selector.split("nav-row-")[1].rstrip('"]')
|
|
return FakeLocator(self.rows.get(rid) is not None)
|
|
|
|
|
|
class _RecordingRoute:
|
|
def __init__(self, page: FakePage) -> None:
|
|
self.page = page
|
|
|
|
def fulfill(
|
|
self,
|
|
status = None,
|
|
content_type = None,
|
|
body = None,
|
|
) -> None:
|
|
import json
|
|
self.page.fulfilled = json.loads(body)
|
|
self.page.fulfilled_status = status
|
|
|
|
|
|
def _health(mod, bodies):
|
|
"""Point the script's backend reads at a scripted sequence of /api/health bodies."""
|
|
queue = list(bodies)
|
|
|
|
def fake(path, timeout = 10.0):
|
|
body = queue.pop(0) if len(queue) > 1 else queue[0]
|
|
return 200, dict(body)
|
|
|
|
mod._get_json = fake
|
|
|
|
|
|
# --------------------------------------------------------------------------------
|
|
# The regression Codex found: the window shut before the browser got there.
|
|
# --------------------------------------------------------------------------------
|
|
|
|
|
|
def test_greyed_row_fails_even_though_the_warm_window_already_shut(tmp_path, monkeypatch):
|
|
"""The vacuous-pass case. Health has settled, so the real-warm sampler observes
|
|
nothing and breaks on its first iteration; the row is blacked out exactly as it was
|
|
in the field. Before the forced-verdict check this run went green."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [SETTLED])
|
|
page = FakePage({TRAIN: GREYED})
|
|
|
|
mod.assert_row_never_greyed_while_unmeasured(page)
|
|
|
|
assert mod._failed, (
|
|
"the run passed with the warm window already shut and the Train row greyed "
|
|
"out; this is the state the whole script exists to catch"
|
|
)
|
|
assert any("disabled" in m for m in mod._failed), mod._failed
|
|
|
|
|
|
def test_row_with_no_spinner_fails(tmp_path, monkeypatch):
|
|
"""Enabled but not spinning is still wrong: an unmeasured capability has to read as
|
|
'still checking', not as a settled verdict that happens to allow the click."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [SETTLED])
|
|
page = FakePage({TRAIN: SETTLED_ENABLED})
|
|
|
|
mod.assert_row_never_greyed_while_unmeasured(page)
|
|
|
|
assert any("spinner" in m for m in mod._failed), mod._failed
|
|
|
|
|
|
def test_absent_row_fails_instead_of_skipping(tmp_path, monkeypatch):
|
|
"""A row that never renders is the signed-out / unrendered shape. It must not be
|
|
read as 'nothing to check here'."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [SETTLED])
|
|
page = FakePage({TRAIN: None}, row_missing = True)
|
|
|
|
mod.assert_row_never_greyed_while_unmeasured(page)
|
|
|
|
assert any("never rendered" in m for m in mod._failed), mod._failed
|
|
|
|
|
|
def test_spinning_row_passes_and_the_route_is_lifted(tmp_path, monkeypatch):
|
|
"""The green case, and the only one there should be: the row spins on a forced
|
|
unmeasured verdict, and the interception is taken back off so the tab walk that
|
|
follows sees the real backend."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [SETTLED])
|
|
page = FakePage({TRAIN: SPINNING})
|
|
|
|
mod.assert_row_never_greyed_while_unmeasured(page)
|
|
|
|
assert mod._failed == []
|
|
assert page.routed == ["**/api/health"]
|
|
assert page.unrouted == ["**/api/health"]
|
|
assert page.gotos == [f"{BASE}/chat"]
|
|
|
|
|
|
def test_forced_body_is_a_real_reply_with_the_measurement_removed(tmp_path, monkeypatch):
|
|
"""What the browser is served has to be provisional by env.ts's rules: hardware
|
|
detecting, no device_type, and not the deferred marker (which env.ts reads as
|
|
settled and would grey the row out legitimately)."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [{**SETTLED, "hardware_detection_deferred": True, "studio_root_id": "abc"}])
|
|
page = FakePage({TRAIN: SPINNING})
|
|
|
|
mod.assert_row_never_greyed_while_unmeasured(page)
|
|
|
|
assert page.fulfilled_status == 200
|
|
assert page.fulfilled["hardware_detecting"] is True
|
|
assert page.fulfilled["chat_only"] is True
|
|
assert "device_type" not in page.fulfilled
|
|
assert "hardware_detection_deferred" not in page.fulfilled
|
|
# Untouched fields survive, so the reply differs from a real one only where it must.
|
|
assert page.fulfilled["studio_root_id"] == "abc"
|
|
|
|
|
|
def test_unreadable_health_fails_rather_than_returning_early(tmp_path, monkeypatch):
|
|
"""No body to build the provisional reply from means the check did not run. Say so."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
mod._get_json = lambda path, timeout = 10.0: (0, None)
|
|
page = FakePage({TRAIN: SPINNING})
|
|
|
|
mod.assert_row_never_greyed_while_unmeasured(page)
|
|
|
|
assert any("provisional" in m for m in mod._failed), mod._failed
|
|
|
|
|
|
# --------------------------------------------------------------------------------
|
|
# The real-warm sampler still has to fail when it does catch a grey-out.
|
|
# --------------------------------------------------------------------------------
|
|
|
|
|
|
def test_real_warm_grey_out_still_fails(tmp_path, monkeypatch):
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [UNMEASURED, SETTLED])
|
|
page = FakePage({TRAIN: GREYED})
|
|
|
|
mod.sample_natural_warm_window(page)
|
|
|
|
assert any("hardware_detecting=true" in m for m in mod._failed), mod._failed
|
|
|
|
|
|
def test_sampler_that_cannot_read_the_page_at_all_fails(tmp_path, monkeypatch):
|
|
"""The bare `except Exception: break` this replaced turned a dead page into a
|
|
silent zero-observation pass."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [UNMEASURED])
|
|
page = FakePage({TRAIN: SPINNING})
|
|
page.evaluate = lambda script, arg = None: (_ for _ in ()).throw(RuntimeError("page closed"))
|
|
|
|
mod.sample_natural_warm_window(page)
|
|
|
|
assert any("could not read the sidebar" in m for m in mod._failed), mod._failed
|
|
|
|
|
|
def test_missed_warm_window_alone_is_not_a_failure(tmp_path, monkeypatch):
|
|
"""Missing the real window is normal and must stay quiet, or the macOS job goes red
|
|
on every run. The forced check above is what carries the guarantee instead."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [SETTLED])
|
|
page = FakePage({TRAIN: SPINNING})
|
|
|
|
mod.sample_natural_warm_window(page)
|
|
|
|
assert mod._failed == []
|
|
|
|
|
|
# --------------------------------------------------------------------------------
|
|
# The tab walk: a pinned row that is not there means the tab checked nothing.
|
|
# --------------------------------------------------------------------------------
|
|
|
|
|
|
def test_drive_tabs_fails_when_the_pinned_rows_never_render(tmp_path, monkeypatch):
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [SETTLED])
|
|
page = FakePage({})
|
|
|
|
mod.drive_tabs(page)
|
|
|
|
assert mod._rows_seen == set()
|
|
for row_id in mod.INLINE_ROW_IDS:
|
|
assert any(f"nav row {row_id} is pinned inline" in m for m in mod._failed), mod._failed
|
|
|
|
|
|
def test_drive_tabs_does_not_fail_on_the_rows_that_live_under_more(tmp_path, monkeypatch):
|
|
"""Video and Export render inside the More dropdown, which mounts no data-testid at
|
|
all. Their absence is the documented shape, not a miss -- asserting on it would be
|
|
asserting on something that can never be true."""
|
|
mod = _load(tmp_path, monkeypatch)
|
|
_health(mod, [SETTLED])
|
|
page = FakePage({rid: SETTLED_ENABLED for rid in mod.INLINE_ROW_IDS})
|
|
|
|
mod.drive_tabs(page)
|
|
|
|
assert mod._failed == []
|
|
assert mod._rows_seen == set(mod.INLINE_ROW_IDS)
|
|
|
|
|
|
# --------------------------------------------------------------------------------
|
|
# Drift guard: the row asserted on has to be one the sidebar actually pins.
|
|
# --------------------------------------------------------------------------------
|
|
|
|
|
|
def test_inline_row_ids_match_the_frontends_default_pinned_set():
|
|
"""If a row is unpinned in the store, it stops rendering a data-testid and every
|
|
assertion pinned to it silently becomes unobservable. That is how the Video half of
|
|
this script came to check nothing, and it must not happen again unnoticed."""
|
|
src = APPEARANCE_STORE.read_text(encoding = "utf-8")
|
|
block = re.search(
|
|
r"SIDEBAR_NAV_DEFAULT_PINNED[^{]*\{(.*?)\n\};",
|
|
src,
|
|
re.S,
|
|
)
|
|
assert block, "SIDEBAR_NAV_DEFAULT_PINNED is gone or its shape changed"
|
|
entries = re.findall(r"^\s*(\w+):\s*(true|false),", block.group(1), re.M)
|
|
assert entries, "no id: boolean entries parsed out of SIDEBAR_NAV_DEFAULT_PINNED"
|
|
pinned = {name for name, value in entries if value == "true"}
|
|
|
|
mod_ids = _module_constant("INLINE_ROW_IDS")
|
|
assert set(mod_ids) == pinned, (
|
|
f"the script treats {sorted(set(mod_ids))} as pinned inline but the store pins "
|
|
f"{sorted(pinned)}; a row that is not pinned renders no data-testid, so any "
|
|
"assertion on it can only ever observe nothing"
|
|
)
|
|
assert _module_constant("GATED_ROW_ID") in pinned
|
|
|
|
|
|
def _module_constant(name: str):
|
|
"""Read a literal constant out of the script without importing it (no playwright,
|
|
no env contract, so this stays usable from a bare collection)."""
|
|
import ast
|
|
|
|
tree = ast.parse(SCRIPT.read_text(encoding = "utf-8"))
|
|
for node in tree.body:
|
|
if isinstance(node, ast.Assign) and getattr(node.targets[0], "id", None) == name:
|
|
return ast.literal_eval(node.value)
|
|
raise AssertionError(f"{name} is not defined in {SCRIPT.name}")
|
|
|
|
|
|
def test_the_forced_verdict_check_is_wired_into_the_public_entry_point():
|
|
"""main() calls assert_row_never_greyed_while_unmeasured, and that has to be what
|
|
reaches the forced check. Splitting them apart without calling both from main is the
|
|
one edit that would restore the vacuous pass while every test above still passes."""
|
|
import ast
|
|
|
|
tree = ast.parse(SCRIPT.read_text(encoding = "utf-8"))
|
|
called = {}
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef):
|
|
called[node.name] = {
|
|
sub.func.id
|
|
for sub in ast.walk(node)
|
|
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name)
|
|
}
|
|
assert "assert_pending_state_on_forced_verdict" in called.get(
|
|
"assert_row_never_greyed_while_unmeasured", set()
|
|
)
|
|
assert "assert_row_never_greyed_while_unmeasured" in called.get("main", set())
|