Commit graph

2 commits

Author SHA1 Message Date
Daniel Han
7103733d7b
Studio: hold the Mac capability verdict while an MLX repair can still overturn it (#8152)
* Studio: keep the Mac capability verdict provisional while MLX repair runs

* Studio: bound the MLX repair hold so a scheduler that never runs cannot spin the tabs

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

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

* Studio: gate the Mac capability verdict on a real Apple Silicon host

The hold that keeps /api/health from publishing a chat-only verdict an MLX
self-heal is about to overturn was covered only by unit tests, which drive it
with a fake clock and a stand-in repair worker. Nothing ran it where `import
mlx.core` succeeds or fails for real.

tests/studio/mac_capability_verdict_smoke.py boots the real `unsloth studio`
through .github/scripts/boot-studio-api-only.sh and polls /api/health from the
first reply the socket gives, judging the whole sequence rather than the last
answer, since the reported bug was a verdict that was wrong for a while and
right afterwards. Three boots, one per scenario, because the self-heal is once
per process:

- real-mlx: the verdict must settle chat_only=false with /api/system reporting
  device_backend "mlx", and no reply on the way there may publish chat_only
  true.
- no-mlx-settles: MLX made unimportable and UNSLOTH_DISABLE_MLX_AUTOREPAIR=1.
  The verdict must still settle promptly to mlx_unavailable, or a Mac with no
  repair coming spins Train and Video for the session.
- no-mlx-repair: MLX made unimportable, self-heal on. No reply may publish the
  verdict while the installer runs, including well past the pre-start grace so
  the hold is provably the live worker, and it must settle once the install
  fails.

MLX is made unimportable the way tests/studio/test_hardware_dispatch_matrix.py
does it, through the import system, but from a sitecustomize on PYTHONPATH
since there is a server to boot. It hides mlx from PathFinder rather than
raising, so the find_spec in unsloth/__init__.py answers None instead of
crashing. The installer in the third scenario is a stub uv first on PATH that
records its argv and takes its time; utils.mlx_repair is untouched and runs its
real thread, real command and real subprocess against it. The recorded argv is
asserted, so a run where the self-heal never reached the installer fails rather
than passing quietly.

mlx-ci.yml gains the three steps plus a venv at $STUDIO_HOME/unsloth_studio,
which is what `unsloth studio` re-execs into and refuses to start without.
Building it over the job's own site-packages gets the real launch path without
the ten minutes install.sh costs. Path filters pick up main.py, mlx_repair.py
and the two boot scripts; timeout goes to 40 minutes for the in-flight boot,
which waits out the torch warm before the self-heal is even scheduled.

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

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

* CI: let boot-studio-api-only.sh actually boot API-only

The script exports UNSLOTH_API_ONLY=1 and its banner says --api-only, but nothing
in unsloth_cli reads that variable. Whether the web UI is served is decided by the
CLI flag alone; the backend only reads the variable back out in main.py to pick a
CORS profile, after run.py has set it from the flag. Every caller has therefore
been booting a server that serves the frontend, which is what the Playwright UI
smokes need, so the flag is opt-in and no existing caller changes.

The capability-verdict boots in mlx-ci.yml are the first caller without a built
studio/frontend/dist (it is gitignored, and that job installs with pip rather than
install.sh). Without the flag the server printed "Unsloth frontend build not found"
and exited before binding, which is how this surfaced.

Also in the smoke driver: give up a wait as soon as the server process is gone
rather than spending the full budget, and flush the boot banner when it is written
so it does not surface minutes later next to the timeout it preceded.

* Studio: drop the verdict cache and bound both halves of the MLX repair hold

Remove the localStorage hardware-verdict cache. It did not buy what it
claimed: the store deliberately kept `fetched` false, so
`capabilitiesUnknown()` is still true on a returning user's first paint
and the rows spin exactly as they do without it. Its only demonstrated
effect was unbreaking a `/settings` deep link, and it introduced a real
regression in exchange. model-selector.tsx, pickers.tsx,
model-inspector.tsx and app-sidebar.tsx read `chatOnly` with no
`capabilitiesUnknown()` gate, so a cached `chatOnly=false` during a
repair window offers MLX-only models that cannot load - worse than the
conservative `navigator.platform` guess it replaced. env.ts and both
frontend test files are back to what main has, and nothing under
studio/frontend changes in this PR.

Start the pre-start handoff grace on the first reading that sees the
warm stopped rather than the last reading that saw it running. The
warm's final stages are C-extension imports that hold the GIL for
seconds at a time, so health requests queue behind them and the next one
served can be the first in a minute. Measured from the last observed
poll the grace could already be spent by the time anyone could ask,
publishing the mlx_unavailable verdict during the very handoff the hold
exists to cover. A stopped reading between two running ones is a lull,
not the end, so it reopens.

Bound a live repair worker with _WORKER_BUDGET_S, the subprocess timeout
plus 300s. attempt_mlx_repair times the uv call but not the
mlx_stack_available() imports that verify the install nor the
detect_hardware() pass after it, and those import mlx.core, mlx_lm and
mlx_vlm on a stack this module already assumes can park indefinitely. An
alive thread was an unbounded answer, so a parked worker held the
verdict provisional for the whole session. Fix _superseded_by_mlx_repair
and mlx_repair_in_flight docstrings, which claimed a cap that did not
exist.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-08 06:47:11 -07:00
Daniel Han
7e47cc03c1
Extract the API-only Studio boot into one script (#8029)
Twenty-one steps across eight workflows ran the same five-line body, varying
only in port, log filename and the name of the environment variable the pid
lands in. .github/scripts/boot-studio-api-only.sh takes those three as flags.

Three parts of that body are easy to get subtly wrong, which is the argument
for one definition rather than twenty-one:

  rm -rf, not `reset-password`. The boot only re-seeds a fresh
  .bootstrap_password when the auth directory is absent, so a caller that
  resets keeps the old password and the test authenticates against stale state.

  The pid has to reach $GITHUB_ENV, because the step that stops the server is a
  different step with a different shell.

  Both streams go to the log. The server writes startup diagnostics to stderr,
  so a redirect without 2>&1 leaves an empty log on exactly the failure someone
  needs it for.

Not folded into serve-unsloth-run.sh, despite the plan saying so. That script
boots `unsloth run --disable-tools` and its body is banner-key parsing and
/v1/models resolution; these boot `unsloth studio --api-only` with auth wiped
and no banner. The two share the word boot and nothing else, and each header
now says which is which.

Deliberately does not wait for health: the callers' waits differ, some polling
/api/health and stopping while others go on to rotate the password and load a
model in the same step.

Left alone: three boots inside Playwright retry loops, which re-boot mid-loop
and reassign the pid in the running shell, and three boot-briefly-and-kill
steps that keep the pid in a local variable rather than $GITHUB_ENV.

Each caller's paths filter gains the script, so an edit to it re-runs the
workflows that call it -- the trap #8015 hit when the install body moved out
from under those filters.
2026-08-06 07:40:28 -07:00