mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-16 04:13:54 +00:00
* 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>
79 lines
3.5 KiB
Bash
Executable file
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
|