Stop the runtime probe stranding older CLIs and rejected settings

Every released CLI predates desktop-runtime-check: PyPI's latest unsloth is
2026.7.5 and MIN_DESKTOP_BACKEND_VERSION stays 2026.5.3, so a field install is
version-compatible and simply lacks the subcommand. It then exits 2 with no
stdout, which read as a failed probe, and probe_managed_bin returned Stale
before consulting the capability cache, so a healthy install was force-repaired
and an offline user could not start at all. Fall back to the previous launch
probe, gated on 'desktop-runtime-check --help' (0 when the command exists, 2
when it does not), so a new CLI that crashes or times out still reports Stale.

Metadata outlives the package it describes. Hatchling wheels store
.dist-info/METADATA as the first archive entry (verified: index 0 for both
fastapi and typer), so an unpack killed midway leaves a readable version for
modules that never landed and the probe called the venv ready. RECORD is
written last, so a missing file list marks that unpack; checked against all 298
distributions in a real venv with no false positives. This supersedes my
earlier reasoning that the metadata is written last, which was wrong.

run.py raises SystemExit at import for a rejected setting such as
UNSLOTH_CPU_THREADS, and SystemExit is not an Exception, so it escaped the
handler and emitted no payload at all. Report it as backend_startup_failed and
keep it out of the repair path: no reinstall can change an inherited
environment value, so repairing would replace a healthy install and fail again.

Also clear the install marker when the installer fails to spawn, the one
terminal outcome that still left it behind.
This commit is contained in:
Daniel Han 2026-07-27 16:25:07 +00:00
commit 8f343038ef
6 changed files with 224 additions and 8 deletions

View file

