From 848ede3d57167ae944ff4616eb0d56f174be87de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=B9=E0=A4=BF=E0=A4=AE=E0=A4=BE=E0=A4=82=E0=A4=B6?= =?UTF-8?q?=E0=A5=81?= Date: Wed, 6 May 2026 22:16:20 +0530 Subject: [PATCH 1/4] [studio]: Fix tool reasoning trace in UI (#5314) * fix thought for 1 second issue * gemini suggesion --- .../src/components/assistant-ui/reasoning.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index 387f8cd458..fe913baf2a 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -316,15 +316,28 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ if (message.status?.type !== "running") { return false; } - const lastIndex = message.parts.length - 1; - if (lastIndex < 0) { + const parts = message.parts; + const len = parts.length; + if (len === 0) { return false; } - const lastType = message.parts[lastIndex]?.type; - if (lastType !== "reasoning") { + + let groupHasReasoning = false; + for (let i = startIndex; i <= endIndex && i < len; i += 1) { + if (parts[i]?.type === "reasoning") { + groupHasReasoning = true; + break; + } + } + if (!groupHasReasoning) { return false; } - return lastIndex >= startIndex && lastIndex <= endIndex; + for (let i = endIndex + 1; i < len; i += 1) { + if (parts[i]?.type !== "tool-call") { + return false; + } + } + return true; }); const persistedDuration = useAuiState(({ message }) => { From 948ce43584c272be016f6c7210d99a6a0bddc26a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 7 May 2026 00:12:09 -0700 Subject: [PATCH 2/4] =?UTF-8?q?fix:=203=20patch=5F*=20helpers=20=E2=80=94?= =?UTF-8?q?=20fast=5Flora=20import,=20sft=5Ftrainer=20Union,=20openenv=20O?= =?UTF-8?q?SError=20(#5319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: import fast_lora_forward inside patch_fast_lora patch_fast_lora has referenced an unbound `fast_lora_forward` since ddf118a8f (2024-11-21). The function is defined at unsloth/kernels/fast_lora.py:652 and re-exported through unsloth/kernels/__init__.py:45, but it was never imported into unsloth/models/_utils.py, so calling patch_fast_lora() raises NameError: name 'fast_lora_forward' is not defined. The bug went unnoticed because no production code path calls patch_fast_lora() unconditionally. Surfaced by a new CPU-CI check that invokes every zero-arg patch_* helper across unsloth + unsloth_zoo (consolidated-tests-ci.yml on PR #5312). Importing inside the function (rather than at module top) keeps the import surface narrow and avoids a circular-import risk if unsloth.kernels.fast_lora ever needs to import from unsloth.models._utils. * fix: inject typing imports into patch_sft_trainer_tokenizer's exec namespace patch_sft_trainer_tokenizer rewrites the source of TRL's SFTTrainer methods (_prepare_non_packed_dataloader, _prepare_dataset) and re-execs them. With TRL 1.x, those methods carry `Union[...]` type hints in their signatures. The current rewrite only injects identifiers found by `dir(trl.trainer.sft_trainer)` into the exec namespace, which does not include `Union`, so exec(function, ...) raises NameError: name 'Union' is not defined. Fix: import Union, Optional, List, Any, Callable, Tuple, Dict, Iterator inside the function. exec receives `locals()` as its globals dict, so those names are visible to the executed source body. Same pattern as unsloth/models/_utils.py:patch_linear_scaling, which already injects `from typing import Union, Optional, List, Any, Callable, Tuple` into its own exec_code. Surfaced by the consolidated CPU-CI runtime patch_* check on PR #5312 in the matrix cell `transformers>=5,<6 + trl>=1,<2`. * fix: guard openenv_vllm_reload_weights against OSError from inspect.getsource TRL 0.29.1 and the 1.x line ship some openenv helpers as compiled bytecode without accessible source on disk. inspect.getsource(patch_target) raises OSError("could not get source code") in that case, which surfaces as a hard failure in patch_trl_openenv() and aborts the rest of the RL_ADDITIONAL_FUNCTIONS["openenv"] iteration. Wrap the getsource call in a try/except OSError and log a warning instead. The wake_up(tags=...) rewrite is the only thing skipped; the core weight-reload patch path stays functional. Surfaced by the consolidated CPU-CI runtime patch_* check on PR #5312 in matrix cells running TRL 0.29.1 (latest <1.0.0) and TRL 1.3.0 (latest 1.x). The pyproject pin (TRL 0.18.2-0.24.0) still gets source for this function so the original code path runs unchanged there. --- unsloth/models/_utils.py | 1 + unsloth/models/rl_replacements.py | 15 ++++++++++++++- unsloth/tokenizer_utils.py | 6 ++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 34fec53504..d3eee03325 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -2483,6 +2483,7 @@ def patch_tokenizer(model, tokenizer): def patch_fast_lora(): import peft.tuners.lora.bnb + from ..kernels.fast_lora import fast_lora_forward peft.tuners.lora.bnb.Linear4bit.forward = fast_lora_forward diff --git a/unsloth/models/rl_replacements.py b/unsloth/models/rl_replacements.py index 0f10847282..c2be1bf74a 100755 --- a/unsloth/models/rl_replacements.py +++ b/unsloth/models/rl_replacements.py @@ -1780,7 +1780,20 @@ def openenv_vllm_reload_weights(): patch_target_name = "generate_rollout_completions" patch_target = getattr(openenv_utils, patch_target_name) - src = inspect.getsource(patch_target) + # TRL 0.29.1+ ships some openenv helpers as compiled bytecode without + # accessible source on disk; inspect.getsource raises OSError("could + # not get source code") in that case. Skip the source-rewrite patch + # rather than crashing -- the core unsloth weight-reload path stays + # functional, only the wake_up tag rewrite is skipped. + try: + src = inspect.getsource(patch_target) + except OSError as e: + logger.warning( + f"Unsloth: Could not retrieve source for trl openenv " + f"{patch_target_name} ({e}); skipping rewrite. " + f"Weight reload still functional." + ) + return src = textwrap.dedent(src) original_src = src diff --git a/unsloth/tokenizer_utils.py b/unsloth/tokenizer_utils.py index 130894e385..67edc41d52 100644 --- a/unsloth/tokenizer_utils.py +++ b/unsloth/tokenizer_utils.py @@ -1580,6 +1580,12 @@ def patch_sft_trainer_tokenizer(): except: return all_imports = dir(trl.trainer.sft_trainer) + # Make typing names available to the exec'd source bodies. TRL >= 1.x + # type-hints _prepare_dataset / _prepare_non_packed_dataloader with + # `Union[...]` and friends; without these imports in the exec namespace + # those become NameErrors at exec time. Mirrors the pattern used in + # unsloth/models/_utils.py:patch_linear_scaling. + from typing import Union, Optional, List, Any, Callable, Tuple, Dict, Iterator # noqa: F401 for ( function_name, From 4ab096970d81520d42a5452017fec77e1a8154f8 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Thu, 7 May 2026 10:17:23 +0100 Subject: [PATCH 3/4] Studio: API settings overflow with long Colab URLs (#5286) * fix: API settings overflow with long Colab URLs * fix: gentle wrapping for API usage snippets --------- Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> --- .../features/settings/components/usage-examples.tsx | 10 +++++----- .../frontend/src/features/settings/settings-dialog.tsx | 2 +- .../src/features/settings/tabs/api-keys-tab.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/studio/frontend/src/features/settings/components/usage-examples.tsx b/studio/frontend/src/features/settings/components/usage-examples.tsx index a8f8ce31a6..0e68237857 100644 --- a/studio/frontend/src/features/settings/components/usage-examples.tsx +++ b/studio/frontend/src/features/settings/components/usage-examples.tsx @@ -96,11 +96,11 @@ export function UsageExamples() { }; return ( -
+

Usage examples

-
-
-
+
+
+
{TABS.map((t) => { const active = lang === t.id; return ( @@ -134,7 +134,7 @@ export function UsageExamples() { {copied ? "Copied" : "Copy"}
-
+        
           {snippets[lang]}
         
diff --git a/studio/frontend/src/features/settings/settings-dialog.tsx b/studio/frontend/src/features/settings/settings-dialog.tsx index 63c0a9d388..38002e2f14 100644 --- a/studio/frontend/src/features/settings/settings-dialog.tsx +++ b/studio/frontend/src/features/settings/settings-dialog.tsx @@ -163,7 +163,7 @@ export function SettingsDialog() { > -
+
{renderTab(activeTab)}
diff --git a/studio/frontend/src/features/settings/tabs/api-keys-tab.tsx b/studio/frontend/src/features/settings/tabs/api-keys-tab.tsx index ac9ec40543..6cbd28f14d 100644 --- a/studio/frontend/src/features/settings/tabs/api-keys-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/api-keys-tab.tsx @@ -61,8 +61,8 @@ export function ApiKeysTab() { }; return ( -
-
+
+

API

Access Unsloth programmatically via the OpenAI-compatible API.{" "} @@ -111,7 +111,7 @@ export function ApiKeysTab() { )} -

+

Access tokens

{error ? (
@@ -131,7 +131,7 @@ export function ApiKeysTab() { No API access yet.

) : ( -
+
{keys.map((k) => ( ))} From 7af8cac0148d209a0ca7b607756d6c1eea917af7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 7 May 2026 02:26:58 -0700 Subject: [PATCH 4/4] tests/studio/install: parallel UNSLOTH_STUDIO_HOME smoke test (#5306) * tests/studio/install: parallel UNSLOTH_STUDIO_HOME smoke test Adds tests/studio/install/smoke_test_parallel_studio_home.py to lock in the install-time and runtime isolation guarantees added by #5190. The runner spawns N concurrent install.sh --local --no-torch jobs, each with its own UNSLOTH_STUDIO_HOME and a redirected HOME, then launches N backends on dynamically allocated ports and cross-checks every install against its running process. Asserts: install-time - all N installs exit 0 - per-install bin / share / llama.cpp / unsloth_studio venv tree - shim symlink resolves into its own venv, no cross-resolution - share/studio_install_id is unique across the N installs - share/studio.conf exports UNSLOTH_EXE / UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH all pointing inside the install - share/launch-studio.sh has @@DATA_DIR@@ substituted to its own share/ at install time - the redirected HOME stays clean: no rc-file append, no .desktop file, no Studio.app stub, no shared marker runtime - /api/health returns 200 with status healthy and chat_only true - /api/health.studio_root_id matches share/studio_install_id (runtime resolver agrees with install-time write) - studio_root_id values are pairwise distinct - GET / and GET /api/chat return 200 on each backend - /proc/PID/exe is the install's own venv python Standalone smoke runner, not pytest collected. Default --n 4 finishes in about 60 seconds on a warm uv cache; artifacts are removed on PASS unless --keep is passed and kept on FAIL or ERROR for inspection. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests/studio/install: portability + log fd cleanup in parallel smoke Two cleanups on the parallel UNSLOTH_STUDIO_HOME smoke runner: - Skip the /proc/PID/exe runtime cross-resolution check on platforms without /proc (macOS, BSD, Windows). install.sh supports macOS, so the smoke should not hard-error there. The install-time symlink, studio.conf and launch-studio.sh assertions already pin the venv python target statically; the proc check stays as a Linux-only redundant cross-resolution catch and now returns None cleanly on other platforms instead of raising. - Wrap the per-backend log file in a with-statement so its parent fd is released deterministically at function return. The child still holds its own dup'd fd via Popen, so logging continues unchanged. The prior code relied on local-scope GC and was fine in CPython, but the with form makes the intent explicit. Smoke still passes locally: 4 parallel installs in 42s, 4 backends healthy in 5s, all install + runtime invariants hold. * tests/studio/install: pin UNSLOTH_STUDIO_HOME on backend launch The launch step copied os.environ unchanged except for HOME. If the parent shell already exports UNSLOTH_STUDIO_HOME or STUDIO_HOME (for example, when the developer is sourcing studio.conf from an existing install), every backend inherits it and the Studio resolver prioritises those env vars over the per-label sys.prefix inference. The runtime invariant block then reports the caller's install_id on every port instead of the per-label one, and the test fails spuriously rather than testing the right roots. Pin UNSLOTH_STUDIO_HOME to the per-label studio_home and pop the STUDIO_HOME alias for each launch, mirroring what _run_one_install already does for the install step. Verified by running the smoke with UNSLOTH_STUDIO_HOME=/nonexistent and STUDIO_HOME=/also-bogus exported in the parent env: PASS, all four backends report their own install_id rather than the parent value. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../smoke_test_parallel_studio_home.py | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 tests/studio/install/smoke_test_parallel_studio_home.py diff --git a/tests/studio/install/smoke_test_parallel_studio_home.py b/tests/studio/install/smoke_test_parallel_studio_home.py new file mode 100644 index 0000000000..133591fb33 --- /dev/null +++ b/tests/studio/install/smoke_test_parallel_studio_home.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""Smoke test: N parallel install.sh runs with distinct UNSLOTH_STUDIO_HOME +values must produce N fully isolated installs whose backends can run +side by side without clashing. + +Covers the env-override path added in #5190: + + install-time + * N concurrent ``install.sh --local --no-torch`` runs against + this checkout, each pinned to its own UNSLOTH_STUDIO_HOME and + a redirected HOME, all exit 0. + * Each STUDIO_HOME contains its own bin/, share/, llama.cpp/ + and unsloth_studio/ venv, with no cross-install absolute + paths. + * share/studio_install_id is unique across the N installs. + * share/studio.conf exports UNSLOTH_EXE, UNSLOTH_STUDIO_HOME + and UNSLOTH_LLAMA_CPP_PATH, all pointing inside this install. + * share/launch-studio.sh has @@DATA_DIR@@ substituted to its + own share/ at install time. + * bin/unsloth is a symlink that resolves into its own venv. + * The redirected HOME is left clean: no shell-rc append, no + .desktop file, no Studio.app stub, no shared marker. + + runtime + * N concurrent ``bin/unsloth studio`` launches each bind their + own dynamically allocated free port and stay healthy. + * /api/health is 200, status is healthy, chat_only is true + under --no-torch. + * The studio_root_id reported by /api/health on each backend + equals that install's share/studio_install_id, so the + runtime resolver agrees with the install-time write. + * studio_root_id values are pairwise distinct. + * GET / and GET /api/chat are 200 on every backend. + * The Python interpreter behind each PID is the install's own + venv python (the bin/unsloth shim does not cross-resolve). + +This is an integration smoke runner, not a pytest unit test. It does +real installs (~1 minute end to end on a warm uv cache) and is meant +to be invoked explicitly: + + python tests/studio/install/smoke_test_parallel_studio_home.py + python tests/studio/install/smoke_test_parallel_studio_home.py --n 6 --keep + +Exits 0 on PASS, 1 on FAIL, 2 on infrastructure error. Artifacts land +under a temporary directory and are removed on PASS unless --keep is +set; on FAIL or ERROR they are kept regardless so logs can be +inspected. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +INSTALL_TIMEOUT_S = 600 +HEALTH_TIMEOUT_S = 120 +HEALTH_POLL_INTERVAL_S = 1.0 + + +class TestFailure(AssertionError): + pass + + +def _log(msg: str) -> None: + ts = datetime.now().strftime("%H:%M:%S") + print(f"[smoke {ts}] {msg}", flush = True) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _run_one_install( + label: str, + repo: Path, + studio_home: Path, + fake_home: Path, + uv_cache: Path, + log_path: Path, +) -> tuple[str, int]: + studio_home.mkdir(parents = True, exist_ok = True) + fake_home.mkdir(parents = True, exist_ok = True) + uv_cache.mkdir(parents = True, exist_ok = True) + log_path.parent.mkdir(parents = True, exist_ok = True) + env = os.environ.copy() + env["HOME"] = str(fake_home) + env["UNSLOTH_STUDIO_HOME"] = str(studio_home) + env["UV_CACHE_DIR"] = str(uv_cache) + env["NO_COLOR"] = "1" + with log_path.open("w") as fh: + proc = subprocess.run( + ["bash", "install.sh", "--local", "--no-torch"], + cwd = str(repo), + env = env, + stdout = fh, + stderr = subprocess.STDOUT, + timeout = INSTALL_TIMEOUT_S, + ) + return label, proc.returncode + + +def _launch_backend( + studio_home: Path, fake_home: Path, port: int, log_path: Path +) -> subprocess.Popen: + log_path.parent.mkdir(parents = True, exist_ok = True) + env = os.environ.copy() + env["HOME"] = str(fake_home) + # Pin UNSLOTH_STUDIO_HOME (and clear the alias) so the child cannot + # inherit a Studio root from the caller's shell. Without this, a shell + # that already exports either var would override the per-label sys.prefix + # inference and every backend would resolve to the caller's install. + env["UNSLOTH_STUDIO_HOME"] = str(studio_home) + env.pop("STUDIO_HOME", None) + # The child process inherits a dup of stdout via Popen, so closing the + # parent's handle when this function returns is safe and avoids relying + # on GC timing to release the fd. + with log_path.open("w") as fh: + return subprocess.Popen( + [ + str(studio_home / "bin" / "unsloth"), + "studio", + "-H", + "127.0.0.1", + "-p", + str(port), + "--silent", + ], + env = env, + stdout = fh, + stderr = subprocess.STDOUT, + start_new_session = True, + ) + + +def _wait_for_health(port: int, timeout: float) -> dict: + deadline = time.time() + timeout + last_err: Exception | None = None + url = f"http://127.0.0.1:{port}/api/health" + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout = 2) as r: + if r.status == 200: + return json.loads(r.read().decode()) + except (urllib.error.URLError, ConnectionError, OSError) as e: + last_err = e + time.sleep(HEALTH_POLL_INTERVAL_S) + raise TestFailure( + f"port {port}: /api/health never returned 200 (last_err={last_err})" + ) + + +def _http_status(port: int, path: str, timeout: float = 5.0) -> int: + url = f"http://127.0.0.1:{port}{path}" + try: + with urllib.request.urlopen(url, timeout = timeout) as r: + return r.status + except urllib.error.HTTPError as e: + return e.code + + +def _check_install_layout(label: str, studio_home: Path) -> dict: + for d in ("bin", "share", "llama.cpp", "unsloth_studio"): + if not (studio_home / d).is_dir(): + raise TestFailure(f"[{label}] missing {studio_home / d}") + + shim = studio_home / "bin" / "unsloth" + if not shim.is_symlink(): + raise TestFailure(f"[{label}] {shim} is not a symlink") + expected_target = (studio_home / "unsloth_studio" / "bin" / "unsloth").resolve() + if shim.resolve() != expected_target: + raise TestFailure( + f"[{label}] shim resolves to {shim.resolve()}, expected {expected_target}" + ) + + install_id_path = studio_home / "share" / "studio_install_id" + if not install_id_path.is_file(): + raise TestFailure(f"[{label}] missing {install_id_path}") + install_id = install_id_path.read_text().strip() + if len(install_id) < 32: + raise TestFailure(f"[{label}] studio_install_id too short: {install_id!r}") + + conf = (studio_home / "share" / "studio.conf").read_text() + must_contain = [ + f"UNSLOTH_EXE='{studio_home}/unsloth_studio/bin/unsloth'", + f"export UNSLOTH_STUDIO_HOME='{studio_home}'", + f"export UNSLOTH_LLAMA_CPP_PATH='{studio_home}/llama.cpp'", + ] + for needle in must_contain: + if needle not in conf: + raise TestFailure( + f"[{label}] studio.conf missing line:\n {needle}\n" f"actual:\n{conf}" + ) + + launcher = (studio_home / "share" / "launch-studio.sh").read_text() + if "@@DATA_DIR@@" in launcher: + raise TestFailure(f"[{label}] launch-studio.sh kept @@DATA_DIR@@ placeholder") + expected_data_dir_line = f"DATA_DIR='{studio_home}/share'" + if expected_data_dir_line not in launcher: + raise TestFailure( + f"[{label}] launch-studio.sh missing {expected_data_dir_line!r}" + ) + + return {"label": label, "studio_home": str(studio_home), "install_id": install_id} + + +def _check_fake_home_clean(fake_home: Path) -> None: + forbidden = [ + ".bashrc", + ".zshrc", + ".profile", + ".unsloth", + Path(".local") / "share" / "applications" / "unsloth-studio.desktop", + Path("Desktop") / "unsloth-studio.desktop", + Path("Applications") / "Unsloth Studio.app", + ] + leaked = [str(p) for p in forbidden if (fake_home / p).exists()] + if leaked: + raise TestFailure( + f"redirected HOME picked up persistent install pollution: {leaked}" + ) + + +def _backend_pid_python(pid: int) -> Path | None: + """Resolve the binary backing a running PID. Linux exposes this at + /proc/PID/exe; on platforms without /proc (macOS, BSD, Windows) we + skip this check and rely on the install-time symlink + studio.conf + invariants to catch cross-resolution. Returns None when /proc is + unavailable so the caller can skip cleanly.""" + if sys.platform != "linux": + return None + proc_exe = Path(f"/proc/{pid}/exe") + if not proc_exe.exists(): + return None + return proc_exe.resolve() + + +def run(n_installs: int, keep: bool) -> int: + if n_installs < 2: + raise TestFailure("--n must be >= 2 to test for clashes") + labels = [chr(ord("a") + i) for i in range(n_installs)] + + repo = PACKAGE_ROOT + if not (repo / "install.sh").is_file(): + raise TestFailure( + f"install.sh not found at {repo}; " "run from a clone of unslothai/unsloth" + ) + + test_root = Path(tempfile.mkdtemp(prefix = "unsloth_studio_clash_")) + _log(f"test root: {test_root}") + _log(f"repo: {repo}") + + backends: list[tuple[str, Path, Path, int, subprocess.Popen]] = [] + failed = False + try: + # ---- parallel installs -------------------------------------------- + _log(f"launching {n_installs} parallel installs (--local --no-torch)") + with ThreadPoolExecutor(max_workers = n_installs) as pool: + futures = [] + for label in labels: + futures.append( + pool.submit( + _run_one_install, + label, + repo, + test_root / "installs" / label, + test_root / "fake_homes" / label, + test_root / "uv_caches" / label, + test_root / "logs" / f"install_{label}.log", + ) + ) + for fut in as_completed(futures): + label, rc = fut.result() + _log(f" install {label}: exit {rc}") + if rc != 0: + raise TestFailure( + f"install {label} failed (rc={rc}); see " + f"{test_root / 'logs' / f'install_{label}.log'}" + ) + + # ---- install-layout invariants ------------------------------------ + _log("verifying install-time invariants") + observed = [] + for label in labels: + studio_home = test_root / "installs" / label + obs = _check_install_layout(label, studio_home) + observed.append(obs) + _check_fake_home_clean(test_root / "fake_homes" / label) + ids = [o["install_id"] for o in observed] + if len(set(ids)) != len(ids): + raise TestFailure(f"studio_install_id collision: {ids}") + _log(f" {len(ids)} unique studio_install_ids, all redirected HOMEs clean") + + # ---- parallel backend launches ------------------------------------ + _log(f"launching {n_installs} backends in parallel") + for label in labels: + port = _free_port() + studio_home = test_root / "installs" / label + fake_home = test_root / "fake_homes" / label + log_path = test_root / "logs" / f"run_{label}.log" + proc = _launch_backend(studio_home, fake_home, port, log_path) + backends.append((label, studio_home, fake_home, port, proc)) + _log(f" {label} -> port {port} (pid {proc.pid})") + + # ---- wait for health ---------------------------------------------- + _log("waiting for /api/health on each backend") + health_payloads: dict[str, dict] = {} + with ThreadPoolExecutor(max_workers = n_installs) as pool: + fut_to_label = { + pool.submit(_wait_for_health, port, HEALTH_TIMEOUT_S): label + for (label, _sh, _fh, port, _p) in backends + } + for fut in as_completed(fut_to_label): + label = fut_to_label[fut] + health_payloads[label] = fut.result() + _log(f" {label}: healthy") + + # ---- runtime invariants ------------------------------------------- + _log("checking runtime invariants") + seen_root_ids: set[str] = set() + for (label, studio_home, _fh, port, proc), obs in zip(backends, observed): + health = health_payloads[label] + if health.get("status") != "healthy": + raise TestFailure(f"[{label}] health status != healthy: {health}") + if health.get("studio_root_id") != obs["install_id"]: + raise TestFailure( + f"[{label}] runtime studio_root_id " + f"{health.get('studio_root_id')!r} != install_id " + f"{obs['install_id']!r}" + ) + if not health.get("chat_only"): + raise TestFailure(f"[{label}] chat_only is not true under --no-torch") + if health["studio_root_id"] in seen_root_ids: + raise TestFailure( + f"[{label}] studio_root_id collision at runtime: " + f"{health['studio_root_id']}" + ) + seen_root_ids.add(health["studio_root_id"]) + + for path in ("/", "/api/chat"): + code = _http_status(port, path) + if code != 200: + raise TestFailure(f"[{label}] GET {path} -> {code}") + + exe = _backend_pid_python(proc.pid) + if exe is not None: + expected_python = ( + studio_home / "unsloth_studio" / "bin" / "python" + ).resolve() + if exe != expected_python: + raise TestFailure( + f"[{label}] PID {proc.pid} exe={exe}, expected {expected_python}" + ) + + versions = {h.get("version") for h in health_payloads.values()} + if len(versions) != 1: + raise TestFailure(f"version mismatch across installs: {versions}") + + _log( + f"PASS: all install + runtime invariants hold " + f"(version={next(iter(versions))})" + ) + return 0 + + except TestFailure as e: + _log(f"FAIL: {e}") + failed = True + return 1 + except Exception as e: + _log(f"ERROR: {type(e).__name__}: {e}") + failed = True + return 2 + finally: + for _lbl, _sh, _fh, _port, proc in backends: + if proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout = 10) + except Exception: + proc.kill() + + if keep or failed: + _log(f"artifacts kept at {test_root}") + else: + shutil.rmtree(test_root, ignore_errors = True) + _log(f"cleaned up {test_root}") + + +def main() -> int: + ap = argparse.ArgumentParser(description = __doc__) + ap.add_argument( + "--n", + type = int, + default = 4, + help = "number of parallel installs (default 4, must be >= 2)", + ) + ap.add_argument( + "--keep", + action = "store_true", + help = "leave the temp test root on disk even on PASS", + ) + args = ap.parse_args() + return run(args.n, args.keep) + + +if __name__ == "__main__": + raise SystemExit(main())