Unsloth start improvements: download progress, server reuse, and safe model switching (#7313)

* Improve unsloth start runtime lifecycle

* Remove speculative Gemma prompt override

* Polish model download progress output

* Refine unsloth start status output

* Clarify unsloth readiness banner

* Clarify model reuse and switching output

* Queue model switches behind active inference

* Tighten unsloth start model switching

* Reduce model switch bookkeeping

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

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

* Fix Studio re-exec compatibility

* Recheck sidecar reservation after inference drain

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

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

* Pass start marker through child environment

* Fix key redaction, switch-waiter ordering, and stop/messaging gaps for PR #7313

- Redact minted sk-unsloth keys from the startup-failure log tail: the early
  key marker lands in the server log before the model load finishes, so a
  load-phase crash printed a live key to the terminal
- Deregister a finished switch waiter before releasing the swap gate so a
  swap on another event loop cannot count it as still queued and unload the
  model the finished request is about to generate against
- Warn on same-repo quant switches: an explicit variant replaces the resident
  weights for every attached session, but the repo ids match so no switch
  warning was printed
- Note the agent exit code when it is nonzero so the server keep-alive
  message does not read as a successful session
- Use taskkill /T in unsloth studio stop so llama-server children stop too

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

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

* Tighten comments in start, studio, and inference changes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
oobabooga 2026-07-22 06:36:24 -03:00 committed by GitHub
commit 8b3c37246c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1009 additions and 171 deletions

View file

@ -14,12 +14,13 @@ import signal
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import NamedTuple, NoReturn, Optional
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import click
import typer
@ -105,8 +106,8 @@ _SERVE_OPTION = typer.Option(
True,
"--serve/--no-serve",
help = (
"If no Unsloth server is running, auto-start one for --model and stop it when the "
"agent exits. --no-serve keeps the old behavior of erroring out."
"If no Unsloth server is running, auto-start one for --model and keep it available "
"after the agent exits. --no-serve keeps the old behavior of erroring out."
),
)
# Model-load knobs mirrored from `unsloth run`; only used when --model triggers a
@ -326,6 +327,13 @@ def _split_repo_variant(model: str) -> tuple:
return repo, variant
def _display_model_spec(model: str, variant: Optional[str]) -> str:
"""Return a user-facing model name that includes the selected GGUF variant."""
repo, inline_variant = _split_repo_variant(model)
selected_variant = variant or inline_variant
return f"{repo}:{selected_variant}" if selected_variant else model
def _fail(message: str) -> NoReturn:
typer.echo(message, err = True)
raise typer.Exit(code = 1)
@ -373,11 +381,265 @@ def _http_json(
# A server that WE auto-started (never one we merely found). Kept at module scope so
# _run's finally and the atexit backstop can tear it down without threading a handle
# failure paths and the atexit backstop can tear it down without threading a handle
# through all six agent commands. Only one agent runs per process, so one slot is enough.
_auto_served_server: Optional[subprocess.Popen] = None
# Model download + load can be slow; give the auto-started server room before giving up.
_SERVER_START_TIMEOUT_S = 900
_DOWNLOAD_POLL_INTERVAL_S = 1.0
_START_API_KEY_PREFIX = "UNSLOTH_START_API_KEY: "
_START_API_KEY_MARKER_ENV = "_UNSLOTH_START_API_KEY_MARKER"
def _format_download_bytes(value: int) -> str:
value = max(0, int(value))
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if value < 1024 or unit == "TiB":
precision = 0 if unit in ("B", "KiB") else 1
return f"{value:.{precision}f} {unit}"
value /= 1024
return "0 B"
def _format_download_eta(seconds: float) -> str:
seconds = max(0, int(seconds))
if seconds < 60:
return f"{seconds}s"
minutes, seconds = divmod(seconds, 60)
if minutes < 60:
return f"{minutes}m {seconds:02d}s"
hours, minutes = divmod(minutes, 60)
return f"{hours}h {minutes:02d}m"
class _DownloadProgressDisplay:
"""Render download progress without making redirected output noisy."""
def __init__(self) -> None:
self._samples: list[tuple[float, int]] = []
self._shown = False
self._last_bucket = -1
self._last_line_length = 0
self._last_expected = 0
self._interactive = bool(getattr(sys.stdout, "isatty", lambda: False)())
def update(self, progress: dict) -> None:
downloaded = max(0, int(progress.get("downloaded_bytes") or 0))
completed = max(0, int(progress.get("completed_bytes") or 0))
expected = max(0, int(progress.get("expected_bytes") or 0))
self._last_expected = max(self._last_expected, expected)
fraction = float(progress.get("progress") or 0)
if downloaded <= 0:
return
# A fully cached snapshot can report 99% with no incomplete bytes; that is
# not a transfer, so don't show it as a download.
if completed >= downloaded > 0:
return
now = time.monotonic()
if self._samples and downloaded < self._samples[-1][1]:
self._samples.clear()
self._samples.append((now, downloaded))
cutoff = now - 15.0
while len(self._samples) > 2 and self._samples[0][0] < cutoff:
self._samples.pop(0)
rate = 0.0
if len(self._samples) >= 2:
elapsed = self._samples[-1][0] - self._samples[0][0]
delta = self._samples[-1][1] - self._samples[0][1]
if elapsed >= 1.0 and delta > 0:
rate = delta / elapsed
if expected > 0:
# The endpoint caps at 99% while bytes remain in an incomplete file; trust it.
fraction = min(1.0, max(0.0, fraction))
percent = min(100, max(0, int(fraction * 100)))
filled = min(24, int(fraction * 24))
bar = "=" * filled + ">" + "." * max(0, 23 - filled) if filled < 24 else "=" * 24
line = (
f"Downloading model [{bar}] {percent:3d}% "
f"{_format_download_bytes(downloaded)} / {_format_download_bytes(expected)}"
)
bucket = percent // 10
if rate > 0:
line += f" | {_format_download_bytes(rate)}/s"
if downloaded < expected:
line += f" | ETA {_format_download_eta((expected - downloaded) / rate)}"
else:
line = f"Downloading model: {_format_download_bytes(downloaded)}"
bucket = downloaded // (1024**3)
if rate > 0:
line += f" | {_format_download_bytes(rate)}/s"
if self._interactive:
padding = " " * max(0, self._last_line_length - len(line))
typer.echo(f"\r{line}{padding}", nl = False)
sys.stdout.flush()
self._last_line_length = len(line)
elif not self._shown or bucket > self._last_bucket:
typer.echo(line)
self._last_bucket = bucket
self._shown = True
def close(self) -> None:
if self._interactive and self._shown:
typer.echo()
self._last_line_length = 0
def complete(self) -> None:
"""Finish a displayed transfer after the model load confirms success."""
if not self._shown:
return
downloaded = self._samples[-1][1] if self._samples else 0
expected = max(downloaded, getattr(self, "_last_expected", 0))
self.update(
{
"downloaded_bytes": expected,
"expected_bytes": expected,
"progress": 1.0,
}
)
def _normalized_variant(value: object) -> str:
return re.sub(r"[^a-z0-9]", "", str(value or "").lower())
class _ModelDownloadProgress:
"""Best-effort polling of the model download endpoints."""
def __init__(self, base: str, key: str, model: str, variant: Optional[str]) -> None:
self._base = base
self._key = key
self._model = model
self._variant = variant or ""
self._expected_bytes = 0
self._display = _DownloadProgressDisplay()
self._configured = False
self._disabled = not _is_hub_model_id(model)
self._progress_prefix = "/api/hub"
def _configure(self) -> None:
self._configured = True
if self._disabled:
return
# GGUF repos need the selected quant's size; the repo endpoint totals every
# quant. Resolve the variant first, otherwise show bytes only.
if self._variant or "gguf" in self._model.lower():
try:
params = urlencode({"repo_id": self._model})
try:
info = _http_json(
"GET",
f"{self._base}/api/hub/gguf-variants?{params}",
self._key,
timeout = 10,
)
except urllib.error.HTTPError as exc:
if exc.code != 404:
raise
self._progress_prefix = "/api/models"
info = _http_json(
"GET",
f"{self._base}/api/models/gguf-variants?{params}",
self._key,
timeout = 10,
)
self._variant = self._variant or str(info.get("default_variant") or "")
wanted = _normalized_variant(self._variant)
for item in info.get("variants") or []:
quant = _normalized_variant(item.get("quant"))
filename = _normalized_variant(item.get("filename"))
if wanted and (wanted == quant or wanted in filename):
self._expected_bytes = int(
item.get("download_size_bytes") or item.get("size_bytes") or 0
)
break
except Exception:
# Older servers lack this endpoint; byte progress is still useful.
pass
def poll(self) -> None:
if not self._configured:
self._configure()
if self._disabled:
return
try:
if self._variant or "gguf" in self._model.lower():
params = urlencode(
{
"repo_id": self._model,
"variant": self._variant,
"expected_bytes": self._expected_bytes,
}
)
url = f"{self._base}{self._progress_prefix}/gguf-download-progress?{params}"
else:
url = (
f"{self._base}{self._progress_prefix}/download-progress?"
f"{urlencode({'repo_id': self._model})}"
)
try:
reading = _http_json("GET", url, self._key, timeout = 10)
except urllib.error.HTTPError as exc:
if exc.code != 404 or self._progress_prefix == "/api/models":
raise
self._progress_prefix = "/api/models"
self.poll()
return
self._display.update(reading)
except Exception:
# Progress is best-effort; never fail the load over a polling error.
self._disabled = True
def close(self) -> None:
self._display.close()
def complete(self) -> None:
self._display.complete()
def _load_model_with_progress(
base: str, key: str, model: str, load: LoadOptions, payload: dict
) -> dict:
"""Run the blocking load request while polling its download progress."""
result: list[tuple[bool, object]] = []
done = threading.Event()
def _load() -> None:
try:
value = _http_json(
"POST",
f"{base}/api/inference/load",
key,
payload,
timeout = 3600,
error = "Model load failed",
)
result.append((True, value))
except BaseException as exc:
result.append((False, exc))
finally:
done.set()
threading.Thread(target = _load, name = "unsloth-model-load", daemon = True).start()
progress = _ModelDownloadProgress(base, key, model, load.gguf_variant)
loading_announced = False
try:
while not done.wait(_DOWNLOAD_POLL_INTERVAL_S):
if not loading_announced:
typer.echo(f"Loading model: {_display_model_spec(model, load.gguf_variant)}")
loading_announced = True
progress.poll()
ok, value = result[0]
if not ok:
assert isinstance(value, BaseException)
raise value
progress.complete()
return value if isinstance(value, dict) else {}
finally:
progress.close()
def _studio_healthy(base: str, timeout: float = 3.0) -> bool:
@ -396,6 +658,11 @@ def _log_tail(path: Path, lines: int = 20) -> str:
return "(no server log)"
def _redacted_log_tail(path: Path, lines: int = 20) -> str:
"""Tail with minted keys removed; only for tails shown on the terminal."""
return re.sub(r"sk-unsloth-\S+", "sk-unsloth-[redacted]", _log_tail(path, lines))
def _shutdown_server(server: Optional[subprocess.Popen]) -> None:
# Idempotent teardown of a server WE started, plus its own children (llama-server,
# cloudflared). A no-op once the process is already gone.
@ -438,6 +705,14 @@ def _shutdown_auto_served() -> None:
_shutdown_server(server)
def _keep_auto_served() -> bool:
"""Release ownership so a successfully started server survives this CLI."""
global _auto_served_server
server, _auto_served_server = _auto_served_server, None
atexit.unregister(_shutdown_auto_served)
return server is not None and server.poll() is None
def _start_studio_server(base: str, model: str, load: LoadOptions) -> subprocess.Popen:
"""Spawn `unsloth run` for `model`, wait until it is fully ready, and return it."""
global _auto_served_server
@ -467,9 +742,8 @@ def _start_studio_server(base: str, model: str, load: LoadOptions) -> subprocess
command += ["--tensor-parallel"]
log_path = Path(tempfile.gettempdir()) / f"unsloth-start-server-{os.getpid()}.log"
typer.echo(
f"No Unsloth server at {base}. Starting one for {model} (loading the model can take a while)…"
)
typer.echo("Starting Unsloth server")
typer.echo(f"Model: {_display_model_spec(model, load.gguf_variant)}")
typer.echo(f"Server log: {log_path}")
# 0600: the `unsloth run` banner in this log carries the minted sk-unsloth- key, and
# the tempdir is world-traversable. Unlink first so a stale looser-mode file (pid
@ -477,8 +751,17 @@ def _start_studio_server(base: str, model: str, load: LoadOptions) -> subprocess
log_path.unlink(missing_ok = True)
log = os.fdopen(os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb")
# Own session/process group so a mid-session Ctrl+C (cancel a turn) doesn't reach the
# server; we tear it down explicitly when the agent exits.
kwargs: dict = {"stdout": log, "stderr": subprocess.STDOUT, "stdin": subprocess.DEVNULL}
# server. It survives a successful agent session; torn down on startup/launch failure.
child_env = os.environ.copy()
# Pass the marker via env so an older launcher ignores it instead of treating an
# unknown CLI flag as a llama-server arg; new launchers preserve it across re-exec.
child_env[_START_API_KEY_MARKER_ENV] = "1"
kwargs: dict = {
"stdout": log,
"stderr": subprocess.STDOUT,
"stdin": subprocess.DEVNULL,
"env": child_env,
}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
@ -491,17 +774,45 @@ def _start_studio_server(base: str, model: str, load: LoadOptions) -> subprocess
atexit.register(_shutdown_auto_served)
deadline = time.monotonic() + _SERVER_START_TIMEOUT_S
while time.monotonic() < deadline:
if server.poll() is not None:
tail = _log_tail(log_path)
_shutdown_auto_served()
_fail(f"The Unsloth server stopped before it was ready. Last log lines:\n{tail}")
# `unsloth run` prints the minted key only after the server is up AND the model is
# loaded, so it is the fully-ready signal (same contract serve-unsloth-run.sh uses).
if _studio_healthy(base) and "sk-unsloth-" in _log_tail(log_path, lines = 400):
typer.echo(f"Unsloth server ready at {base}.")
return server
time.sleep(2.0)
progress: Optional[_ModelDownloadProgress] = None
early_key_seen = False
try:
while time.monotonic() < deadline:
if server.poll() is not None:
# The early key marker lands here before load finishes; redact it.
tail = _redacted_log_tail(log_path)
_shutdown_auto_served()
_fail(f"The Unsloth server stopped before it was ready. Last log lines:\n{tail}")
tail = _log_tail(log_path, lines = 400)
if progress is None:
marker = re.search(
rf"^{re.escape(_START_API_KEY_PREFIX)}(sk-unsloth-[^\s]+)$",
tail,
flags = re.MULTILINE,
)
if marker:
early_key_seen = True
progress = _ModelDownloadProgress(
base,
marker.group(1),
model,
load.gguf_variant,
)
if progress is not None:
progress.poll()
# New children emit an early key marker, so wait for the final model banner;
# older children only print the key after load, so fall back to that.
ready_signal = "Model loaded:" in tail if early_key_seen else "sk-unsloth-" in tail
if _studio_healthy(base) and ready_signal:
if progress is not None:
progress.complete()
progress.close()
progress = None
return server
time.sleep(2.0)
finally:
if progress is not None:
progress.close()
_shutdown_auto_served()
_fail(
f"The Unsloth server didn't become ready within {_SERVER_START_TIMEOUT_S}s. See {log_path}."
@ -796,6 +1107,7 @@ def _resolve_model(
load: LoadOptions = LoadOptions(),
) -> dict:
models = _loaded_models(base, key)
load_requested = False
# Only casefold-match ids against a loopback Unsloth, where _is_hub_model_id's
# local existence probe can actually reject a server-side path; see the note there.
allow_casefold = is_loopback_url(base)
@ -825,11 +1137,30 @@ def _resolve_model(
)
)
if requested and match is None:
typer.echo(
f"Loading {requested} - please wait…"
if load_has_overrides
else f"Loading {requested} on the Unsloth server (this can take a while)…"
)
load_requested = True
active = next((m for m in models if m.get("loaded") is not False), None)
active_id = active.get("id") if active else None
if active_id and not _model_id_matches(
active_id,
requested,
allow_casefold = allow_casefold,
):
typer.echo(f"Switching the Unsloth server from {active_id} to {requested}.")
typer.echo("This unloads the current model for every attached session.")
elif active_id and load.gguf_variant:
# Same repo id but an explicit quant still replaces the resident
# weights; /v1/models has no variant, so ask the status endpoint.
try:
status = _http_json("GET", f"{base}/api/inference/status", key)
except Exception:
status = {}
resident = status.get("gguf_variant") if status.get("is_gguf") else None
if resident and _normalized_variant(resident) != _normalized_variant(load.gguf_variant):
typer.echo(
f"Switching the Unsloth server from {active_id}:{resident} "
f"to {requested}:{load.gguf_variant}."
)
typer.echo("This unloads the current model for every attached session.")
# Mirror `unsloth run`'s load knobs; keep the default payload as just
# model_path so a bare `--model` load is unchanged.
payload = {"model_path": requested}
@ -841,14 +1172,9 @@ def _resolve_model(
payload["load_in_4bit"] = False
if load.tensor_parallel:
payload["tensor_parallel"] = True
loaded = _http_json(
"POST",
f"{base}/api/inference/load",
key,
payload,
timeout = 3600,
error = "Model load failed",
)
loaded = _load_model_with_progress(base, key, requested, load, payload)
if loaded.get("status") == "already_loaded":
typer.echo(f"Reusing loaded model: {_display_model_spec(requested, load.gguf_variant)}")
# Unsloth registers the model under a canonical id (resolved identifier,
# casing) that /v1/models echoes but which may differ from the path we
# passed; match on the id the load reports so we don't silently fall
@ -861,13 +1187,16 @@ def _resolve_model(
(
m
for m in models
if any(
if m.get("loaded") is not False
and any(
_model_id_matches(m.get("id"), w, allow_casefold = allow_casefold) for w in wanted
)
),
None,
)
if match is not None:
if requested and not load_requested:
typer.echo(f"Reusing loaded model: {_display_model_spec(requested, load.gguf_variant)}")
return match
if requested:
# We asked Unsloth to load it and it didn't surface in /v1/models; don't
@ -881,7 +1210,13 @@ def _resolve_model(
"No model is loaded in Unsloth. Load one from the model dropdown in "
"the UI, or pass --model <hf-id-or-path> to load it from here."
)
return models[0]
resident = next((m for m in models if m.get("loaded") is not False), None)
if resident is None:
_fail(
"No model is currently resident in Unsloth. Pass --model <hf-id-or-path> "
"to reload one, or load it from the model dropdown in the UI."
)
return resident
def _require_gguf_for_codex(base: str, key: str, model_id: str) -> None:
@ -1356,7 +1691,7 @@ def _launch(
env: dict,
install_hint: str,
unset_env: tuple = (),
) -> NoReturn:
) -> int:
# Resolve well-known install dirs (e.g. ~/.local/bin) first, so an already-installed
# agent not yet on PATH is found instead of prompting a needless reinstall.
_augment_path_with_install_dirs()
@ -1382,7 +1717,7 @@ def _launch(
finally:
signal.signal(signal.SIGINT, previous)
# Negative returncode means killed by signal N; shells expect 128+N.
raise typer.Exit(code = code if code >= 0 else 128 - code)
return code if code >= 0 else 128 - code
def _connect(
@ -1434,16 +1769,35 @@ def _run(
# --no-launch recipes stay intact.
if launch and clear_screen:
click.clear()
typer.echo(f"Unsloth {base} · model {entry['id']}")
typer.echo(f"Unsloth ready at {base} · model {entry['id']}")
if not launch:
env, wsl_env_bridge = _wsl_shim_env(command, env, unset_env)
_print_env(env, command, unset_env = unset_env, wsl_env_bridge = wsl_env_bridge)
if _keep_auto_served():
typer.echo(f"Unsloth Studio is still running at {base}.")
typer.echo("Stop it with: unsloth studio stop")
return
try:
_launch(command, env, install_hint = install_hint, unset_env = unset_env)
finally:
# Tear down a server we auto-started once the agent session ends (no-op otherwise).
code = _launch(command, env, install_hint = install_hint, unset_env = unset_env)
except BaseException:
# Startup succeeded but the agent failed to launch; tear the server down
# rather than orphan it.
_shutdown_auto_served()
raise
auto_started = _auto_served_server is not None
kept = _keep_auto_served()
if auto_started and not kept:
typer.echo(f"The auto-started Unsloth server at {base} stopped during the session.")
raise typer.Exit(code = code)
if code:
# The server status below must not read as a successful agent session.
typer.echo(f"The agent exited with code {code}.")
if is_loopback_url(base):
typer.echo(f"Unsloth Studio is still running at {base}.")
typer.echo("Stop it with: unsloth studio stop")
else:
typer.echo(f"The remote Unsloth server is still running at {base}.")
raise typer.Exit(code = code)
def _agents_config_root() -> Path:
@ -1893,7 +2247,7 @@ def codex(
launch = launch,
)
# This preflight runs after _connect may have auto-started a server but before _run
# installs its teardown finally, so tear the server down here if it rejects the model
# takes over its lifecycle, so tear the server down here if it rejects the model
# (e.g. a transformers-backend model) rather than leaving it on the atexit backstop.
try:
_require_gguf_for_codex(base, key, entry["id"])

View file

@ -106,6 +106,13 @@ API_KEY_PBKDF2_SALT_KEY = "api_key_pbkdf2_salt"
DESKTOP_SECRET_HASH_KEY = "desktop_secret_hash"
DESKTOP_SECRET_CREATED_AT_KEY = "desktop_secret_created_at"
PBKDF2_ITERATIONS = 100_000
_START_API_KEY_MARKER_ENV = "_UNSLOTH_START_API_KEY_MARKER"
def _consume_start_api_key_marker_env() -> bool:
"""Consume the one-shot readiness marker passed across a Studio re-exec."""
return os.environ.pop(_START_API_KEY_MARKER_ENV, None) == "1"
# __file__ is unsloth_cli/commands/studio.py -- two parents up is the package root
# (either site-packages or the repo root for editable installs).
@ -1760,6 +1767,12 @@ def run(
"decode speed, MoE usually don't."
),
),
start_api_key_marker: bool = typer.Option(
False,
"--start-api-key-marker",
hidden = True,
help = "Emit an early API key marker for the unsloth start parent process.",
),
password: str = typer.Option(
"",
"--password",
@ -1786,6 +1799,11 @@ def run(
unsloth studio run --model some-model --chat-template-file /path/to/tpl.jinja
unsloth studio run --model unsloth/Qwen3-27B-GGUF --gguf-variant Q8_0 --tensor-parallel
"""
# A newer outer CLI can re-exec into an older Studio venv; pass this signal via
# env so an older child ignores it instead of treating it as a llama-server arg.
inherited_start_api_key_marker = _consume_start_api_key_marker_env()
start_api_key_marker = start_api_key_marker or inherited_start_api_key_marker
# Back-compat: --not-secure is a deprecated alias for --no-secure.
secure = _resolve_secure(secure, not_secure)
extra_llama_args: List[str] = list(ctx.args) if ctx.args else []
@ -1991,15 +2009,21 @@ def run(
if extra_llama_args:
args.extend(extra_llama_args)
if sys.platform == "win32":
proc = subprocess.Popen(args)
try:
rc = proc.wait()
except KeyboardInterrupt:
rc = proc.wait()
raise typer.Exit(rc)
else:
os.execvp(str(studio_bin), args)
if start_api_key_marker:
os.environ[_START_API_KEY_MARKER_ENV] = "1"
try:
if sys.platform == "win32":
proc = subprocess.Popen(args)
try:
rc = proc.wait()
except KeyboardInterrupt:
rc = proc.wait()
raise typer.Exit(rc)
else:
os.execvp(str(studio_bin), args)
finally:
# execvp doesn't return on success; restore env after a Windows wait or a failed launch.
os.environ.pop(_START_API_KEY_MARKER_ENV, None)
# ── 2. Start server (always suppress built-in banner) ─────────────
run_mod = _load_run_module()
@ -2045,6 +2069,10 @@ def run(
# 4. Create API key in-process.
api_key = _create_api_key_inprocess(api_key_name)
if start_api_key_marker:
# `unsloth start` reads this key from a private 0600 log to authenticate
# download-progress polling; the normal `unsloth run` output is unchanged.
typer.echo(f"UNSLOTH_START_API_KEY: {api_key}")
# 5. Load model via HTTP.
if not silent:
@ -2236,7 +2264,8 @@ def stop():
# Send SIGTERM (graceful shutdown) or TerminateProcess on Windows
try:
if sys.platform == "win32":
subprocess.run(["taskkill", "/PID", str(pid), "/F"], check = True)
# /T also stops llama-server children, which otherwise keep GPU and port.
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check = True)
else:
os.kill(pid, _signal.SIGTERM)
typer.echo(f"Sent shutdown signal to Unsloth server (PID {pid}).")

View file

@ -20,6 +20,7 @@ if str(_REPO_ROOT) not in sys.path:
import pytest
import typer
from typer.testing import CliRunner
import unsloth_cli.commands.start as start
@ -639,8 +640,13 @@ def fake_studio(tmp_path, monkeypatch):
if url.endswith("/api/auth/api-keys"):
return {"key": "sk-unsloth-feedfacefeedface"}
if url.endswith("/api/inference/load"):
already_loaded = state["models"][0]["id"] == payload["model_path"]
state["models"] = [{"id": payload["model_path"], "context_length": 4096}]
return {}
return {
"status": "already_loaded" if already_loaded else "loaded",
"model": payload["model_path"],
"display_name": payload["model_path"],
}
raise AssertionError(f"unexpected request: {method} {url}")
monkeypatch.setattr(start, "find_studio_server", lambda: BASE)
@ -824,7 +830,7 @@ def test_connect_codex_matches_requested_model_case_insensitively(fake_studio, t
assert profile["model"] == MODEL["id"]
def test_resolve_model_matches_loaded_canonical_case_after_load(monkeypatch):
def test_resolve_model_matches_loaded_canonical_case_after_load(monkeypatch, capsys):
calls = []
state = {"loaded": False}
@ -862,6 +868,8 @@ def test_resolve_model_matches_loaded_canonical_case_after_load(monkeypatch):
assert entry["id"] == "unsloth/gemma-4-E2B-it-GGUF"
assert any(c[1].endswith("/api/inference/load") for c in calls)
output = capsys.readouterr().out
assert "please wait" not in output
def test_resolve_model_loads_when_catalog_hit_is_not_loaded(monkeypatch):
@ -903,6 +911,35 @@ def test_resolve_model_loads_when_catalog_hit_is_not_loaded(monkeypatch):
assert any(u.endswith("/api/inference/load") for _, u in calls)
def test_resolve_model_does_not_attach_if_catalog_stays_unloaded(monkeypatch):
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
if url.endswith("/v1/models"):
return {
"data": [
{
"id": "unsloth/Gemma-4-GGUF",
"loaded": False,
"context_length": 131072,
}
]
}
if url.endswith("/api/inference/load"):
return {"status": "loaded", "model": "unsloth/Gemma-4-GGUF"}
raise AssertionError(f"unexpected request: {method} {url}")
monkeypatch.setattr(start, "_http_json", http_json)
with pytest.raises(typer.Exit):
start._resolve_model(BASE, "sk-test", "unsloth/gemma-4-gguf")
def test_resolve_model_attaches_to_loaded_catalog_hit_without_reload(monkeypatch):
# The mirror case: a loaded entry (loaded == True) that case-matches attaches with
# no /api/inference/load call.
@ -931,6 +968,25 @@ def test_resolve_model_attaches_to_loaded_catalog_hit_without_reload(monkeypatch
assert not any(u.endswith("/api/inference/load") for _, u in calls)
def test_resolve_model_without_request_rejects_unloaded_catalog(monkeypatch):
monkeypatch.setattr(
start,
"_http_json",
lambda *a, **k: {
"data": [
{
"id": "unsloth/Gemma-4-GGUF",
"loaded": False,
"context_length": 131072,
}
]
},
)
with pytest.raises(typer.Exit):
start._resolve_model(BASE, "sk-test", None)
def test_resolve_model_remote_studio_does_not_casefold_attach(monkeypatch):
# Against a remote Unsloth the local existence probe cannot see server-side paths,
# so a case-variant loaded id must NOT attach without a load: it could be a distinct
@ -1213,6 +1269,9 @@ def test_connect_model_flag_loads_on_server(fake_studio):
assert loads == [
("POST", f"{BASE}/api/inference/load", {"model_path": "unsloth/Qwen3.5-35B-A3B"})
]
assert result.output.index(
f"Switching the Unsloth server from {MODEL['id']} to unsloth/Qwen3.5-35B-A3B.\n"
) < result.output.index("This unloads the current model for every attached session.\n")
_assert_env_set(result.output, "ANTHROPIC_MODEL", "unsloth/Qwen3.5-35B-A3B")
@ -1303,6 +1362,7 @@ def test_connect_model_bare_id_matches_loaded_without_reload(fake_studio):
assert result.exit_code == 0, result.output
loads = [c for c in fake_studio if c[1].endswith("/api/inference/load")]
assert loads == []
assert f"Reusing loaded model: {MODEL['id']}\n" in result.output
_assert_env_set(result.output, "ANTHROPIC_MODEL", MODEL["id"])
@ -1324,6 +1384,7 @@ def test_connect_model_variant_suffix_defers_to_server_dedup(fake_studio):
{"model_path": MODEL["id"], "gguf_variant": "UD-Q4_K_XL"},
)
]
assert f"Reusing loaded model: {MODEL['id']}:UD-Q4_K_XL\n" in result.output
_assert_env_set(result.output, "ANTHROPIC_MODEL", MODEL["id"])
@ -1730,8 +1791,9 @@ def _reset_auto_served():
start._auto_served_server = None
def test_start_studio_server_builds_command_and_waits(monkeypatch):
def test_start_studio_server_builds_command_and_waits(monkeypatch, capsys):
captured = {}
monkeypatch.setenv(start._START_API_KEY_MARKER_ENV, "parent")
class FakePopen:
def __init__(self, command, **kwargs):
@ -1761,13 +1823,200 @@ def test_start_studio_server_builds_command_and_waits(monkeypatch):
assert cmd[cmd.index("--gguf-variant") + 1] == "UD-Q4_K_XL"
assert cmd[cmd.index("--context-length") + 1] == "8192"
assert "--tensor-parallel" in cmd
assert "--start-api-key-marker" not in cmd
assert captured["kwargs"]["env"][start._START_API_KEY_MARKER_ENV] == "1"
assert start.os.environ[start._START_API_KEY_MARKER_ENV] == "parent"
assert cmd[cmd.index("-p") + 1] == "8888"
assert start.LoadOptions().load_in_4bit is True and "--no-load-in-4bit" not in cmd
assert captured["kwargs"].get("start_new_session") is True # own process group
assert server.pid == 4321
output = capsys.readouterr().out
assert "Starting Unsloth server\n" in output
assert "Model: unsloth/Qwen3-1.7B-GGUF:UD-Q4_K_XL\n" in output
assert "No Unsloth server at" not in output
assert "server ready" not in output
def test_auto_serves_when_no_server_then_tears_down(fake_studio, monkeypatch):
def test_start_studio_server_polls_progress_from_early_key(monkeypatch):
class FakePopen:
pid = 4321
def poll(self):
return None
tails = iter(
[
"UNSLOTH_START_API_KEY: sk-unsloth-early\nLoading model...",
"UNSLOTH_START_API_KEY: sk-unsloth-early\nModel loaded: owner/model",
]
)
created = []
class FakeProgress:
def __init__(self, base, key, model, variant):
created.append((base, key, model, variant, "created"))
def poll(self):
created.append("poll")
def close(self):
created.append("close")
def complete(self):
created.append("complete")
monkeypatch.setattr(start.subprocess, "Popen", lambda *a, **k: FakePopen())
monkeypatch.setattr(start, "_studio_healthy", lambda *a, **k: True)
monkeypatch.setattr(start, "_log_tail", lambda *a, **k: next(tails))
monkeypatch.setattr(start, "_ModelDownloadProgress", FakeProgress)
monkeypatch.setattr(start.time, "sleep", lambda _s: None)
monkeypatch.setattr(
start.typer,
"echo",
lambda message = "", **_kwargs: created.append(("echo", message)),
)
server = start._start_studio_server(
BASE,
"owner/model-GGUF",
start.LoadOptions(gguf_variant = "Q4_K_M"),
)
assert server.pid == 4321
assert (BASE, "sk-unsloth-early", "owner/model-GGUF", "Q4_K_M", "created") in created
assert created.count("poll") == 2
assert created[-2:] == ["complete", "close"]
assert not any(isinstance(event, tuple) and "server ready" in event[-1] for event in created)
def test_load_model_with_progress_uses_selected_gguf_size(monkeypatch, capsys):
release = start.threading.Event()
calls = []
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
calls.append((method, url, payload))
if url.endswith("/api/inference/load"):
assert release.wait(timeout = 2)
return {"model": "owner/model-GGUF"}
if "/api/hub/gguf-variants?" in url:
return {
"default_variant": "Q8_0",
"variants": [
{
"quant": "UD-Q4_K_XL",
"filename": "model-UD-Q4_K_XL.gguf",
"size_bytes": 4 * 1024**3,
"download_size_bytes": 4 * 1024**3,
}
],
}
if "/api/hub/gguf-download-progress?" in url:
release.set()
return {
"downloaded_bytes": 2 * 1024**3,
"expected_bytes": 4 * 1024**3,
"progress": 0.5,
}
raise AssertionError(f"unexpected request: {method} {url}")
monkeypatch.setattr(start, "_http_json", http_json)
monkeypatch.setattr(start, "_DOWNLOAD_POLL_INTERVAL_S", 0.001)
result = start._load_model_with_progress(
BASE,
"sk-test",
"owner/model-GGUF",
start.LoadOptions(gguf_variant = "UD-Q4_K_XL"),
{"model_path": "owner/model-GGUF", "gguf_variant": "UD-Q4_K_XL"},
)
assert result == {"model": "owner/model-GGUF"}
output = capsys.readouterr().out
assert "Downloading model" in output
assert "100%" in output
progress_url = next(url for method, url, _ in calls if "gguf-download-progress" in url)
assert "variant=UD-Q4_K_XL" in progress_url
assert f"expected_bytes={4 * 1024**3}" in progress_url
def test_download_progress_ignores_fully_cached_bytes(capsys):
display = start._DownloadProgressDisplay()
display.update(
{
"downloaded_bytes": 4 * 1024**3,
"completed_bytes": 4 * 1024**3,
"expected_bytes": 4 * 1024**3,
"progress": 0.99,
}
)
display.close()
assert capsys.readouterr().out == ""
def test_resolve_model_warns_on_same_repo_quant_switch(monkeypatch, capsys):
models = [{"id": "owner/model-GGUF", "loaded": True}]
def http_json(
method,
url,
key,
payload = None,
timeout = 30,
error = None,
):
assert url.endswith("/api/inference/status"), url
return {"is_gguf": True, "gguf_variant": "Q4_K_M"}
monkeypatch.setattr(start, "_loaded_models", lambda base, key: models)
monkeypatch.setattr(start, "_http_json", http_json)
monkeypatch.setattr(
start,
"_load_model_with_progress",
lambda base, key, model, load, payload: {"status": "loaded", "model": "owner/model-GGUF"},
)
start._resolve_model(BASE, "key", "owner/model-GGUF", start.LoadOptions(gguf_variant = "Q8_0"))
out = capsys.readouterr().out
assert (
"Switching the Unsloth server from owner/model-GGUF:Q4_K_M to owner/model-GGUF:Q8_0." in out
)
assert "every attached session" in out
def test_resolve_model_same_quant_prints_no_switch_warning(monkeypatch, capsys):
models = [{"id": "owner/model-GGUF", "loaded": True}]
monkeypatch.setattr(start, "_loaded_models", lambda base, key: models)
monkeypatch.setattr(
start,
"_http_json",
lambda *a, **k: {"is_gguf": True, "gguf_variant": "Q8_0"},
)
monkeypatch.setattr(
start,
"_load_model_with_progress",
lambda base, key, model, load, payload: {
"status": "already_loaded",
"model": "owner/model-GGUF",
},
)
start._resolve_model(BASE, "key", "owner/model-GGUF", start.LoadOptions(gguf_variant = "Q8_0"))
out = capsys.readouterr().out
assert "Switching" not in out
assert "Reusing loaded model: owner/model-GGUF:Q8_0" in out
def test_auto_serves_when_no_server_then_keeps_server(fake_studio, monkeypatch):
monkeypatch.setattr(start, "find_studio_server", lambda: None)
started = {}
fake = SimpleNamespace(pid = 999, poll = lambda: None)
@ -1793,8 +2042,134 @@ def test_auto_serves_when_no_server_then_tears_down(fake_studio, monkeypatch):
assert started["model"] == "unsloth/Qwen3-1.7B-GGUF"
assert started["load"].gguf_variant == "UD-Q4_K_XL"
assert started["base"] == BASE
# Torn down after the agent session ended.
assert started.get("down") is fake
# A successful agent exit releases ownership and leaves the server available
# for another terminal. Explicit startup failures still use the cleanup path.
assert "down" not in started
assert start._auto_served_server is None
assert "is still running" in result.output
assert "unsloth studio stop" in result.output
def test_auto_served_agent_launch_failure_stops_server(fake_studio, monkeypatch):
monkeypatch.setattr(start, "find_studio_server", lambda: None)
stopped = []
fake = SimpleNamespace(pid = 999, poll = lambda: None)
def fake_start(*_args):
start._auto_served_server = fake
return fake
monkeypatch.setattr(start, "_start_studio_server", fake_start)
monkeypatch.setattr(start, "_shutdown_server", stopped.append)
monkeypatch.setattr(
start,
"_launch",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("agent launch failed")),
)
result = CliRunner().invoke(
start.start_app,
["claude", "--model", "unsloth/Qwen3-1.7B-GGUF"],
)
assert result.exit_code == 1
assert stopped == [fake]
assert "is still running" not in result.output
def test_auto_served_server_exit_is_not_reported_as_running(fake_studio, monkeypatch):
monkeypatch.setattr(start, "find_studio_server", lambda: None)
fake = SimpleNamespace(pid = 999, poll = lambda: 1)
def fake_start(*_args):
start._auto_served_server = fake
return fake
monkeypatch.setattr(start, "_start_studio_server", fake_start)
monkeypatch.setattr(start, "_launch", lambda *a, **k: 0)
result = CliRunner().invoke(
start.start_app,
["claude", "--model", "unsloth/Qwen3-1.7B-GGUF"],
)
assert result.exit_code == 0, result.output
assert "stopped during the session" in result.output
assert "is still running" not in result.output
def test_attached_server_prints_stop_hint_after_agent_exits(fake_studio, monkeypatch):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(start, "_claude_flags", lambda *a, **k: [])
monkeypatch.setattr(
start.subprocess,
"run",
lambda command, env: SimpleNamespace(returncode = 0),
)
result = CliRunner().invoke(start.start_app, ["claude"])
assert result.exit_code == 0, result.output
assert f"Unsloth ready at {BASE} · model {MODEL['id']}\n" in result.output
assert f"Unsloth Studio is still running at {BASE}." in result.output
assert "Stop it with: unsloth studio stop\n" in result.output
def test_no_launch_recipe_does_not_print_stop_hint(fake_studio):
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
assert "is still running" not in result.output
def test_nonzero_agent_exit_notes_code_before_stop_hint(fake_studio, monkeypatch):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(start, "_claude_flags", lambda *a, **k: [])
monkeypatch.setattr(
start.subprocess,
"run",
lambda command, env: SimpleNamespace(returncode = 3),
)
result = CliRunner().invoke(start.start_app, ["claude"])
assert result.exit_code == 3
assert "The agent exited with code 3." in result.output
assert f"Unsloth Studio is still running at {BASE}." in result.output
def test_redacted_log_tail_strips_minted_keys(tmp_path):
log = tmp_path / "server.log"
log.write_text(
"booting\nUNSLOTH_START_API_KEY: sk-unsloth-feedfacefeedface\nerror: load failed\n",
encoding = "utf-8",
)
tail = start._redacted_log_tail(log)
assert "sk-unsloth-feedfacefeedface" not in tail
assert "sk-unsloth-[redacted]" in tail
assert "error: load failed" in tail
def test_startup_failure_output_redacts_minted_key(monkeypatch, tmp_path, capsys):
monkeypatch.setattr(start.tempfile, "gettempdir", lambda: str(tmp_path))
fake = SimpleNamespace(pid = 4242, poll = lambda: 1)
def fake_popen(command, **kwargs):
# The child prints the early key marker, then dies before it is ready.
kwargs["stdout"].write(b"UNSLOTH_START_API_KEY: sk-unsloth-secretsecret\nload failed\n")
kwargs["stdout"].flush()
return fake
monkeypatch.setattr(start.subprocess, "Popen", fake_popen)
with pytest.raises(start.typer.Exit):
start._start_studio_server(BASE, "owner/model-GGUF", start.LoadOptions())
err = capsys.readouterr().err
assert "stopped before it was ready" in err
assert "sk-unsloth-secretsecret" not in err
assert "sk-unsloth-[redacted]" in err
def test_codex_preflight_failure_tears_down_auto_served(fake_studio, monkeypatch):

View file

@ -170,13 +170,24 @@ def _install_reexec_capture(monkeypatch, *, platform):
monkeypatch.setattr(sys, "platform", platform)
def capture(kind, argv):
captured.append(
{
"kind": kind,
"argv": list(argv),
"start_api_key_marker": studio_mod.os.environ.get(
studio_mod._START_API_KEY_MARKER_ENV
),
}
)
def fake_execvp(file, argv):
captured.append({"kind": "execvp", "argv": list(argv)})
capture("execvp", argv)
raise _ExecCaptured(argv)
class _FakePopen:
def __init__(self, argv, *a, **kw):
captured.append({"kind": "popen", "argv": list(argv)})
capture("popen", argv)
self._argv = argv
def wait(self):
@ -235,6 +246,30 @@ def test_reexec_forwards_parallel_all_aliases(monkeypatch, flag, value):
), f"{flag} {value} was dropped on re-exec; argv = {argv}"
@pytest.mark.parametrize("platform", ["linux", "darwin", "win32"])
def test_reexec_hands_off_start_api_key_marker_out_of_band(monkeypatch, platform):
"""A new child receives the marker while an old child sees no unknown flag."""
result, captured = _invoke_run(
monkeypatch,
_BASE + ["--start-api-key-marker"],
platform = platform,
)
assert len(captured) == 1, result.output
assert "--start-api-key-marker" not in captured[0]["argv"]
assert captured[0]["start_api_key_marker"] == "1"
def test_reexeced_child_consumes_start_api_key_marker_env(monkeypatch):
"""A supported child consumes the handoff before starting descendants."""
studio_mod = _load_run_command()
monkeypatch.setenv(studio_mod._START_API_KEY_MARKER_ENV, "1")
inherited = studio_mod._consume_start_api_key_marker_env()
assert inherited is True
assert studio_mod._START_API_KEY_MARKER_ENV not in studio_mod.os.environ
@pytest.mark.parametrize("platform", ["linux", "darwin", "win32"])
def test_reexec_argv_is_consistent_across_platforms(monkeypatch, platform):
"""Linux/Darwin (execvp) and Windows (Popen) must build the same argv."""