@ -236,6 +236,10 @@ export function useTauriBackend() {
stopExternalServerPoll();
if (preflight.can_auto_repair) {
await startRepair();
} else if (preflight.reason === "studio_runtime_startup_failed") {
setBackendError(
"The Unsloth backend refused to start with the current environment settings (for example UNSLOTH_CPU_THREADS). Fix or unset them, then restart Unsloth.",
);
} else {
setBackendError(
preflight.disposition === "owned_stale"

View file

@ -513,6 +513,8 @@ fn run_install_with_event_mode(
let (stdout, stderr) = match spawn_script(&script, &args, &state) {
Ok(handles) => handles,
Err(msg) => {
// Nothing ran, so nothing is half-installed.
clear_install_marker_best_effort();
diagnostics::finish_attempt(
&diagnostics,
&attempt,

View file

@ -24,10 +24,20 @@ use managed::probe_managed_bin;
#[cfg(test)]
use version::{backend_version_compatible, MIN_DESKTOP_BACKEND_VERSION};
/// The managed backend refused to start on a value it inherited from the
/// environment rather than from the install.
pub(crate) const STUDIO_RUNTIME_STARTUP_FAILED: &str = "studio_runtime_startup_failed";
fn release_auto_repair() -> bool {
!cfg!(debug_assertions)
}
/// Reinstalling cannot change a rejected environment value, so repairing over
/// one only replaces a healthy install and fails the same way afterwards.
fn stale_reason_is_repairable(reason: &str) -> bool {
reason != STUDIO_RUNTIME_STARTUP_FAILED
}
fn managed_bin_for_result(managed: &ManagedProbe) -> Option<PathBuf> {
match managed {
ManagedProbe::Ready { bin } | ManagedProbe::Stale { bin, .. } => Some(bin.clone()),
@ -61,9 +71,9 @@ fn choose_preflight(managed: ManagedProbe, backend: BackendProbe) -> DesktopPref
},
ManagedProbe::Stale { bin, reason } => DesktopPreflightResult {
disposition: DesktopPreflightDisposition::ManagedStale,
can_auto_repair: release_auto_repair() && stale_reason_is_repairable(&reason),
reason: Some(reason),
port: None,
can_auto_repair: release_auto_repair(),
managed_bin: Some(bin),
},
ManagedProbe::Missing => DesktopPreflightResult {
@ -392,6 +402,40 @@ mod tests {
}
}
#[test]
fn rejected_backend_settings_do_not_auto_repair() {
// No reinstall can change an inherited environment value, so repairing
// would replace a healthy install and fail again the same way.
assert!(!stale_reason_is_repairable(STUDIO_RUNTIME_STARTUP_FAILED));
for reason in [
"studio_runtime_missing_dependency",
"studio_runtime_import_failed",
"install_incomplete",
"desktop_backend_version_too_old",
] {
assert!(stale_reason_is_repairable(reason), "{reason}");
}
let result = choose_preflight(
ManagedProbe::Stale {
bin: PathBuf::from("/managed/unsloth"),
reason: STUDIO_RUNTIME_STARTUP_FAILED.to_string(),
},
BackendProbe::Missing,
);
assert_eq!(
result.disposition,
DesktopPreflightDisposition::ManagedStale
);
assert_eq!(
result.reason.as_deref(),
Some(STUDIO_RUNTIME_STARTUP_FAILED)
);
// False in every build profile, unlike the other stale reasons.
assert!(!result.can_auto_repair);
}
#[test]
fn external_conflict_blocks_managed_flow() {
let result = choose_preflight(
@ -675,6 +719,55 @@ exit 1
remove_managed_capability_cache();
}
#[cfg(unix)]
#[tokio::test]
async fn managed_cli_predating_the_runtime_check_is_not_forced_into_repair() {
// Every published CLI satisfies MIN_DESKTOP_BACKEND_VERSION but has no
// `desktop-runtime-check`, so click exits 2 with an empty stdout. That
// must not repair an install that still launches Studio.
let _cache_guard = MANAGED_CAPABILITY_CACHE_TEST_LOCK.lock().await;
let _cache_home = ManagedCapabilityCacheHome::new("runtime-check-absent");
remove_managed_capability_cache();
let old = fake_cli(
"runtime-check-absent",
r#"#!/bin/sh
if [ "$1" = "studio" ] && [ "$2" = "desktop-runtime-check" ]; then
echo "Error: No such command 'desktop-runtime-check'." >&2
exit 2
fi
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.7.5"}'
exit 0
fi
exit 1
"#,
);
let probe = probe_managed_bin(old.bin.clone()).await;
assert!(
matches!(probe, ManagedProbe::Ready { .. }),
"CLI without the runtime-check subcommand must stay Ready, got {probe:?}"
);
// The fallback must not rescue a CLI that cannot launch at all.
remove_managed_capability_cache();
let broken = fake_cli(
"runtime-check-unlaunchable",
r#"#!/bin/sh
exit 2
"#,
);
assert!(
matches!(
probe_managed_bin(broken.bin.clone()).await,
ManagedProbe::Stale { reason, .. } if reason == "studio_runtime_probe_failed"
),
"an unlaunchable CLI must stay Stale"
);
remove_managed_capability_cache();
}
#[cfg(unix)]
#[tokio::test]
async fn managed_runtime_probe_runs_before_capability_cache() {

View file

@ -270,6 +270,48 @@ fn write_cached_capability(fingerprint: &ManagedBinFingerprint, capability: &Des
}
}
async fn run_cli_probe(bin: &Path, args: &[&str]) -> bool {
let mut cmd = Command::new(bin);
cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let Ok(mut child) = cmd.spawn() else {
return false;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) => status.success(),
_ => {
let _ = child.kill().await;
let _ = child.wait().await;
false
}
}
}
/// True when the CLI is older than `desktop-runtime-check` yet still launches.
/// Such a CLI exits with a usage error and no JSON, which is indistinguishable
/// from a crashed probe; `--help` resolves the command without running it, and
/// the legacy launch probe keeps a genuinely unusable binary out of this path.
async fn predates_runtime_check(bin: &Path) -> bool {
!run_cli_probe(bin, &["studio", "desktop-runtime-check", "--help"]).await
&& run_cli_probe(bin, &["-h"]).await
}
async fn probe_cli_runtime(bin: &Path) -> Result<(), String> {
let started = Instant::now();
let mut cmd = Command::new(bin);
@ -348,6 +390,7 @@ async fn probe_cli_runtime(bin: &Path) -> Result<(), String> {
"studio_runtime_missing_dependency"
}
Some("backend_import_failed") => "studio_runtime_import_failed",
Some("backend_startup_failed") => super::STUDIO_RUNTIME_STARTUP_FAILED,
_ => "studio_runtime_probe_failed",
};
Err(reason.to_string())
@ -472,13 +515,21 @@ pub(super) async fn probe_managed_bin(bin: PathBuf) -> ManagedProbe {
// fingerprint only proves protocol compatibility, not that Studio's backend
// imports are complete after an interrupted dependency transaction.
if let Err(reason) = probe_cli_runtime(&bin).await {
// Every released CLI predates this subcommand, so a missing-command exit
// must not strand an install the launch probe still accepts.
if reason != "studio_runtime_probe_failed" || !predates_runtime_check(&bin).await {
info!(
"Managed preflight: runtime unusable for {:?} reason={} in {}ms",
bin,
reason,
started.elapsed().as_millis()
);
return ManagedProbe::Stale { bin, reason };
}
info!(
"Managed preflight: runtime unusable for {:?} reason={} in {}ms",
bin,
reason,
started.elapsed().as_millis()
"Managed preflight: cli predates the runtime probe for {:?}; using the launch probe",
bin
);
return ManagedProbe::Stale { bin, reason };
}
if let Some(fingerprint) = managed_bin_fingerprint(&bin) {

View file

@ -292,6 +292,12 @@ def _missing_studio_requirement(run_mod):
installed = distribution(requirement.name)
except PackageNotFoundError:
return requirement.name
# Metadata outlives the package it describes: hatchling wheels (fastapi,
# typer) store .dist-info/METADATA as the first archive entry, so an
# unpack killed midway leaves a readable version for modules that never
# landed. RECORD is written last, so its absence marks that unpack.
if installed.files is None:
return requirement.name
# prereleases=True: a prerelease satisfying a floor is not a broken install.
if requirement.specifier and not requirement.specifier.contains(
installed.version, prereleases = True
@ -2861,6 +2867,16 @@ def desktop_runtime_check(
"reason": "missing_dependency",
"module": exc.name,
}
# SystemExit is not an Exception. run.py raises it for rejected settings such
# as UNSLOTH_CPU_THREADS, and letting it escape would emit no payload at all,
# so the desktop app would reinstall over an environment value instead.
except SystemExit as exc:
payload = {
"runtime_ready": False,
"reason": "backend_startup_failed",
"error_type": "SystemExit",
"error": str(exc),
}
except Exception as exc:
payload = {
"runtime_ready": False,

View file

@ -94,7 +94,7 @@ def test_desktop_runtime_check_accepts_a_prerelease_over_a_floor(monkeypatch, ca
monkeypatch.setattr(
importlib.import_module("importlib.metadata"),
"distribution",
lambda _name: SimpleNamespace(version = "2.0.0b1"),
lambda _name: SimpleNamespace(version = "2.0.0b1", files = []),
)
studio.desktop_runtime_check(_json_output = True)
@ -113,7 +113,7 @@ def test_desktop_runtime_check_rejects_version_mismatch(monkeypatch, capsys, tmp
monkeypatch.setattr(
importlib.import_module("importlib.metadata"),
"distribution",
lambda _name: SimpleNamespace(version = "1.0"),
lambda _name: SimpleNamespace(version = "1.0", files = []),
)
with pytest.raises(typer.Exit):
@ -121,3 +121,53 @@ def test_desktop_runtime_check_rejects_version_mismatch(monkeypatch, capsys, tmp
payload = json.loads(capsys.readouterr().out)
assert payload["module"] == "example-package"
def test_desktop_runtime_check_rejects_metadata_without_an_unpacked_package(
monkeypatch, capsys, tmp_path,
):
"""fastapi's wheel stores .dist-info/METADATA as its first archive entry, so
an interrupted unpack leaves a readable version for modules that never
landed. RECORD is written last, so its absence marks the unfinished unpack."""
studio = importlib.import_module("unsloth_cli.commands.studio")
backend = tmp_path / "backend"
requirements = backend / "requirements"
requirements.mkdir(parents = True)
(requirements / "studio.txt").write_text("fastapi\n", encoding = "utf-8")
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
monkeypatch.setattr(
importlib.import_module("importlib.metadata"),
"distribution",
lambda _name: SimpleNamespace(version = "0.140.5", files = None),
)
with pytest.raises(typer.Exit):
studio.desktop_runtime_check(_json_output = True)
payload = json.loads(capsys.readouterr().out)
assert payload["reason"] == "missing_dependency"
assert payload["module"] == "fastapi"
def test_desktop_runtime_check_reports_a_rejected_setting_instead_of_exiting(
monkeypatch, capsys,
):
"""run.py raises SystemExit for values such as UNSLOTH_CPU_THREADS=invalid.
Escaping without a payload makes the desktop app reinstall over an
environment value no install can change."""
studio = importlib.import_module("unsloth_cli.commands.studio")
def _rejected_setting():
raise SystemExit("Error: Invalid UNSLOTH_CPU_THREADS value 'invalid'")
monkeypatch.setattr(studio, "_load_run_module", _rejected_setting)
with pytest.raises(typer.Exit) as exited:
studio.desktop_runtime_check(_json_output = True)
assert exited.value.exit_code == 1
payload = json.loads(capsys.readouterr().out)
assert payload["runtime_ready"] is False
assert payload["reason"] == "backend_startup_failed"
assert "UNSLOTH_CPU_THREADS" in payload["error"]