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:
parent
b40d422654
commit
8f343038ef
6 changed files with 224 additions and 8 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue