unsloth/tests/studio/test_mac_tab_capability_warm_window.py
Daniel Han 8b34acebcd
Studio: stop the macOS launcher killing a warming backend, and the Mac tab blackout (#8076)
* Studio: recover the MLX self-heal when uv cannot resolve the venv interpreter

On Apple Silicon the self-heal reinstalls mlx/mlx-lm/mlx-vlm to re-enable
Train/Export. It ran uv with --python sys.executable and nothing else, so when uv
declined that path the repair gave up and Train stayed disabled for good:

  MLX self-heal failed (staying chat-only):
  error: No virtual environment or system Python installation found for path
  `<studio_home>/unsloth_studio/bin/python`; run `uv venv` to create an environment

A venv's bin/python is a symlink into the base interpreter, and macOS breaks that
link routinely: a Homebrew or python.org point upgrade moves the target and the
venv keeps a dangling symlink. The running process never notices, because it
mapped the binary at exec time, so uv is the first thing to fail.

Name the environment as well as the interpreter. _uv_python_target prefers
sys.executable and falls back to the venv root when the interpreter no longer
re-resolves, _mlx_install_env sets VIRTUAL_ENV from sys.prefix, and a uv run that
still reports an unresolvable interpreter is retried once against the venv
directory. Only that specific failure retries; an ordinary resolution failure
stays a single run.

VIRTUAL_ENV is set from sys.prefix rather than forwarded from os.environ, matching
how UV_OVERRIDE is already handled: it names the install target, so inheriting it
would let a caller redirect the install.

When both attempts fail the venv itself is broken rather than merely missing MLX.
uv's own text says to run `uv venv`, which would build an environment Unsloth does
not manage, so the warning now names `unsloth studio update` instead.

Reported on macOS 0.1.524-beta.

* Studio: stop double-logging exceptions and sqlite-vec spam

A macOS diagnostics bundle came back at ~300KB, and roughly half of it was the
same tracebacks written twice.

LoggingMiddleware logs request_failed with exc_info, which structlog renders as
a full traceback inside the JSON "exception" field, then re-raises. Uvicorn logs
that same exception again on stderr as "Exception in ASGI application", and the
desktop shell mirrors every stderr line into tauri.log individually, so one
failure cost about 90 log lines. Mark the exception once request_failed has
reported it and filter uvicorn's duplicate on the uvicorn.error logger, the same
technique run.py already uses for the startup line. An exception raised above
the middleware carries no marker and keeps its traceback, and --verbose restores
both copies.

The bundle's own failure was a missing sqlite_vec/vec0.dylib, which the import
check does not catch: every /api/rag/knowledge-bases poll opened a connection,
failed to load the extension and 500ed. rag_db now warns once per process and
raises RagExtensionUnavailable (a RuntimeError subclass, so existing handlers
are unaffected), and list_knowledge_bases degrades to an empty list for that
case only. A locked or corrupt database still surfaces as before.

Also quiet the 2xx line for four boot-burst catalog reads (/api/providers/
registry, /api/providers/, /api/models/loras, /api/settings/personalization);
4xx/5xx and every mutation still log. And tauri.log only checked its 5MiB
rotation threshold at startup, so a long session grew unbounded: the file logger
now writes through a size-tracking handle that rotates in place.

On the reported bundle this removes 945 of 2063 lines (89KB of duplicate stderr
traceback) plus the 9 sqlite-vec request_failed events (48KB), leaving one
warning line.

* Studio: stop Mac launches blacking out the Train and Video tabs

The platform store seeds chatOnly from the browser user agent, so on every Mac
both rows rendered disabled from first paint, visually identical to a measured
"this machine cannot do that", until /api/health answered. Since the hardware
detection went lazy that reply can take seconds to minutes.

Add capabilitiesUnknown() next to isChatOnly(), derived from the fetched flag
that already means "a server-reported verdict is stored" (a deferred reply
counts as settled: under the torch-warm kill switch nothing else is coming).
NavRowDef gains a pending field, folded in by a small import-free resolver both
render sites go through, so an unmeasured row stays enabled and reuses the
existing spinner column instead of graying out. The root guard lets /studio and
/video wait the verdict out rather than one-way redirecting them to /chat, and
each page shows its own loading state while it does.

Video also gets a real capability answer. There is no Apple path in the video
backend, but a healthy Apple Silicon host is not chat-only, so the tab was
enabled and would just fail at load. video_capability() mirrors
export_capability() and is spliced into GET /api/system and
GET /api/system/hardware as additive fields; the page renders a coming-soon
panel on an authoritative false, and the sidebar tooltip is now derived from the
reason instead of hardcoding "needs an NVIDIA or AMD GPU".

Also retry a failed hardware probe in useHardwareInfo: it resolved to the
unloaded default with nothing scheduled to run again, which would have left the
new Video gate spinning for the session.

* Studio: add a macOS tab-capability UI smoke and nav row test hooks

Covers the two things reported on Apple Silicon 0.1.524-beta in one live run:
Train and Video rendering blacked out for minutes after launch, and the desktop
launcher killing the backend about a minute in with "Server stopped unexpectedly".

The nav button now carries data-testid="nav-row-<id>" and data-spinner, so the
smoke can tell a spinning row from a greyed-out one. Nothing reads them at
runtime; resolveNavRowState still owns the behaviour.

tests/studio/playwright_mac_tab_capabilities.py drives a live Studio: it samples
the Train and Video rows from first paint and fails if either renders disabled
while /api/health still reports hardware_detecting, walks Chat, Hub, Images,
Train, Video and Export clicking each row and screenshotting the result, and
polls /api/liveness and /api/health on a background thread for the whole run.

The poll window is 330s by default, deliberately past the launcher's 300s startup
grace: the reported crash landed at t+66s, so a backend that dies to the watchdog
fails here rather than looking like a slow boot. Redirects away from /studio and
/video are allowed only once the verdict is measured.

* Desktop: stop the health watchdog killing a backend that is still importing torch

The macOS "Server stopped unexpectedly" report was three probe timeouts inside the
first 64s of a normal cold start. #7958 fixed the grace period being bypassed; this is
the rest of it, on the probe itself.

Probe /api/liveness instead of /api/health. Health awaits hardware detection through
_await_hardware_detection on purpose, so probing it every 15s bills the watchdog for the
warm thread's torch import. Liveness reads module-level caches only, which is what it was
added for. Backends older than the route answer 404, so fall back to health, in the same
order process::generic_backend_health_ok and desktop_backend_owner::fetch_liveness use,
and accept "alive" or "healthy" so a downgrade still validates.

Raise the per-probe budget from 2s to 10s. The C-extension imports hold the GIL and the
process can go quiet for seconds at a time on a cold start (3735ms measured on /api/health,
with a ~27s silence around it). preflight::backend keeps its own 2s: a timeout there
dead-ends the launch instead of retrying, and the backend derives _HEALTH_DETECT_BUDGET_S
from that number.

Stop one early reply ending the startup grace for good. A backend that answers now can
still miss the next three probes while it loads a large model, so has_seen_healthy is set
only once a reply says the hardware verdict has settled. /api/liveness now carries the same
hardware_detecting marker health publishes, plus hardware_detection_deferred when the warm
is switched off and nothing will ever settle it; a backend too old to send either reads as
settled, which is what the launcher assumed before.

Adds the regression test #7958 landed without: the failure policy replayed against the
reported timeline, and the probe covered against a stub backend on both routes.

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

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

* Studio: accept either password env var in the macOS tab smoke

The repo's macOS workflow exports STUDIO_OLD_PW; the staging harness exports
STUDIO_PW. Reading only the first made the script KeyError before it reached the
backend under staging CI.

* Studio: make the new liveness and RAG tests hold on a macOS runner

Two assumptions that hold on this dev box but not on a bare macos-15 runner:

studio_root_id is environment-derived and is legitimately empty on a fresh
runner, so the liveness test asserts the key is present (which is what the
launcher reads) rather than that it is truthy.

python.org macOS builds ship a sqlite3 without enable_load_extension, so the
healthy-path RAG test cannot open a connection at all and errored in fixture
setup. It skips there. The unavailable-path tests, which are the ones this PR
changes behaviour for, still run everywhere.

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

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

* Studio: drop the MLX self-heal venv-root retry, which cannot work

Codex was right that passing the venv root to uv does not recover a dangling
bin/python. Confirmed against uv directly, with a venv whose interpreter symlink
was pointed at a missing target:

  --python <venv>/bin/python    error: No virtual environment or system Python
                                installation found for path ...
  --python <venv>               error: No virtual environment or system Python
                                installation found for directory ...
  VIRTUAL_ENV set, no --python  error: Failed to inspect Python interpreter from
                                active virtual environment

All three fail the same way, so the fallback and the retry were a placebo: when
the first attempt already resolved to the venv root the retry was skipped, and
when it ran it repeated a call that cannot succeed.

Keep the part that does work. uv refusing the environment means the venv is
broken rather than merely missing MLX, and nothing this process can pass to an
install command fixes that, so the warning names 'unsloth studio update' (which
rebuilds the environment) instead of uv's own 'uv venv' suggestion, which would
build one Unsloth does not manage.

_venv_root stays: it names the environment in that message.

* Studio: hold the startup grace for the whole warm, not just hardware detection

The health watchdog ends its five minute startup grace once a liveness reply says
the backend is no longer warming, and that signal was `hardware_detecting`. But
hardware detection is only the first of utils/torch_warmup.py's stages: the marker
disappears while inference_backend, transformers, datasets and unsloth_zoo are still
importing, and those are the C-extension imports that hold the GIL longest. So the
grace could end mid warm and a stall spanning three 10s probes would count as three
dead probes against a backend that was starting normally, which is the
unresponsive_health_check kill the grace exists to prevent.

Publish `torch_warm_in_progress` on /api/liveness and /api/health, derived from
warm_status(), and read that in the launcher.

A new field rather than a wider `hardware_detecting`: that marker also means "this
hardware verdict is provisional, re-read it", and config/hardware-verdict.ts keeps
the UI provisional and polling while it is set, so keeping it lit through datasets
would hide Train for the whole warm over a verdict that settled seconds in.

Additive and backwards compatible both ways. The launcher keeps its
`hardware_detecting` path as a fallback, so a backend that predates the new field
still gets the grace it gets today, and a backend that sends it talks to an older
launcher exactly as before.

The deferred case is preserved by construction: the field is published only while a
warm thread is actually running, so it is absent both when the warm finished and
when none is coming. UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1 never starts one, and a
warm retired mid stage by a shutdown never sets finished; deriving the field from
"not finished" would have reported either as warming forever and held the grace
open until it expired on its own.

* Studio: poll the hardware verdict out of its unknown state on every platform

Train and Video now spin instead of graying out while the verdict is unmeasured,
so something has to end the spin. fetchDeviceType spends its bounded wait at most
once per page load, so a host that detects slower than that keeps the provisional
reply and `fetched` stays false. The sidebar's recovery poll returned early unless
the host was chat-only or deferred, which off macOS it is not: the store seeds
chatOnly from the user agent, so on Linux and Windows nothing re-read /api/health.
A cold GPU host importing torch sits squarely in that window, and there the rows
spun and /studio held its loading panel until the user navigated or reloaded.

Poll while the verdict is unknown too, on any platform, and keep the mlx_unavailable
and deferred cases as they were. The poll re-reads with force, which is what gets
past the cached provisional reply and the spent wait latch, and the effect re-runs
when the verdict lands, so the interval is cleared as soon as it is known. Re-reads
are skipped while one is outstanding, bounded so a request that never settles cannot
hold the poll off.

studio-page and video-page gate on the same store value, and the sidebar is mounted
on both routes, so they recover with it rather than growing a second poll.

Covered by tests/hardware-verdict-recovery.test.ts, which drives the real store
through a slow non-Mac detection and asserts the guard arms on the unknown state.

* Studio: teach the run-module test stub about the new loggers export

run.py imports install_uvicorn_duplicate_exception_filter at module scope, and
load_studio_run_module replaces 'loggers' with a stub module carrying only
get_logger, so importing run raised ImportError before any test body ran:

  ImportError: cannot import name 'install_uvicorn_duplicate_exception_filter'
  from 'loggers' (unknown location)

A no-op is the right stub here. The filter only de-duplicates uvicorn's copy of a
traceback and this module never starts a server.

* Studio: run the tab-capability smoke in the macOS UI job

Codex was right that the script was dead code: nothing under .github invoked
playwright_mac_tab_capabilities.py, so the regression coverage it claims never
executed and the tab blackout could come back unnoticed.

It gets its own boot on a cold port. The assertion is that Train and Video spin
rather than grey out while the verdict is unmeasured, and that window only exists
on a backend that has not warmed yet, so reusing the already-warm 18897 server
would have passed vacuously. For the same reason this phase deliberately does not
wait for a healthy backend first, unlike every other phase here: waiting is how
you miss the window. The script does its own wait, and health answers
provisionally inside a 1s budget.

The liveness poll is cut from its 330s default to 120s here. The full window
exists to outlive the launcher's 300s startup grace, and nothing in this job runs
that watchdog; it boots 'unsloth studio' directly. Spending five macOS-runner
minutes to prove something this job cannot observe is not worth it, and the
watchdog is covered by the Rust tests in commands.rs.

* Studio: let /video reach its own gate, and stop stale polls freeing the guard

Two review findings on this branch.

/video was still outside CHAT_ONLY_ALLOWED, so on a measured chat-only host, a
CPU-only box or a Mac without usable MLX, a direct link or a reload bounced to
/chat before VideoPage could render. That is exactly where the unsupported
explanation this branch added has something to say, so the message was
unreachable in the only cases it was written for. Video now follows /export: the
route is allowed through and the page self-gates on the backend's video verdict,
so nothing loads on a host that cannot run it.

The recovery poll's no-stacking guard could be freed by a read that no longer
held it. A read outliving the 30s stall window is abandoned and the next tick
starts a replacement, but the abandoned read's finally still zeroed the shared
marker, so every following tick saw a free guard and fired another forced
/api/health. On the backend this poll exists for, one still importing torch, that
is a read every three seconds piled onto the process being waited for. A
generation counter now means only the owning read can clear it.

Both regressions verified to fail without the fix: 3 of 754 tests go red.

* Studio: make the tab-capability smoke fail instead of passing vacuously

The first staging run went green having tested nothing. The log tells the story:

  [mac-tabs] spinner observed during warm: {'train': False, 'video': False}
  [mac-tabs] Train: redirected to http://127.0.0.1:8888/login ... (allowed)
  [mac-tabs] Train: nav row not pinned inline; reached by route instead
  [mac-tabs] PASS

The login form takes a username as well as a password and the script filled only
the password, so the submit was a no-op and the browser stayed signed out. Every
later check then read an empty shell: no sidebar, so no nav row, so no assertion
had anything to act on. The redirect check called the bounce to /login 'allowed'
because it only asked whether the verdict was measured, and the row checks
downgraded a total miss to an info line.

Three changes, all so this run would have gone red:

log_in fills the username, then proves the session took by navigating to /chat
and checking it stayed; a failure there aborts rather than continuing into checks
that cannot mean anything.

A bounce to /login, /onboarding or /change-password during the tab walk is a
failure. The session was proven live before the walk started, so losing it
mid-walk is never an allowed redirect.

A walk that never locates a single nav row now fails. That is the shape a
signed-out or unrendered run takes, and it has to be loud.

The survival poll was the one part that did mean something: 65 samples on each of
/api/liveness and /api/health, zero non-200, backend alive past 319s.

* Studio: make RAG unavailability one coherent state across the router

Degrading the KB list to an empty response when sqlite-vec's native library
cannot load stopped the 500-plus-traceback on every poll, but it left the rest
of the router behind: the frontend read the empty list as a working empty
state, offered Create, and the POST went straight to get_connection() and
raised. So the fix for the log spam reintroduced the log spam one click later,
and told the user nothing.

One contract now. The polled KB list still degrades, but carries ragAvailable
and ragUnavailableReason so a client can tell "no knowledge bases yet" from
"RAG cannot run on this machine". Every other endpoint answers 503 stating the
same reason, via a rag_available() gate up front and a _rag_connection()
wrapper that catches the case where the first request of a session is the one
that discovers the library is missing. That wrapper also reaches the
connections ingestion opens for itself, and removes an upload it had already
saved rather than orphaning it in the uploads root.

The two new fields are additive and the document listings are deliberately
left erroring rather than degrading, so a frontend that has not learned to read
the marker keeps every error surface it has today.

rag_available() remembers only that the extension loaded, never that it failed,
so a one-off cannot latch RAG off until restart. The warn-once stays: a 503
path that logged per request would be the same regression in a new place.
Genuine database errors are untouched and still surface as real errors.

reconcile_orphaned_ingestion_jobs() gates on rag_available() too, so it is the
no-op its docstring already claimed instead of raising out of startup to be
logged as a reconcile failure.

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

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

* Studio: let the UI read the RAG availability the backend already reports

routes/rag.py answers a host where sqlite-vec will not load as a contract: the
polled KB list degrades to 200 with ragAvailable/ragUnavailableReason beside an
empty list, and every other endpoint answers 503 with the same reason. The client
dropped both, so a broken-RAG Mac still showed an apparently working, empty
Knowledge bases page whose Create button could only 503.

features/rag/api/rag-availability holds the verdict, modelled on the platform
store in config/env: optimistic until the backend has actually answered, so a
slow first poll, an unreachable server and a backend that predates the marker
all render exactly as they do today. Only a measured unavailable gates anything.

Wired in at every response path, including the two that bypass ragRequest
(ragUpload, streamJobEvents), so a user who lands on a mutating route first gets
a coherent UI instead of waiting for the list poll. listKnowledgeBases reads the
marker, which is the only way to tell an empty store from a host where RAG cannot
run. The dialog then disables New knowledge base and Create/Save, guards
submitForm for the keyboard path, and shows the backend's reason in place of
"No knowledge bases yet.".

Also breaks a hang this condition triggers. targetHasIndexingDocuments answers
"still indexing" whenever its listThreadDocuments probe throws, and
dispatchQueuedPrompt reschedules on that with no cap, so on a broken-RAG host a
queued prompt on a thread using documents was never dispatched at all. A 503 is
now distinguishable, and there are no documents to wait for, so it sends. A
transient failure still holds the prompt back as before.

This is deliberately not folded into useRagToolDisabled: that is a model
capability gate and is false when no model is loaded.

* Studio: report video as macOS-unsupported on Intel Macs too

video_capability() keyed the macOS branch on is_apple_silicon(), so an Intel Mac
fell through to pytorch_not_installed or no_accelerator and was told to install
PyTorch or add a GPU. Neither enables video: the diffusers pipelines have no
supported macOS path at all, so both Macs get the same honest answer.

The accompanying AST test also asserted is_apple_silicon() was named in the
function, which kept passing on the word surviving in a comment. Strip comments
before asserting so it tests the gate rather than the prose.

* Studio: only the backend's own 503 detail is a RAG capability verdict

A 503 is what a reverse proxy, Cloudflare or a briefly overloaded server returns,
and those bodies say nothing about sqlite-vec. Recording one as unavailable gated
the Knowledge bases dialog for the rest of the session behind a transient outage,
explaining it with an extension failure that never happened -- and only a 2xx from
a gated endpoint could clear it.

Match the RAG router's own wording instead. A bodyless or unrelated 503 now leaves
availability unknown, which is what the store was built to represent.

* Studio: fix the macOS tab smoke signing in, which made every assertion vacuous

The helper read the password field with count() straight after domcontentloaded.
auth-form.tsx returns null while the auth-status request is in flight, so there is
no form in the DOM yet, count() does not wait, and the run fell through to
'assuming desktop auth' and checked an empty signed-out shell. It also filled a
username the login form does not have and clicked a button labelled 'Sign in'
when the label is 'Login'.

Wait for #password, click Login, and wait for the post-auth route. Verified against
a live Studio: the helper now reports 'signed in' where it previously did not.

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

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

* Studio: make the tab-capability smoke create the window it asserts on

Second hole of the same shape in this file. assert_row_never_greyed_while_unmeasured
computed seen_spinner and only logged it, so when the verdict settled before the
browser arrived the loop broke on its first iteration, nothing was sampled, and the
run went green having observed nothing.

Requiring the window would not have fixed it, because the window is not there to be
required. hardware_detecting covers stage 0 of the warm, and on the macOS runner's
--no-torch install with no MLX that stage is a failed `import torch` plus one failed
metadata lookup: it settles inside a second of the port binding, while Playwright is
still launching Chromium. Add the login and two navigations, one of which spends the
frontend's own 5s wait on the verdict, and the sampler arrives 15 to 20s late on every
host, fast or slow. The workflow comment claiming a cold boot keeps the window open
was wrong; it is corrected to say what boot ordering actually buys, which is a
provisional reply to the first /api/health probe and nothing past it.

So the script opens its own window instead of racing that one. It answers the
browser's /api/health with a real reply that has the measurement taken back out, holds
it there, and requires nav-row-train to render enabled with data-spinner="true", the
way pending beats disabled in resolveNavRowState. That window is open for as long as
the check needs, on any host, and a missing row fails rather than skips, so there is
no path through it that reports success without having read the row. The real warm is
still sampled and a real grey-out still fails, but nothing is required of it.

Two more things in here could observe nothing:

The Video half of this file was structurally empty. Video is not pinned inline
(SIDEBAR_NAV_DEFAULT_PINNED), so it renders inside the More dropdown, which mounts
nothing until it is opened and carries no data-testid even then. Every query for
nav-row-video returned null on every host, so seen_spinner["video"] could never be
true and "Video: nav row not pinned inline" was a permanent info line, not a finding.
Train carries the same pending flag through the same resolver, so it is the observable
end of that wire; the rows the sidebar does pin are now named, an inline row that does
not render is a failure, and the More rows are documented as expected misses.

_saw_any_row was satisfied by any row on any route, and the sampler swallowed a dead
page with a bare `except Exception: break`. Now the walk requires every default-pinned
row to have been seen, and a page that cannot be evaluated at all fails.

tests/studio/test_mac_tab_capability_warm_window.py drives all of this with the page
and the backend stubbed, so the red cases are checked in the tests/ walk rather than
only on a macOS runner. Against the previous file, its first case reproduces the
staging log exactly: "spinner observed during warm: {'train': False, 'video': False}",
then PASS.

* Studio: give an adopted backend the startup grace when it is still warming

The watchdog already knows that one healthy answer is proof of life and not proof
that startup is over, but only on the path where this app spawned the backend. An
adopted backend starts with the latch already set, on the reasoning that it was
serving before the app attached. That is the same fallacy: a force-quit during a
cold start leaves the backend running and still importing the ML stack, and the
relaunched app adopts it. Three GIL-stalled probes later it was cleared and the
user got a crash screen for a host that was starting normally.

The ownership probe carries no warm-up signal, so ask the backend directly once
ownership is verified, and clear the latch on a warming reply rather than merely
declining to set it. The grace stays bounded at 300s either way.

This path predates the fix on the owned side; it is the other half of the same
report rather than a regression.

* Studio: fix five guards in this PR's own tests that could not fail

Each was verified by mutation: the regression the test names was applied, the
suite stayed green, and it now goes red.

- provisional-hardware-verdict: the slice end was located by searching for the
  latch it then asserted was absent. String.search returns the first match, so
  moving the latch into the loop moved the boundary with it and the assertion
  passed against exactly the regression its header describes. Anchor on the
  loop's closing brace, and require the latch to exist after it so deleting it
  outright is not a pass either.
- liveness warm state: the subprocess harness builds its result with
  body.get(studio_root_id), so the key is present whether or not the reply
  carried it. Deleting the field from liveness_check() left the test green.
  Report presence explicitly, as the two neighbouring fields already do.
- warm window, DEVICE guard: asserted a count of at least two when the function
  has three comparisons, so deleting the poll loop's requirement still left two.
  Bind to the sites instead of counting them.
- warm window, boot silence: scanned only for _fetch_top_models, but the fetch
  is started through the public wrapper now, so putting _start_top_models_fetch
  back in the constructor restored the huggingface.co call on every boot
  undetected. Check both names.
- rag availability: asserted only after noteRagAvailability, which writes
  unavailable unconditionally, so the exemption under test could be deleted.
  Assert between the two calls.

Also replaces a dead 'disabled: chatOnly;' string check: that is an object entry,
so the regressed form ends in a comma and the literal could never match.

* Studio: deliver the hardware verdict to a component that subscribed a tick late

useHardwareInfo seeds its state from the module cache during render, but joins
the listener set in the effect. A probe resolving between those two points
notifies the listeners registered at the time, which does not include this one,
and leaves the cache set, so 'if (!cached) load()' skipped the fetch as
redundant. Nothing remained that would ever call setInfo, and the component sat
on the unloaded default for its whole life.

That was survivable while callers read individual fields. This PR gates whole
pages on 'loaded', so it now reads as 'Checking this machine for video support'
for the rest of the session, which is the stuck-loading state the PR exists to
remove. Hand the cache straight to the listener instead.

Also stops a successful 200 that a later invalidate superseded from resolving as
the unloaded default, which load() reads as a failed probe.

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

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

* Studio: complete the forced password change in the macOS tab smoke

The repo's own macOS smoke hands this script the raw bootstrap password, and a
backend that still holds one injects it into the page and signs itself in, landing
on /change-password with no login form ever rendered. This script treats that as a
signed-out route, since it has no sidebar to assert against, so the run failed with
'could not sign in'. The staging harness rotates over the API before driving, which
is why the same script passed there and failed here.

Rotate it in the browser instead of giving up, and check for that screen before
waiting on the login field so an authenticated session does not spend a minute
waiting for a form that is correctly absent.

Verified against a live Studio on both paths: a fresh instance holding its
bootstrap password now reports 'password rotated' then 'signed in' where it
previously reported 'could not sign in', and an already-rotated instance still
signs in through the login form.

* Studio: give the adopted ownership probe the watchdog's probe budget

The warm-up read added for adopted backends is gated on ownership verifying, and
that probe runs every request at the 2s default. During the multi-second GIL stall
the watchdog exists to ride out, both requests inside it time out, the backend
comes back unverified, and the warm-up read never runs at all, so the grace never
reopens and three stalls still clear the backend. The longer budget has to be
applied before verification, not after it.

probe_owned_backend_state keeps its signature and delegates to a variant taking an
explicit budget, so only the watchdog changes. A guard binds that call site to
HEALTH_PROBE_TIMEOUT and fails if it drifts back to the default.

Also uploads the tab-capability phase's log and Playwright evidence, which the
artifact list did not name, so a red macOS run discarded the only record of it.

* Studio: match the RAG 503 on the extension name only

Matching either fragment meant the loose half carried the same weight as the
specific one: anything RAG-aware in front of the backend can answer a transient
503 saying RAG is unavailable without meaning the extension, and that persisted a
capability verdict which only a later successful gated request could clear.

Keep sqlite-vec and drop the English phrase. Nothing upstream emits a package name
by accident, and the capability being gated is exactly that extension, so this is
narrower than requiring both fragments would be while still tolerating a reworded
backend detail. Requiring both would have made the matcher brittle to precisely the
rewording the fragment match exists for.

Two proxy phrasings added to the generic-503 case, which restoring the loose marker
now fails.

* Studio: stop the MLX install env claiming a recovery it does not perform

_mlx_install_env's docstring still described VIRTUAL_ENV as the second half of a
dangling-symlink recovery, and named the helper that performed the first half.
That helper was deleted earlier in this PR when the retry was found not to work,
but the claim outlived it and is false: an explicit --python outranks VIRTUAL_ENV,
so uv reports the same unresolved-interpreter error either way.

It is not a harmless stale comment. It is the tree asserting a repair that does not
happen, and a reviewer reading it asked for the mechanism to be restored. State
what uv actually does, and why --target and --prefix are not the escape hatch they
look like: both exit 0 against a broken venv but resolve against whatever ambient
interpreter uv finds, writing a wrong-ABI or off-sys.path install that leaves
mlx_stack_available() False while looking like success.

Two guards: the deleted helper must stay deleted and the claim must not come back,
and the unresolved-interpreter path must stay one attempt plus a diagnosis, never a
second install with a different target.

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

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

* Studio: make the watchdog-budget guard line-ending agnostic

include_str! embeds the file exactly as checked out, so on Windows the source is
CRLF and the \n}\n search for the end of check_watchdog_health never matched. The
guard panicked on the Tauri CI runner while passing on every Linux and macOS job.
Normalise before searching.

Verified both ways: 149 tests pass on the LF tree, and the guard still passes after
converting the file to CRLF, which reproduced the runner failure before this.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-07 02:45:06 -07:00

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())