Studio: detect an interrupted dependency install instead of launching a backend that cannot import (#7492)

* Studio: detect an interrupted dependency install instead of launching a backend that cannot import

An installer killed part-way leaves a venv with a working CLI but without
studio.txt's dependencies. Nothing recorded that, so three separate places all
reported it healthy:

- the desktop preflight probed only `unsloth -h` (typer + rich) and a hardcoded
  desktop-capabilities dict, neither of which touches studio.backend, so it
  returned ManagedReady and spawned a backend that died on `import structlog`;
- setup.sh's fast path compared the installed unsloth version against PyPI,
  which matches on a half-built venv because unsloth is installed early, so
  `unsloth studio update` printed "up to date" and repaired nothing;
- start_managed_repair calls that update and then re-checks with the same blind
  probes, so Repair reported success without fixing anything.

install_python_stack.py now clears a completion manifest before the dependency
pass and writes it only after the final step. `unsloth studio verify-install`
and desktop-capabilities' new studio_install_ok field read it, the preflight
turns a false answer into ManagedStale so auto-repair runs, and setup.sh /
setup.ps1 gain an escape hatch next to the existing anyio one.

Separately, the wheel ships studio/ and studio.backend* but declared none of
their dependencies, so `unsloth train`, `export`, `chat`, `inference` and
`studio` all ended in a rich traceback after a plain pip install. structlog is
the only hard module-level import that chain reaches once starlette's
annotation-only import moves under TYPE_CHECKING, so it becomes a core
dependency and the rest of the server stack becomes a [studio] extra mirroring
studio.txt. The CLI import sites now report missing dependencies as a sentence
with two remedies.

Fixes #4701, #5260, #7147

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

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

* Match the trimmed comments merged on the pip branch

* Put the install manifest in the preflight fingerprint for PR #7492

The capability cache keyed the venv on pyvenv.cfg, uv.lock, requirements.txt,
the interpreter and site-packages/unsloth_cli/commands/studio.py, none of which
a repair touches when it only reinstalls studio.txt. So an entry cached while
the install was healthy stayed valid after the manifest was dropped, and the
probe returned Ready on exactly the half-built venv this is meant to catch.

* Address the review findings on PR #7492

Fail the install when the completion manifest cannot be written, instead of
exiting 0 without the record every later check requires, which is a repair
loop by construction.

Compare the version of the package the manifest names, so `studio update
--package X` does not read as a permanent version change.

Read the manifest from the venv that owns it when the CLI runs outside the
managed venv, and drop the dependency verdict in that case: the walk ran
against the wrong interpreter and says nothing about that venv.

Name the import that actually failed. `unsloth train` reaches torch through
the same guard, and the studio extra does not carry it, so recommending that
extra alone left the command failing in the same place.

* Declare click, which typer stopped providing, for PR #7492

unsloth_cli/commands/start.py imports click at module scope and
unsloth_cli/__init__.py imports that module, so every unsloth command needs
it. typer carried click through 0.19 and dropped it in 0.27, and the declared
floor is typer>=0.12.0, so a fresh resolve gets no click. On the published
wheel it still arrives because huggingface_hub requires click<9,>=8.4.2, which
is luck rather than a declaration. A wheel built from this branch's
dependency list has neither, and every command dies at import.

Verified: before, `unsloth --help` on a fresh venv raised ModuleNotFoundError
for click; after, it exits 0. The drift test now covers it.

* Keep a running backend from the previous app version manageable

The manageability bump gated two unrelated things through one constant. For
the managed CLI probe 2 is right: a CLI reporting 1 cannot answer
studio_install_ok. For a RUNNING backend it is wrong, because a process
already started cannot change what it reports, so bumping studio/backend/main.py
in lockstep does not help one the previous app version spawned.

That backend is proven ours by root id and ownership token, but
lifecycle_control_block_reason returned Unmanageable, and that branch never
calls adopt_verified_backend. has_owned_backend() stays false, so Repair falls
into block_external_conflict, which finds the same process and refuses: the app
could no longer stop a backend it owns the token for. The same regression in
backend.rs turned a terminal-launched same-root server from AttachedReady into
ExternalConflict.

Split the constant: DESKTOP_BACKEND_MANAGEABILITY_VERSION = 1 for the two
live-backend probes, DESKTOP_MANAGEABILITY_VERSION = 2 for the CLI probe. Every
real gate (protocol, auth, ownership, desktop-login, MIN_DESKTOP_BACKEND_VERSION)
is untouched, so an old backend still reaches OwnedStale, adopt, stop, repair.

Also stop the installer when the stale manifest cannot be removed. Windows
raises on a read-only or locked file, and the pass would then run behind a
marker that still names this version and these digests, so a run killed
part-way would verify as complete.

* Answer for the managed venv, not the one the CLI happens to run in

The guard matched ModuleNotFoundError.name, an import name, against
missing_requirements(), which returns distribution names. So a missing PyJWT
printed 'pip install jwt', and jwt, docx and fitz are each a real but unrelated
PyPI project (fitz is a neuroimaging workflow tool), so following the advice
installed the wrong package and left the backend just as broken. Map the import
to its distribution before deciding, and never offer the import itself.

install_state() verified the caller's own prefix. The wheel ships studio/, so a
CLI installed outside the managed venv always finds its own copy of the helper
first, and a healthy managed install reported studio_install_incomplete with a
missing list copied from the wrong venv. Selecting the root is not enough:
_installed_version() reads the running interpreter and req_root defaults to the
caller's studio.txt, so both checks still answered for the wrong venv. Hand
verify_install() that venv's own metadata, enumerated through
Distribution.discover(context = ...path), which does not fall back to sys.path.
The candidate order is untouched, so shadowed-tree detection is unchanged.

setup.ps1 replaces pip, torch and triton before install_python_stack.py runs,
so the manifest it drops is not dropped before the first mutation. A run killed
in between kept a marker that still verifies while torch was half-replaced;
drop it at the top of the dependency pass instead. setup.sh is unaffected, the
stack is the first thing its pass runs, and a test now pins both.

pip uninstall rewrites nothing that was fingerprinted, and cache_matches
re-reads the cached studio_install_ok rather than re-checking, so a venv that
lost a studio.txt package kept being served the healthy verdict. Fold a sorted
hash of the installed dist-info names into the marker hash.

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

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

* A missing manifest helper is a torn install, not an old one

studio/install_manifest.py ships in the same wheel as _studio_deps.py, so
nothing legitimately has one without the other: a CLI predating both never
reaches this code, and the desktop already calls such a CLI stale on
desktop_manageability_version.

Returning ok=true there reported a healthy install for a tree the package
update had half replaced, and the preflight then launched a backend whose
own run.py could be just as absent. Report it incomplete so repair runs.

* Tighten comments across the install-detection changes

* Validate Studio dependency readiness

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
This commit is contained in:
Daniel Han 2026-07-28 01:57:20 -07:00 committed by GitHub
commit 1781770bee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1877 additions and 61 deletions

View file

@ -8,12 +8,19 @@ filter_sensitive_data (structlog processor for sanitization), and
get_logger (factory for structured loggers).
"""
from __future__ import annotations
import os
import re
import time
from typing import TYPE_CHECKING
import structlog
from starlette.types import ASGIApp, Message, Receive, Scope, Send
# Annotations only: a runtime import makes the ASGI stack a hard dependency of
# every CLI command.
if TYPE_CHECKING:
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from utils.native_path_leases import redact_native_paths

View file

@ -1075,7 +1075,9 @@ async def liveness_check():
"status": "alive",
"service": "Unsloth UI Backend",
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
# Lockstep with DESKTOP_MANAGEABILITY_VERSION in
# studio/src-tauri/src/preflight/version.rs and `desktop-capabilities`.
"desktop_manageability_version": 2,
"supports_desktop_auth": True,
"supports_desktop_backend_ownership": True,
"studio_root_id": _studio_root_id(),
@ -1098,7 +1100,8 @@ async def health_check(request: Request):
"service": "Unsloth UI Backend",
"chat_only": _hw_module.CHAT_ONLY,
"desktop_protocol_version": 1,
"desktop_manageability_version": 1,
# Lockstep: see the note in /api/liveness above.
"desktop_manageability_version": 2,
"supports_desktop_auth": True,
"supports_desktop_backend_ownership": True,
# Opaque per-install id; launchers reject sibling Studios on the same port.

305
studio/install_manifest.py Normal file
View file

@ -0,0 +1,305 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Install-completeness manifest for Unsloth Studio.
install_python_stack.py drops the manifest before the dependency pass and writes
it back only after the last step, so its presence means "the install finished".
Read by `unsloth studio verify-install`, `desktop-capabilities` (and through it
the Tauri preflight) and setup.sh/setup.ps1's fast path.
Without it an installer killed part-way leaves a venv with `unsloth` but not
studio.txt's dependencies, which still answers `-h` and so looked ready right up
until the backend died on `import structlog`.
Must import inside that half-installed venv: stdlib only, `packaging` optional.
"""
from __future__ import annotations
import hashlib
import json
import os
import platform
import re
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
MANIFEST_NAME = "unsloth_install_manifest.json"
MANIFEST_SCHEMA = 1
# Fingerprinted into the manifest, relative to studio/backend/requirements/.
# Editing one (a --local install) invalidates it and forces a dependency pass.
TRACKED_REQUIREMENT_FILES: Tuple[str, ...] = (
"studio.txt",
"base.txt",
"extras.txt",
"extras-no-deps.txt",
"no-torch-runtime.txt",
"single-env/data-designer-deps.txt",
"single-env/data-designer.txt",
)
# The import chain studio/backend/run.py walks on startup.
BOOT_REQUIREMENT_FILE = "studio.txt"
def venv_root() -> Path:
"""Directory holding pyvenv.cfg for the interpreter running this code."""
return Path(sys.prefix)
def manifest_path(root: Optional[Path] = None) -> Path:
return (root or venv_root()) / MANIFEST_NAME
def requirements_root(script_dir: Optional[Path] = None) -> Path:
"""studio/backend/requirements/ next to this module (or a given studio/ dir)."""
return (script_dir or Path(__file__).resolve().parent) / "backend" / "requirements"
def _sha256(path: Path) -> Optional[str]:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return None
def requirement_digests(req_root: Optional[Path] = None) -> Dict[str, str]:
"""sha256 of every tracked requirement file that exists."""
root = req_root or requirements_root()
digests: Dict[str, str] = {}
for name in TRACKED_REQUIREMENT_FILES:
digest = _sha256(root / name)
if digest is not None:
digests[name] = digest
return digests
def _canonical(name: str) -> str:
"""PEP 503 normalisation, so PyJWT / pyjwt / py_jwt compare equal."""
return re.sub(r"[-_.]+", "-", name).lower()
def _installed_version(dist_name: str, installed: Optional[Dict[str, str]] = None) -> Optional[str]:
if installed is not None:
return installed.get(_canonical(dist_name))
from importlib.metadata import PackageNotFoundError, version
try:
return version(dist_name)
except PackageNotFoundError:
return None
except Exception:
return None
def remove_manifest(root: Optional[Path] = None) -> bool:
"""Called before the dependency pass so an aborted run cannot leave a valid one.
True when no manifest remains. A surviving marker (Windows raises on a
read-only or locked file) still names this version and these digests, so a
pass killed afterwards would verify as complete.
"""
try:
manifest_path(root).unlink()
except FileNotFoundError:
return True
except OSError:
return False
return True
def write_manifest(
root: Optional[Path] = None,
req_root: Optional[Path] = None,
steps_total: int = 0,
package_name: str = "unsloth",
) -> Optional[Path]:
"""Record a completed install. Never raises: no manifest reads as incomplete,
which is the safe answer."""
payload = {
"schema": MANIFEST_SCHEMA,
"completed_at_ms": int(time.time() * 1000),
"package": package_name,
"package_version": _installed_version(package_name),
"python": platform.python_version(),
"platform": f"{sys.platform}-{platform.machine()}",
"prefix": str(venv_root()),
"steps_total": steps_total,
"requirement_files": requirement_digests(req_root),
}
path = manifest_path(root)
try:
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, indent = 2, sort_keys = True), encoding = "utf-8")
os.replace(tmp, path)
return path
except OSError:
return None
def read_manifest(root: Optional[Path] = None) -> Optional[dict]:
try:
raw = manifest_path(root).read_text(encoding = "utf-8")
except OSError:
return None
try:
data = json.loads(raw)
except ValueError:
return None
return data if isinstance(data, dict) else None
def _parse_requirement_line(line: str) -> Optional[Tuple[str, str, str]]:
"""(distribution name, marker, specifier) for a requirement, or None.
Covers what studio.txt uses: names, specifiers, inline comments, markers.
pip flags are skipped.
"""
text = line.split("#", 1)[0].strip()
if not text or text.startswith("-"):
return None
try:
from packaging.requirements import Requirement
requirement = Requirement(text)
return (
requirement.name,
str(requirement.marker or ""),
str(requirement.specifier),
)
except Exception:
pass
marker = ""
if ";" in text:
text, marker = text.split(";", 1)
marker = marker.strip()
name = text.strip()
for sep in ("===", "==", ">=", "<=", "~=", "!=", ">", "<", "[", " "):
idx = name.find(sep)
if idx > 0:
name = name[:idx]
name = name.strip()
return (name, marker, "") if name else None
def _marker_applies(marker: str) -> bool:
"""True when the environment marker matches (or cannot be evaluated)."""
if not marker:
return True
try:
from packaging.markers import Marker
except Exception:
# No packaging: assume it applies. Over-reporting costs one extra pass.
return True
try:
return bool(Marker(marker).evaluate())
except Exception:
return True
def _version_satisfies(version: str, specifier: str) -> bool:
if not specifier:
return True
try:
from packaging.specifiers import SpecifierSet
return SpecifierSet(specifier).contains(version)
except Exception:
return False
def missing_requirements(
req_file: Optional[Path] = None, installed: Optional[Dict[str, str]] = None
) -> List[str]:
"""Distribution names that are missing or outside their required versions.
Checked via importlib.metadata, not import names, because studio.txt lists
PyJWT / python-docx / pymupdf whose import names (jwt, docx, fitz) differ.
`installed` (canonical distribution name -> version) checks a venv other
than the one running this code, which importlib.metadata cannot see.
"""
from importlib.metadata import PackageNotFoundError, distribution
path = req_file or (requirements_root() / BOOT_REQUIREMENT_FILE)
try:
lines = path.read_text(encoding = "utf-8").splitlines()
except OSError:
return []
missing: List[str] = []
for line in lines:
parsed = _parse_requirement_line(line)
if parsed is None:
continue
name, marker, specifier = parsed
if not _marker_applies(marker):
continue
if installed is not None:
version = installed.get(_canonical(name))
if version is None or not _version_satisfies(version, specifier):
missing.append(name)
continue
try:
dist = distribution(name)
except PackageNotFoundError:
missing.append(name)
except Exception:
missing.append(name)
else:
if not _version_satisfies(dist.version, specifier):
missing.append(name)
return missing
def verify_install(
root: Optional[Path] = None,
req_root: Optional[Path] = None,
package_name: str = "unsloth",
installed: Optional[Dict[str, str]] = None,
) -> dict:
"""Report whether the managed install finished and can still boot.
Reason strings are surfaced verbatim by the desktop preflight as its
staleness reason, so keep them stable.
Pass `installed` (and the matching `root` / `req_root`) to describe a venv
other than this interpreter's; without it the version and dependency checks
would answer for the venv the caller happens to be running in.
"""
reqs = req_root or requirements_root()
missing = missing_requirements(reqs / BOOT_REQUIREMENT_FILE, installed = installed)
deps_ok = not missing
manifest = read_manifest(root)
manifest_ok = False
reason: Optional[str] = None
if manifest is None:
reason = "studio_install_incomplete"
elif manifest.get("schema") != MANIFEST_SCHEMA:
reason = "studio_install_manifest_schema"
else:
# `update --package X` records X, so comparing against unsloth would
# report a permanent version change.
current = _installed_version(manifest.get("package") or package_name, installed)
recorded = manifest.get("package_version")
if current and recorded and current != recorded:
reason = "studio_install_version_changed"
elif manifest.get("requirement_files") != requirement_digests(reqs):
reason = "studio_install_requirements_changed"
else:
manifest_ok = True
if manifest_ok and not deps_ok:
# Install finished but the boot deps are gone: venv edited afterwards.
reason = "studio_deps_missing"
return {
"ok": manifest_ok and deps_ok,
"manifest_ok": manifest_ok,
"deps_ok": deps_ok,
"missing": missing,
"reason": None if (manifest_ok and deps_ok) else (reason or "studio_deps_missing"),
}

