unsloth/.github/scripts/boot-studio-api-only.sh
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

79 lines
3.5 KiB
Bash
Executable file

#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Wipe Studio's auth state and boot `unsloth studio` in the background, exporting
# the pid so a later step can stop it.
#
# Usage:
# boot-studio-api-only.sh --port 18888 [--log logs/studio.log] [--pid-var STUDIO_PID]
# [--api-only]
#
# On the name, and on --api-only being a flag rather than the default. The
# UNSLOTH_API_ONLY=1 below reads like the switch and is not: nothing in unsloth_cli
# takes it as input. Whether the web UI is served is decided by the CLI's --api-only
# flag alone, and the backend only ever reads that variable back out (main.py, to pick
# a CORS profile) after run.py has set it from the flag. So every caller here has in
# fact been booting a server that serves the frontend, and the Playwright UI smokes
# depend on exactly that -- passing --api-only unconditionally would leave them
# driving a browser at a backend with no UI. Hence opt-in: callers with no built
# `studio/frontend/dist` (mlx-ci.yml boots on a bare pip install) must ask for it, or
# the server prints "Unsloth frontend build not found" and exits before it binds.
#
# Twenty steps across eight workflows ran this same five-line body, varying only
# in those three values. Extracted so the three easy-to-get-wrong parts have one
# definition:
#
# * `rm -rf`, not `unsloth studio reset-password`. The boot below has to re-seed
# a fresh `.bootstrap_password`, and it only does that when the auth directory
# is absent. A caller that "resets" instead silently keeps the old password and
# the test then 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. `$!` alone dies with the step.
# * stdout AND stderr go to the log. The server writes its startup diagnostics to
# stderr, so a `>` without `2>&1` produces an empty log on exactly the failure
# a reader needs it for.
#
# NOT for `unsloth run`: that is serve-unsloth-run.sh, which boots a different
# command with a different contract (banner API key, /v1/models resolution) and
# has nothing to share with this beyond the word "boot".
#
# Deliberately does not wait for health. The callers' waits differ -- some poll
# /api/health and stop, others go on to rotate the bootstrap password and load a
# model in the same step -- and folding the simple case in here would leave the
# rest calling a script that does half their work.
set -uo pipefail
PORT=""
LOG="logs/studio.log"
PID_VAR="STUDIO_PID"
API_ONLY=""
while [ "$#" -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--log) LOG="$2"; shift 2 ;;
--pid-var) PID_VAR="$2"; shift 2 ;;
--api-only) API_ONLY="--api-only"; shift ;;
*) echo "boot-studio-api-only.sh: unknown arg '$1'" >&2; exit 2 ;;
esac
done
[ -n "$PORT" ] || { echo "boot-studio-api-only.sh: --port is required" >&2; exit 2; }
# Wipe rather than reset: the boot below re-seeds .bootstrap_password only when
# the directory is gone. See the header.
rm -rf ~/.unsloth/studio/auth
mkdir -p "$(dirname "$LOG")"
# shellcheck disable=SC2086 # $API_ONLY is one flag or empty, and must not become ''
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$PORT" $API_ONLY > "$LOG" 2>&1 &
SERVER_PID=$!
echo "[boot] unsloth studio ${API_ONLY:---with-frontend} on 127.0.0.1:${PORT}, pid ${SERVER_PID}, log ${LOG}"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "${PID_VAR}=${SERVER_PID}" >> "$GITHUB_ENV"
else
echo "${PID_VAR}=${SERVER_PID}"
fi