diff --git a/studio/frontend/src/hooks/use-tauri-backend.ts b/studio/frontend/src/hooks/use-tauri-backend.ts index 53122864e7..016316c9dd 100644 --- a/studio/frontend/src/hooks/use-tauri-backend.ts +++ b/studio/frontend/src/hooks/use-tauri-backend.ts @@ -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" diff --git a/studio/src-tauri/src/install.rs b/studio/src-tauri/src/install.rs index dc0d7ac24d..dee2481a6e 100644 --- a/studio/src-tauri/src/install.rs +++ b/studio/src-tauri/src/install.rs @@ -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, diff --git a/studio/src-tauri/src/preflight.rs b/studio/src-tauri/src/preflight.rs index e7abaa6715..fd596f4cd5 100644 --- a/studio/src-tauri/src/preflight.rs +++ b/studio/src-tauri/src/preflight.rs @@ -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 { 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() { diff --git a/studio/src-tauri/src/preflight/managed.rs b/studio/src-tauri/src/preflight/managed.rs index a45131584a..af722eef83 100644 --- a/studio/src-tauri/src/preflight/managed.rs +++ b/studio/src-tauri/src/preflight/managed.rs @@ -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) { diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index cf5215e69a..c6cae42b83 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -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, diff --git a/unsloth_cli/tests/test_studio_runtime_readiness.py b/unsloth_cli/tests/test_studio_runtime_readiness.py index 9eace51d60..2bbd1c7c0b 100644 --- a/unsloth_cli/tests/test_studio_runtime_readiness.py +++ b/unsloth_cli/tests/test_studio_runtime_readiness.py @@ -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"]