View file

@ -28,6 +28,9 @@ _BACKEND_DIR = Path(__file__).resolve().parent / "backend"
if str(_BACKEND_DIR) not in sys.path:
sys.path.insert(1, str(_BACKEND_DIR))
# setup.sh/setup.ps1 invoke this by path, so its directory is sys.path[0].
import install_manifest # noqa: E402
from backend.utils.wheel_utils import (
flash_attn_package_version,
flash_attn_wheel_url,
@ -2856,6 +2859,18 @@ def install_python_stack() -> int:
base_total += 2 # flash-attn + torch final repair (step 13), Linux
_TOTAL = (base_total - 1) if skip_base else base_total
# Drop it up front: a missing manifest is what tells the CLI, setup.sh and
# the preflight that an interrupted run left the venv half-built. Stop if it
# survives rather than mutate the venv behind a marker that still verifies.
if not install_manifest.remove_manifest():
print(
f"error: could not remove the stale {install_manifest.MANIFEST_NAME} in "
f"{install_manifest.venv_root()}; refusing to install behind a marker "
"that would still report this venv as complete",
file = sys.stderr,
)
return 1
# 1. Try uv for faster installs (before pip upgrade -- uv venvs don't
# include pip by default).
USE_UV = _bootstrap_uv()
@ -3234,6 +3249,23 @@ def install_python_stack() -> int:
**_windows_hidden_subprocess_kwargs(),
)
# 15. Record success. Written last so an earlier kill leaves none. Exiting 0
# without it reports a finished install every later check calls unfinished.
if (
install_manifest.write_manifest(
req_root = REQ_ROOT,
steps_total = _TOTAL,
package_name = package_name,
)
is None
):
print(
f"error: could not write {install_manifest.MANIFEST_NAME} to "
f"{install_manifest.venv_root()}",
file = sys.stderr,
)
return 1
_step(_LABEL, "installed")
return 0

View file

@ -2977,6 +2977,26 @@ sys.exit(0 if (major, minor) >= (4, 14) else 1)
substep "anyio >=4.14 found (#6483) -- forcing dependency pass to repair..." "Cyan"
$SkipPythonDeps = $false
}
# An interrupted install leaves $_PkgName current while studio.txt
# never finished, so the compare above says "up to date" and update --
# plus the desktop Repair button -- no-ops on a venv that cannot boot.
$_studioInstallIncomplete = $false
try {
& python -c "
import sys
sys.path.insert(0, sys.argv[1])
try:
import install_manifest
except Exception:
sys.exit(0) # older tree without the manifest helper: leave the fast path alone
sys.exit(0 if install_manifest.verify_install()['ok'] else 1)
" "$PSScriptRoot" 2>$null
if ($LASTEXITCODE -ne 0) { $_studioInstallIncomplete = $true }
} catch {}
if ($_studioInstallIncomplete) {
substep "studio install incomplete -- forcing dependency pass to repair..." "Cyan"
$SkipPythonDeps = $false
}
# ...but not if an AMD GPU is present and installed PyTorch is CPU-only
# (host predates ROCm-wheel support, or GPU added later): the fast "up to
# date" path would leave the user on CPU torch with Train/Export disabled.
@ -3023,6 +3043,28 @@ if ($script:PinChangedForceReinstall) { $SkipPythonDeps = $false }
if (-not $SkipPythonDeps) {
# install_python_stack.py drops the manifest before its own dependency pass, but
# pip, torch and triton are replaced first here. Drop it now so a run killed in
# those leaves the venv marked half-built, not behind a marker that verifies.
$_ManifestDropped = $true
try {
& python -c "
import sys
sys.path.insert(0, sys.argv[1])
try:
import install_manifest
except Exception:
sys.exit(0) # older tree without the manifest helper
sys.exit(0 if install_manifest.remove_manifest() else 1)
" "$PSScriptRoot" 2>$null
if ($LASTEXITCODE -ne 0) { $_ManifestDropped = $false }
} catch { $_ManifestDropped = $false }
if (-not $_ManifestDropped) {
Write-Host "[ERROR] Could not remove the stale unsloth_install_manifest.json." -ForegroundColor Red
Write-Host " Refusing to install behind a marker that still reports this venv as complete." -ForegroundColor Red
exit 1
}
if ($script:UnslothVerbose) {
Fast-Install --upgrade pip
} else {

View file

@ -1044,6 +1044,21 @@ sys.exit(0 if (major, minor) >= (4, 14) else 1)
substep "anyio >=4.14 found (#6483) -- forcing dependency pass to repair..."
_SKIP_PYTHON_DEPS=false
fi
# An interrupted install leaves $_PKG_NAME current while studio.txt
# never finished, so the compare above says "up to date" and update --
# plus the desktop Repair button -- no-ops on a venv that cannot boot.
if ! "$VENV_DIR/bin/python" -c "
import sys
sys.path.insert(0, sys.argv[1])
try:
import install_manifest
except Exception:
sys.exit(0) # older tree without the manifest helper: leave the fast path alone
sys.exit(0 if install_manifest.verify_install()['ok'] else 1)
" "$SCRIPT_DIR" 2>/dev/null; then
substep "studio install incomplete -- forcing dependency pass to repair..."
_SKIP_PYTHON_DEPS=false
fi
elif [ -n "$INSTALLED_VER" ] && [ -n "$LATEST_VER" ]; then
substep "$_PKG_NAME $INSTALLED_VER -> $LATEST_VER available, updating..."
elif [ -z "$LATEST_VER" ]; then

View file

@ -528,7 +528,7 @@ fn lifecycle_control_block_reason(liveness: &DesktopLiveness) -> Option<String>
return Some("desktop_auth_unsupported".to_string());
}
if liveness.desktop_manageability_version.unwrap_or(0)
< crate::preflight::DESKTOP_MANAGEABILITY_VERSION
< crate::preflight::DESKTOP_BACKEND_MANAGEABILITY_VERSION
{
return Some("desktop_manageability_unsupported".to_string());
}
@ -1014,14 +1014,12 @@ mod tests {
assert!(!metadata_is_well_formed(&metadata));
}
#[test]
fn liveness_verification_requires_root_kind_and_token_sha() {
let metadata = metadata(1, Some(8888));
let liveness = DesktopLiveness {
fn owned_liveness(manageability: u16) -> DesktopLiveness {
DesktopLiveness {
status: Some("alive".to_string()),
service: Some("Unsloth UI Backend".to_string()),
desktop_protocol_version: Some(1),
desktop_manageability_version: Some(1),
desktop_manageability_version: Some(manageability),
supports_desktop_auth: Some(true),
supports_desktop_backend_ownership: Some(true),
studio_root_id: Some(ROOT_ID.to_string()),
@ -1029,7 +1027,57 @@ mod tests {
kind: Some(OWNER_KIND_TAURI.to_string()),
token_sha256: Some(token_sha256(TOKEN)),
}),
};
}
}
#[test]
fn legacy_manageability_backend_stays_lifecycle_controllable() {
// A backend from the previous app version reports manageability 1.
// studio_install_ok is CLI-side, not part of this backend's HTTP
// contract: blocking makes preflight answer ExternalConflict and never
// adopt a process the root id and token already prove is ours.
assert_eq!(lifecycle_control_block_reason(&owned_liveness(1)), None);
assert_eq!(
lifecycle_control_block_reason(&owned_liveness(
crate::preflight::DESKTOP_MANAGEABILITY_VERSION
)),
None
);
// The bits a live backend really must carry are still enforced.
let mut no_ownership = owned_liveness(1);
no_ownership.supports_desktop_backend_ownership = Some(false);
assert_eq!(
lifecycle_control_block_reason(&no_ownership).as_deref(),
Some("desktop_backend_ownership_unsupported")
);
let mut no_auth = owned_liveness(1);
no_auth.supports_desktop_auth = Some(false);
assert_eq!(
lifecycle_control_block_reason(&no_auth).as_deref(),
Some("desktop_auth_unsupported")
);
let mut old_protocol = owned_liveness(1);
old_protocol.desktop_protocol_version = Some(0);
assert_eq!(
lifecycle_control_block_reason(&old_protocol).as_deref(),
Some("desktop_protocol_incompatible")
);
let mut no_manageability = owned_liveness(1);
no_manageability.desktop_manageability_version = None;
assert_eq!(
lifecycle_control_block_reason(&no_manageability).as_deref(),
Some("desktop_manageability_unsupported")
);
}
#[test]
fn liveness_verification_requires_root_kind_and_token_sha() {
let metadata = metadata(1, Some(8888));
let liveness = owned_liveness(1);
assert!(liveness_verifies_metadata(&liveness, &metadata));
let mut wrong_root = liveness;

View file

@ -14,7 +14,8 @@ use std::path::PathBuf;
use types::{BackendProbe, ManagedProbe};
pub use types::{DesktopPreflightDisposition, DesktopPreflightResult, ExternalBackendConflict};
pub(crate) use version::{
backend_version_stale_reason, DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
backend_version_stale_reason, DESKTOP_BACKEND_MANAGEABILITY_VERSION,
DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
};
#[cfg(test)]
@ -577,7 +578,7 @@ exit 1
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-capabilities" ] && [ "$3" = "--json" ]; then
printf '{"desktop_protocol_version":1,"desktop_manageability_version":1,"supports_api_only":true,"supports_provision_desktop_auth":true,"supports_desktop_backend_ownership":true,"version":"2026.5.3"}'
printf '{"desktop_protocol_version":1,"desktop_manageability_version":2,"supports_api_only":true,"supports_provision_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_install_ok":true,"version":"2026.5.3"}'
exit 0
fi
exit 1
@ -589,7 +590,7 @@ exit 1
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-capabilities" ] && [ "$3" = "--json" ]; then
printf '{"desktop_protocol_version":1,"desktop_manageability_version":1,"supports_api_only":true,"supports_provision_desktop_auth":false,"supports_desktop_backend_ownership":true,"desktop_auth_stale_reason":"cap_false","version":"2026.5.3"}'
printf '{"desktop_protocol_version":1,"desktop_manageability_version":2,"supports_api_only":true,"supports_provision_desktop_auth":false,"supports_desktop_backend_ownership":true,"desktop_auth_stale_reason":"cap_false","studio_install_ok":true,"version":"2026.5.3"}'
exit 0
fi
if [ "$1" = "studio" ] && [ "$2" = "provision-desktop-auth" ] && [ "$3" = "--help" ]; then exit 0; fi
@ -642,7 +643,7 @@ if [ "$1" = "-h" ]; then
fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-capabilities" ] && [ "$3" = "--json" ]; then
if [ -f "$modecap" ]; then exit 42; fi
printf '{"desktop_protocol_version":1,"desktop_manageability_version":1,"supports_api_only":true,"supports_provision_desktop_auth":true,"supports_desktop_backend_ownership":true,"version":"2026.5.3"}'
printf '{"desktop_protocol_version":1,"desktop_manageability_version":2,"supports_api_only":true,"supports_provision_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_install_ok":true,"version":"2026.5.3"}'
exit 0
fi
exit 1
@ -712,7 +713,7 @@ exit 1
fn desktop_ready_health_with_owner(root_id: &str, include_owner: bool) -> String {
let owner = desktop_owner_json(include_owner);
format!(
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_protocol_version":1,"desktop_manageability_version":1,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{root_id}"{owner}}}"#
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_protocol_version":1,"desktop_manageability_version":2,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{root_id}"{owner}}}"#
)
}
@ -772,7 +773,7 @@ exit 1
async fn backend_with_auth_support_but_missing_protocol_is_old() {
let probe = probe_test_backend(
format!(
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_manageability_version":1,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{EXPECTED_ROOT_ID}"{}}}"#,
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_manageability_version":2,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{EXPECTED_ROOT_ID}"{}}}"#,
desktop_owner_json(true)
),
"401 Unauthorized",
@ -790,6 +791,41 @@ exit 1
assert!(matches!(probe, BackendProbe::Ready { .. }));
}
#[tokio::test]
async fn legacy_manageability_same_root_backend_is_still_ready() {
// Same migration window as the owned-backend case: a server from the
// release before the CLI gained studio_install_ok reports manageability
// 1. That capability is CLI-side, so it must not turn a live,
// protocol-compatible backend into a conflict the user has to kill.
let probe = probe_test_backend(
format!(
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_protocol_version":1,"desktop_manageability_version":1,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{EXPECTED_ROOT_ID}"{}}}"#,
desktop_owner_json(true)
),
"401 Unauthorized",
)
.await;
assert!(matches!(probe, BackendProbe::Ready { .. }));
}
#[tokio::test]
async fn backend_without_any_manageability_field_is_old() {
let probe = probe_test_backend(
format!(
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_protocol_version":1,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{EXPECTED_ROOT_ID}"{}}}"#,
desktop_owner_json(true)
),
"401 Unauthorized",
)
.await;
assert!(matches!(
probe,
BackendProbe::Old { reason, .. } if reason == "desktop_manageability_unsupported"
));
}
#[tokio::test]
async fn compatible_same_root_without_desktop_owner_is_ready() {
let probe = probe_test_backend(
@ -805,7 +841,7 @@ exit 1
async fn stale_same_root_without_desktop_owner_is_external_conflict() {
let probe = probe_test_backend(
format!(
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.1","desktop_protocol_version":1,"desktop_manageability_version":1,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{EXPECTED_ROOT_ID}"}}"#,
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.1","desktop_protocol_version":1,"desktop_manageability_version":2,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{EXPECTED_ROOT_ID}"}}"#,
),
"401 Unauthorized",
)
@ -885,7 +921,7 @@ exit 1
async fn backend_capability_false_is_old_even_when_route_401() {
let probe = probe_test_backend(
format!(
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_protocol_version":1,"desktop_manageability_version":1,"supports_desktop_auth":false,"supports_desktop_backend_ownership":true,"desktop_auth_stale_reason":"cap_false","studio_root_id":"{EXPECTED_ROOT_ID}"{}}}"#,
r#"{{"status":"healthy","service":"Unsloth UI Backend","version":"2026.5.3","desktop_protocol_version":1,"desktop_manageability_version":2,"supports_desktop_auth":false,"supports_desktop_backend_ownership":true,"desktop_auth_stale_reason":"cap_false","studio_root_id":"{EXPECTED_ROOT_ID}"{}}}"#,
desktop_owner_json(true)
),
"401 Unauthorized",

View file

@ -1,6 +1,6 @@
use super::types::BackendProbe;
use super::version::{
backend_version_stale_reason, DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
backend_version_stale_reason, DESKTOP_BACKEND_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
};
use serde::{Deserialize, Serialize};
@ -149,7 +149,7 @@ fn backend_capability_stale_reason(health: &BackendHealth) -> Option<String> {
.clone()
.or_else(|| Some("desktop_auth_unsupported".to_string()));
}
if health.desktop_manageability_version.unwrap_or(0) < DESKTOP_MANAGEABILITY_VERSION {
if health.desktop_manageability_version.unwrap_or(0) < DESKTOP_BACKEND_MANAGEABILITY_VERSION {
return Some("desktop_manageability_unsupported".to_string());
}
if health.supports_desktop_backend_ownership != Some(true) {

View file

@ -11,13 +11,18 @@ use std::time::{Duration, Instant, UNIX_EPOCH};
use tokio::io::AsyncReadExt;
use tokio::process::Command;
const MANAGED_CAPABILITY_CACHE_SCHEMA: u16 = 2;
// 3: the cached capability gained studio_install_ok / studio_install_reason.
const MANAGED_CAPABILITY_CACHE_SCHEMA: u16 = 3;
const FNV64_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const FNV64_PRIME: u64 = 0x100000001b3;
const HASHED_MARKER_MAX_BYTES: u64 = 64 * 1024;
const FALLBACK_MARKER_NAMES: &[&str] = &[
// In the fingerprint, not just the cached answer: a repair touching only
// studio.txt leaves every other marker alone, so a cache entry written
// while healthy would outlive the dropped manifest. Mirrors MANIFEST_NAME.
"unsloth_install_manifest.json",
"pyvenv.cfg",
"uv.lock",
"requirements.txt",
@ -33,6 +38,10 @@ struct DesktopCapability {
supports_provision_desktop_auth: Option<bool>,
supports_desktop_backend_ownership: Option<bool>,
desktop_auth_stale_reason: Option<String>,
// A part-way install leaves a CLI that answers `-h` and a backend that dies
// on `import structlog`, so a running CLI does not mean ready.
studio_install_ok: Option<bool>,
studio_install_reason: Option<String>,
version: Option<String>,
}
@ -94,6 +103,41 @@ fn marker_content_hash(path: &Path, metadata: &fs::Metadata) -> Option<u64> {
.map(|bytes| hash_bytes(FNV64_OFFSET_BASIS, &bytes))
}
fn site_packages_dirs(venv_dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
#[cfg(unix)]
{
if let Ok(lib_dir) = fs::read_dir(venv_dir.join("lib")) {
for entry in lib_dir.flatten() {
out.push(entry.path().join("site-packages"));
}
}
}
out.push(venv_dir.join("Lib").join("site-packages"));
// read_dir order is unspecified and the hashes below fold in order.
out.sort();
out
}
/// Hash of the .dist-info / .egg-info names present, version included.
///
/// pip uninstall rewrites nothing else that is fingerprinted, so a venv that
/// lost a studio.txt dependency would keep serving the healthy verdict.
fn installed_distributions_hash(site_packages: &Path) -> Option<u64> {
let mut names: Vec<String> = fs::read_dir(site_packages)
.ok()?
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
(name.ends_with(".dist-info") || name.ends_with(".egg-info")).then_some(name)
})
.collect();
names.sort();
Some(names.iter().fold(FNV64_OFFSET_BASIS, |hash, name| {
hash_bytes(hash, name.as_bytes())
}))
}
fn marker_candidates_for_bin(bin: &Path) -> Vec<PathBuf> {
let Some(scripts_dir) = bin.parent() else {
return Vec::new();
@ -103,34 +147,18 @@ fn marker_candidates_for_bin(bin: &Path) -> Vec<PathBuf> {
};
let mut out = Vec::new();
#[cfg(unix)]
{
if let Ok(lib_dir) = fs::read_dir(venv_dir.join("lib")) {
for entry in lib_dir.flatten() {
out.push(
entry
.path()
.join("site-packages")
.join("unsloth_cli")
.join("commands")
.join("studio.py"),
);
}
}
for site_packages in site_packages_dirs(venv_dir) {
out.push(
site_packages
.join("unsloth_cli")
.join("commands")
.join("studio.py"),
);
}
for marker_name in FALLBACK_MARKER_NAMES {
out.push(venv_dir.join(marker_name));
out.push(scripts_dir.join(marker_name));
}
out.push(
venv_dir
.join("Lib")
.join("site-packages")
.join("unsloth_cli")
.join("commands")
.join("studio.py"),
);
out
}
@ -160,7 +188,7 @@ fn managed_bin_fingerprint(bin: &Path) -> Option<ManagedBinFingerprint> {
})
.collect();
marker_entries.sort_by(|left, right| left.path.cmp(&right.path));
let marker_hash = marker_entries
let mut marker_hash = marker_entries
.iter()
.fold(FNV64_OFFSET_BASIS, |hash, marker| {
let next = hash_bytes(hash, marker.path.as_bytes());
@ -172,9 +200,20 @@ fn managed_bin_fingerprint(bin: &Path) -> Option<ManagedBinFingerprint> {
next
}
});
let marker_path = (!marker_entries.is_empty()).then(|| "markers".to_string());
let marker_size = (!marker_entries.is_empty()).then(|| marker_entries.len() as u64);
let marker_mtime_ms = (!marker_entries.is_empty()).then_some(marker_hash);
let mut tracked = marker_entries.len();
if let Some(venv_dir) = bin.parent().and_then(Path::parent) {
for site_packages in site_packages_dirs(venv_dir) {
let Some(dist_hash) = installed_distributions_hash(&site_packages) else {
continue;
};
marker_hash = hash_bytes(marker_hash, site_packages.to_string_lossy().as_bytes());
marker_hash = hash_bytes(marker_hash, &dist_hash.to_le_bytes());
tracked += 1;
}
}
let marker_path = (tracked > 0).then(|| "markers".to_string());
let marker_size = (tracked > 0).then_some(tracked as u64);
let marker_mtime_ms = (tracked > 0).then_some(marker_hash);
Some(ManagedBinFingerprint {
bin_path,
@ -401,6 +440,16 @@ fn desktop_capability_stale_reason(capability: &DesktopCapability) -> Option<Str
if capability.supports_desktop_backend_ownership != Some(true) {
return Some("desktop_backend_ownership_unsupported".to_string());
}
// Half-installed is Stale, not Ready: starting the backend just crashes it.
// A CLI too old to answer is already rejected above on manageability.
if capability.studio_install_ok != Some(true) {
return Some(
capability
.studio_install_reason
.clone()
.unwrap_or_else(|| "studio_install_incomplete".to_string()),
);
}
backend_version_stale_reason(capability.version.as_deref())
}
@ -492,3 +541,198 @@ pub(super) async fn probe_managed_install() -> ManagedProbe {
pub async fn managed_install_ready() -> bool {
matches!(probe_managed_install().await, ManagedProbe::Ready { .. })
}
#[cfg(test)]
mod tests {
use super::*;
fn healthy_capability() -> DesktopCapability {
DesktopCapability {
desktop_protocol_version: Some(DESKTOP_PROTOCOL_VERSION),
desktop_manageability_version: Some(DESKTOP_MANAGEABILITY_VERSION),
supports_api_only: Some(true),
supports_provision_desktop_auth: Some(true),
supports_desktop_backend_ownership: Some(true),
desktop_auth_stale_reason: None,
studio_install_ok: Some(true),
studio_install_reason: None,
version: Some("2026.7.5".to_string()),
}
}
#[test]
fn complete_install_is_ready() {
assert_eq!(desktop_capability_stale_reason(&healthy_capability()), None);
assert!(desktop_capability_ready(&healthy_capability()));
}
#[test]
fn incomplete_install_is_stale_with_the_cli_reason() {
// The venv has the CLI but not structlog, so preflight must repair
// rather than spawn a backend that cannot import.
let mut capability = healthy_capability();
capability.studio_install_ok = Some(false);
capability.studio_install_reason = Some("studio_install_incomplete".to_string());
assert_eq!(
desktop_capability_stale_reason(&capability).as_deref(),
Some("studio_install_incomplete")
);
assert!(!desktop_capability_ready(&capability));
}
#[test]
fn deps_removed_after_install_is_stale() {
let mut capability = healthy_capability();
capability.studio_install_ok = Some(false);
capability.studio_install_reason = Some("studio_deps_missing".to_string());
assert_eq!(
desktop_capability_stale_reason(&capability).as_deref(),
Some("studio_deps_missing")
);
}
#[test]
fn missing_install_field_falls_back_to_a_generic_reason() {
let mut capability = healthy_capability();
capability.studio_install_ok = None;
capability.studio_install_reason = None;
assert_eq!(
desktop_capability_stale_reason(&capability).as_deref(),
Some("studio_install_incomplete")
);
}
#[test]
fn older_cli_is_rejected_on_manageability_before_the_install_check() {
// A CLI predating this feature cannot answer studio_install_ok, so the
// more specific manageability reason must win in the diagnostics.
let mut capability = healthy_capability();
capability.desktop_manageability_version = Some(1);
capability.studio_install_ok = None;
assert_eq!(
desktop_capability_stale_reason(&capability).as_deref(),
Some("desktop_manageability_unsupported")
);
}
#[test]
fn a_stale_capability_is_never_served_from_cache() {
// write_cached_capability runs before the ready check, so an incomplete
// install does get cached; reusing it would outlive the repair.
let mut capability = healthy_capability();
capability.studio_install_ok = Some(false);
let cache = ManagedCapabilityCache {
schema: MANAGED_CAPABILITY_CACHE_SCHEMA,
bin_path: "/managed/unsloth".to_string(),
bin_size: 1,
bin_mtime_ms: 1,
studio_root_id: None,
marker_path: None,
marker_size: None,
marker_mtime_ms: None,
desktop_protocol_version: DESKTOP_PROTOCOL_VERSION,
desktop_manageability_version: DESKTOP_MANAGEABILITY_VERSION,
capability,
};
let fingerprint = ManagedBinFingerprint {
bin_path: "/managed/unsloth".to_string(),
bin_size: 1,
bin_mtime_ms: 1,
studio_root_id: None,
marker_path: None,
marker_size: None,
marker_mtime_ms: None,
};
assert!(!cache_matches(&cache, &fingerprint));
}
#[test]
fn dropping_the_manifest_changes_the_fingerprint() {
// Otherwise a cache entry written while healthy outlives the manifest,
// and the probe returns Ready on the very venv this is meant to catch.
let venv = std::env::temp_dir().join(format!(
"unsloth-fingerprint-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let scripts = venv.join("bin");
fs::create_dir_all(&scripts).unwrap();
let bin = scripts.join("unsloth");
fs::write(&bin, "#!/bin/sh\nexit 0\n").unwrap();
let manifest = venv.join("unsloth_install_manifest.json");
fs::write(&manifest, "{}").unwrap();
let with_manifest = managed_bin_fingerprint(&bin).unwrap();
fs::remove_file(&manifest).unwrap();
let without_manifest = managed_bin_fingerprint(&bin).unwrap();
assert_ne!(with_manifest, without_manifest);
let _ = fs::remove_dir_all(&venv);
}
fn cache_for(fingerprint: &ManagedBinFingerprint) -> ManagedCapabilityCache {
ManagedCapabilityCache {
schema: MANAGED_CAPABILITY_CACHE_SCHEMA,
bin_path: fingerprint.bin_path.clone(),
bin_size: fingerprint.bin_size,
bin_mtime_ms: fingerprint.bin_mtime_ms,
studio_root_id: fingerprint.studio_root_id.clone(),
marker_path: fingerprint.marker_path.clone(),
marker_size: fingerprint.marker_size,
marker_mtime_ms: fingerprint.marker_mtime_ms,
desktop_protocol_version: DESKTOP_PROTOCOL_VERSION,
desktop_manageability_version: DESKTOP_MANAGEABILITY_VERSION,
capability: healthy_capability(),
}
}
#[test]
fn losing_a_studio_package_changes_the_fingerprint() {
// pip uninstall rewrites no fingerprinted file: the manifest, pyvenv.cfg
// and the launcher survive and `unsloth -h` still exits 0. Without the
// installed distributions in the fingerprint the healthy answer sticks.
let venv = std::env::temp_dir().join(format!(
"unsloth-fingerprint-deps-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&venv);
let scripts = venv.join("bin");
fs::create_dir_all(&scripts).unwrap();
let bin = scripts.join("unsloth");
fs::write(&bin, "#!/bin/sh\nexit 0\n").unwrap();
fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\n").unwrap();
fs::write(venv.join("unsloth_install_manifest.json"), "{}").unwrap();
let site_packages = venv.join("lib").join("python3.11").join("site-packages");
fs::create_dir_all(site_packages.join("unsloth_cli").join("commands")).unwrap();
fs::write(
site_packages
.join("unsloth_cli")
.join("commands")
.join("studio.py"),
"# cli\n",
)
.unwrap();
let dist_info = site_packages.join("fastmcp-3.0.2.dist-info");
fs::create_dir_all(&dist_info).unwrap();
fs::write(dist_info.join("METADATA"), "Name: fastmcp\n").unwrap();
let with_dep = managed_bin_fingerprint(&bin).unwrap();
let healthy_cache = cache_for(&with_dep);
// read_dir order is unspecified, so an unsorted walk would miss its own
// cache every launch and the entry would never be worth writing.
assert_eq!(with_dep, managed_bin_fingerprint(&bin).unwrap());
assert!(cache_matches(&healthy_cache, &with_dep));
fs::remove_dir_all(&dist_info).unwrap();
let without_dep = managed_bin_fingerprint(&bin).unwrap();
assert_ne!(with_dep, without_dep);
assert!(
!cache_matches(&healthy_cache, &without_dep),
"a removed studio package must not keep serving the cached Ready answer"
);
let _ = fs::remove_dir_all(&venv);
}
}

View file

@ -1,7 +1,15 @@
use std::cmp::Ordering;
pub(crate) const DESKTOP_PROTOCOL_VERSION: u16 = 1;
pub(crate) const DESKTOP_MANAGEABILITY_VERSION: u16 = 1;
// 2: the CLI must report studio_install_ok from `studio desktop-capabilities`,
// so an interrupted install is caught before the backend is spawned. A CLI
// reporting 1 is Stale and gets repaired, which reinstalls what it missed.
pub(crate) const DESKTOP_MANAGEABILITY_VERSION: u16 = 2;
// What a RUNNING backend must report to be adopted and stopped. Not the
// constant above: studio_install_ok is CLI-side, so gating on 2 would only
// reject (and so never adopt, or stop) a backend the previous app version
// spawned. Bump only for a real backend contract change, keep it <= main.py's.
pub(crate) const DESKTOP_BACKEND_MANAGEABILITY_VERSION: u16 = 1;
// Explicit backend package minimum, not the desktop app Cargo version: backend
// and app releases can diverge. When bumping, verify this package exists on PyPI.
pub(super) const MIN_DESKTOP_BACKEND_VERSION: &str = "2026.5.3";