* 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>
939 lines
33 KiB
Rust
939 lines
33 KiB
Rust
mod backend;
|
|
mod managed;
|
|
mod types;
|
|
mod version;
|
|
|
|
use crate::desktop_backend_owner::{
|
|
OwnedBackendProbe, OwnedBackendReadiness, VerifiedOwnedBackend,
|
|
};
|
|
use backend::probe_existing_backends;
|
|
use log::warn;
|
|
pub use managed::managed_install_ready;
|
|
use managed::probe_managed_install;
|
|
use std::path::PathBuf;
|
|
use types::{BackendProbe, ManagedProbe};
|
|
pub use types::{DesktopPreflightDisposition, DesktopPreflightResult, ExternalBackendConflict};
|
|
pub(crate) use version::{
|
|
backend_version_stale_reason, DESKTOP_BACKEND_MANAGEABILITY_VERSION,
|
|
DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
|
|
};
|
|
|
|
#[cfg(test)]
|
|
use backend::{backend_desktop_auth_status, backend_health};
|
|
#[cfg(test)]
|
|
use managed::probe_managed_bin;
|
|
#[cfg(test)]
|
|
use version::{backend_version_compatible, MIN_DESKTOP_BACKEND_VERSION};
|
|
|
|
fn release_auto_repair() -> bool {
|
|
!cfg!(debug_assertions)
|
|
}
|
|
|
|
fn managed_bin_for_result(managed: &ManagedProbe) -> Option<PathBuf> {
|
|
match managed {
|
|
ManagedProbe::Ready { bin } | ManagedProbe::Stale { bin, .. } => Some(bin.clone()),
|
|
ManagedProbe::Missing => None,
|
|
}
|
|
}
|
|
|
|
fn choose_preflight(managed: ManagedProbe, backend: BackendProbe) -> DesktopPreflightResult {
|
|
match (backend, managed) {
|
|
(BackendProbe::ExternalConflict { port, reason }, managed) => DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::ExternalConflict,
|
|
reason: Some(reason),
|
|
port: Some(port),
|
|
can_auto_repair: false,
|
|
managed_bin: managed_bin_for_result(&managed),
|
|
},
|
|
(BackendProbe::Ready { port }, managed) => DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::AttachedReady,
|
|
reason: None,
|
|
port: Some(port),
|
|
can_auto_repair: false,
|
|
managed_bin: managed_bin_for_result(&managed),
|
|
},
|
|
(_, managed) => match managed {
|
|
ManagedProbe::Ready { bin } => DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::ManagedReady,
|
|
reason: None,
|
|
port: None,
|
|
can_auto_repair: false,
|
|
managed_bin: Some(bin),
|
|
},
|
|
ManagedProbe::Stale { bin, reason } => DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::ManagedStale,
|
|
reason: Some(reason),
|
|
port: None,
|
|
can_auto_repair: release_auto_repair(),
|
|
managed_bin: Some(bin),
|
|
},
|
|
ManagedProbe::Missing => DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::NotInstalled,
|
|
reason: None,
|
|
port: None,
|
|
can_auto_repair: false,
|
|
managed_bin: None,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
fn owned_unmanageable_reason(reason: &str) -> String {
|
|
format!("desktop_owned_backend_unmanageable:{reason}")
|
|
}
|
|
|
|
fn choose_owned_preflight(
|
|
managed: &ManagedProbe,
|
|
owned: &VerifiedOwnedBackend,
|
|
) -> DesktopPreflightResult {
|
|
match &owned.readiness {
|
|
OwnedBackendReadiness::Ready => DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::OwnedReady,
|
|
reason: None,
|
|
port: Some(owned.port),
|
|
can_auto_repair: false,
|
|
managed_bin: managed_bin_for_result(managed),
|
|
},
|
|
OwnedBackendReadiness::Stale { reason } => DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::OwnedStale,
|
|
reason: Some(reason.clone()),
|
|
port: Some(owned.port),
|
|
can_auto_repair: release_auto_repair(),
|
|
managed_bin: managed_bin_for_result(managed),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn choose_unmanageable_owned_preflight(
|
|
managed: &ManagedProbe,
|
|
port: u16,
|
|
reason: String,
|
|
) -> DesktopPreflightResult {
|
|
DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::ExternalConflict,
|
|
reason: Some(owned_unmanageable_reason(&reason)),
|
|
port: Some(port),
|
|
can_auto_repair: false,
|
|
managed_bin: managed_bin_for_result(managed),
|
|
}
|
|
}
|
|
|
|
fn choose_owned_transitional_preflight(
|
|
managed: &ManagedProbe,
|
|
port: Option<u16>,
|
|
) -> DesktopPreflightResult {
|
|
DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::ExternalConflict,
|
|
reason: Some("desktop_owned_backend_starting".to_string()),
|
|
port,
|
|
can_auto_repair: false,
|
|
managed_bin: managed_bin_for_result(managed),
|
|
}
|
|
}
|
|
|
|
fn choose_ownerless_spawned_preflight(
|
|
managed: &ManagedProbe,
|
|
backend: &BackendProbe,
|
|
port: Option<u16>,
|
|
) -> DesktopPreflightResult {
|
|
match (port, backend) {
|
|
(Some(owned_port), BackendProbe::Ready { port }) if owned_port == *port => {
|
|
DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::OwnedReady,
|
|
reason: None,
|
|
port: Some(*port),
|
|
can_auto_repair: false,
|
|
managed_bin: managed_bin_for_result(managed),
|
|
}
|
|
}
|
|
(Some(owned_port), BackendProbe::Old { port, reason }) if owned_port == *port => {
|
|
DesktopPreflightResult {
|
|
disposition: DesktopPreflightDisposition::OwnedStale,
|
|
reason: Some(reason.clone()),
|
|
port: Some(*port),
|
|
can_auto_repair: release_auto_repair(),
|
|
managed_bin: managed_bin_for_result(managed),
|
|
}
|
|
}
|
|
_ => choose_owned_transitional_preflight(managed, port),
|
|
}
|
|
}
|
|
|
|
fn mutation_blocker_from_probe(probe: BackendProbe) -> Option<ExternalBackendConflict> {
|
|
match probe {
|
|
BackendProbe::ExternalConflict { port, reason } => {
|
|
Some(ExternalBackendConflict { port, reason })
|
|
}
|
|
BackendProbe::Ready { port } => Some(ExternalBackendConflict {
|
|
port,
|
|
reason: "same_root_external_backend_active".to_string(),
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub async fn mutation_blocking_backend_ignoring(
|
|
ignored_ports: &[u16],
|
|
) -> Option<ExternalBackendConflict> {
|
|
mutation_blocker_from_probe(probe_existing_backends(ignored_ports).await)
|
|
}
|
|
|
|
pub async fn desktop_preflight_result() -> DesktopPreflightResult {
|
|
let (managed, backend) = tokio::join!(probe_managed_install(), probe_existing_backends(&[]));
|
|
choose_preflight(managed, backend)
|
|
}
|
|
|
|
pub async fn desktop_preflight_result_with_state(
|
|
state: &crate::process::BackendState,
|
|
) -> Result<(DesktopPreflightResult, Option<(u64, bool)>), String> {
|
|
let (managed, backend, owned) = tokio::join!(
|
|
probe_managed_install(),
|
|
probe_existing_backends(&[]),
|
|
crate::desktop_backend_owner::probe_verified_owned_backend()
|
|
);
|
|
|
|
if let Some(snapshot) = crate::process::owned_backend_snapshot(state)? {
|
|
let Some(owner) = snapshot.owner.clone() else {
|
|
// TAURI_PORT is emitted only after uvicorn lifespan completes; keep
|
|
// this ownerless path on full health so auth/bootstrap are ready.
|
|
|
|
let probe = match snapshot.port {
|
|
Some(port) => backend::probe_ownerless_spawned_backend(port).await,
|
|
None => backend,
|
|
};
|
|
return Ok((
|
|
choose_ownerless_spawned_preflight(&managed, &probe, snapshot.port),
|
|
None,
|
|
));
|
|
};
|
|
match crate::desktop_backend_owner::probe_owned_backend_state(
|
|
owner,
|
|
snapshot.port,
|
|
snapshot.is_adopted,
|
|
)
|
|
.await
|
|
{
|
|
OwnedBackendProbe::Verified(verified) => {
|
|
if snapshot.port.is_none() {
|
|
crate::process::record_owned_backend_port_if_current(
|
|
state,
|
|
snapshot.generation,
|
|
verified.port,
|
|
);
|
|
}
|
|
let result = choose_owned_preflight(&managed, &verified);
|
|
let watchdog_generation = if snapshot.is_adopted
|
|
&& result.disposition == DesktopPreflightDisposition::OwnedReady
|
|
{
|
|
Some((snapshot.generation, false))
|
|
} else {
|
|
None
|
|
};
|
|
return Ok((result, watchdog_generation));
|
|
}
|
|
OwnedBackendProbe::Unmanageable { port, reason } => {
|
|
return Ok((
|
|
choose_unmanageable_owned_preflight(&managed, port, reason),
|
|
None,
|
|
));
|
|
}
|
|
OwnedBackendProbe::NoMetadata
|
|
| OwnedBackendProbe::RemovedMalformed
|
|
| OwnedBackendProbe::NotVerified { .. } => {
|
|
if snapshot.is_adopted {
|
|
crate::process::clear_adopted_backend_if_current(
|
|
state,
|
|
snapshot.generation,
|
|
snapshot.port,
|
|
"state owner probe no longer verifies",
|
|
);
|
|
return Ok((choose_preflight(managed, backend), None));
|
|
}
|
|
return Ok((
|
|
choose_owned_transitional_preflight(&managed, snapshot.port),
|
|
None,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
let owned = match owned {
|
|
Ok(owned) => owned,
|
|
Err(error) => {
|
|
warn!(
|
|
"Desktop-owned backend probe failed; continuing without adoption: {}",
|
|
error
|
|
);
|
|
return Ok((choose_preflight(managed, backend), None));
|
|
}
|
|
};
|
|
|
|
match owned {
|
|
OwnedBackendProbe::Verified(verified) => {
|
|
let result = choose_owned_preflight(&managed, &verified);
|
|
let adopted = crate::process::adopt_verified_backend(state, verified)?;
|
|
let watchdog_generation =
|
|
if result.disposition == DesktopPreflightDisposition::OwnedReady {
|
|
Some((adopted.generation, adopted.newly_adopted))
|
|
} else {
|
|
None
|
|
};
|
|
Ok((result, watchdog_generation))
|
|
}
|
|
OwnedBackendProbe::Unmanageable { port, reason } => Ok((
|
|
choose_unmanageable_owned_preflight(&managed, port, reason),
|
|
None,
|
|
)),
|
|
OwnedBackendProbe::NoMetadata
|
|
| OwnedBackendProbe::RemovedMalformed
|
|
| OwnedBackendProbe::NotVerified { .. } => Ok((choose_preflight(managed, backend), None)),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
|
|
#[test]
|
|
fn choose_preflight_classifies_core_cases() {
|
|
let bin = || PathBuf::from("/managed/unsloth");
|
|
let ready = || ManagedProbe::Ready { bin: bin() };
|
|
let stale = || ManagedProbe::Stale {
|
|
bin: bin(),
|
|
reason: "old cli".to_string(),
|
|
};
|
|
let old_backend = || BackendProbe::Old {
|
|
port: 8001,
|
|
reason: "missing endpoint".to_string(),
|
|
};
|
|
let cases = [
|
|
(
|
|
stale(),
|
|
BackendProbe::Ready { port: 8000 },
|
|
DesktopPreflightDisposition::AttachedReady,
|
|
Some(8000),
|
|
None,
|
|
false,
|
|
Some(bin()),
|
|
),
|
|
(
|
|
ready(),
|
|
BackendProbe::Ready { port: 8000 },
|
|
DesktopPreflightDisposition::AttachedReady,
|
|
Some(8000),
|
|
None,
|
|
false,
|
|
Some(bin()),
|
|
),
|
|
(
|
|
ManagedProbe::Missing,
|
|
BackendProbe::Ready { port: 8000 },
|
|
DesktopPreflightDisposition::AttachedReady,
|
|
Some(8000),
|
|
None,
|
|
false,
|
|
None,
|
|
),
|
|
(
|
|
ready(),
|
|
old_backend(),
|
|
DesktopPreflightDisposition::ManagedReady,
|
|
None,
|
|
None,
|
|
false,
|
|
Some(bin()),
|
|
),
|
|
(
|
|
stale(),
|
|
old_backend(),
|
|
DesktopPreflightDisposition::ManagedStale,
|
|
None,
|
|
Some("old cli"),
|
|
release_auto_repair(),
|
|
Some(bin()),
|
|
),
|
|
(
|
|
ready(),
|
|
BackendProbe::Missing,
|
|
DesktopPreflightDisposition::ManagedReady,
|
|
None,
|
|
None,
|
|
false,
|
|
Some(bin()),
|
|
),
|
|
(
|
|
stale(),
|
|
BackendProbe::Missing,
|
|
DesktopPreflightDisposition::ManagedStale,
|
|
None,
|
|
Some("old cli"),
|
|
release_auto_repair(),
|
|
Some(bin()),
|
|
),
|
|
(
|
|
ManagedProbe::Missing,
|
|
BackendProbe::Missing,
|
|
DesktopPreflightDisposition::NotInstalled,
|
|
None,
|
|
None,
|
|
false,
|
|
None,
|
|
),
|
|
];
|
|
|
|
for (managed, backend, disposition, port, reason, can_auto_repair, managed_bin) in cases {
|
|
let result = choose_preflight(managed, backend);
|
|
assert_eq!(result.disposition, disposition);
|
|
assert_eq!(result.port, port);
|
|
assert_eq!(result.reason.as_deref(), reason);
|
|
assert_eq!(result.can_auto_repair, can_auto_repair);
|
|
assert_eq!(result.managed_bin, managed_bin);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn external_conflict_blocks_managed_flow() {
|
|
let result = choose_preflight(
|
|
ManagedProbe::Ready {
|
|
bin: PathBuf::from("/managed/unsloth"),
|
|
},
|
|
BackendProbe::ExternalConflict {
|
|
port: 8888,
|
|
reason: "same_root_external_backend_active".to_string(),
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
result.disposition,
|
|
DesktopPreflightDisposition::ExternalConflict
|
|
);
|
|
assert_eq!(result.port, Some(8888));
|
|
assert_eq!(
|
|
result.reason,
|
|
Some("same_root_external_backend_active".to_string())
|
|
);
|
|
assert!(!result.can_auto_repair);
|
|
assert_eq!(result.managed_bin, Some(PathBuf::from("/managed/unsloth")));
|
|
}
|
|
|
|
#[test]
|
|
fn mutation_blocker_blocks_ready_external_backends() {
|
|
assert_eq!(
|
|
mutation_blocker_from_probe(BackendProbe::Ready { port: 8890 }),
|
|
Some(ExternalBackendConflict {
|
|
port: 8890,
|
|
reason: "same_root_external_backend_active".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backend_version_gate_classifies_core_cases() {
|
|
for version in [
|
|
MIN_DESKTOP_BACKEND_VERSION,
|
|
"2026.5.4",
|
|
"2027.1.0",
|
|
"2026.5.3.post1",
|
|
"2026.5.3+local",
|
|
"2026.5.3.post1",
|
|
] {
|
|
assert!(backend_version_compatible(Some(version)), "{version}");
|
|
}
|
|
for (version, reason) in [
|
|
(None, "desktop_backend_version_missing"),
|
|
(Some("not-a-version"), "desktop_backend_version_invalid"),
|
|
(Some("2026.5.3.1"), "desktop_backend_version_invalid"),
|
|
(Some("2026.5.3foo"), "desktop_backend_version_invalid"),
|
|
(Some("2026.5.3.devx"), "desktop_backend_version_invalid"),
|
|
(Some("2026.5.2"), "desktop_backend_version_too_old"),
|
|
(Some("2026.5.3rc1"), "desktop_backend_version_too_old"),
|
|
(Some("2026.5.3.dev1"), "desktop_backend_version_too_old"),
|
|
] {
|
|
assert_eq!(
|
|
backend_version_stale_reason(version).as_deref(),
|
|
Some(reason)
|
|
);
|
|
}
|
|
assert_eq!(
|
|
backend_version_compatible(Some("dev")),
|
|
cfg!(debug_assertions)
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
struct FakeCli {
|
|
bin: PathBuf,
|
|
dir: PathBuf,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl Drop for FakeCli {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.dir);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn fake_cli(test_name: &str, script: &str) -> FakeCli {
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"unsloth-preflight-{test_name}-{}-{nanos}",
|
|
std::process::id()
|
|
));
|
|
fs::create_dir_all(&dir).unwrap();
|
|
let bin = dir.join("unsloth");
|
|
fs::write(&bin, script).unwrap();
|
|
let mut perms = fs::metadata(&bin).unwrap().permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&bin, perms).unwrap();
|
|
FakeCli { bin, dir }
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
static MANAGED_CAPABILITY_CACHE_TEST_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
|
|
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
|
|
|
|
#[cfg(unix)]
|
|
struct ManagedCapabilityCacheHome {
|
|
path: PathBuf,
|
|
previous: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl ManagedCapabilityCacheHome {
|
|
fn new(test_name: &str) -> Self {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"unsloth-preflight-cache-{test_name}-{}-{nanos}",
|
|
std::process::id()
|
|
));
|
|
std::fs::create_dir_all(&path).unwrap();
|
|
let previous = std::env::var_os("UNSLOTH_TEST_DESKTOP_CAPABILITY_CACHE_HOME");
|
|
std::env::set_var("UNSLOTH_TEST_DESKTOP_CAPABILITY_CACHE_HOME", &path);
|
|
Self { path, previous }
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl Drop for ManagedCapabilityCacheHome {
|
|
fn drop(&mut self) {
|
|
if let Some(previous) = &self.previous {
|
|
std::env::set_var("UNSLOTH_TEST_DESKTOP_CAPABILITY_CACHE_HOME", previous);
|
|
} else {
|
|
std::env::remove_var("UNSLOTH_TEST_DESKTOP_CAPABILITY_CACHE_HOME");
|
|
}
|
|
let _ = std::fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn managed_capability_cache_path_for_test() -> PathBuf {
|
|
std::env::var_os("UNSLOTH_TEST_DESKTOP_CAPABILITY_CACHE_HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(dirs::home_dir)
|
|
.unwrap()
|
|
.join(".unsloth")
|
|
.join("studio")
|
|
.join("desktop_capability_cache.json")
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn remove_managed_capability_cache() {
|
|
let _ = std::fs::remove_file(managed_capability_cache_path_for_test());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn managed_cli_capability_probe_classifies_core_cases() {
|
|
let _cache_guard = MANAGED_CAPABILITY_CACHE_TEST_LOCK.lock().await;
|
|
let _cache_home = ManagedCapabilityCacheHome::new("core-cases");
|
|
remove_managed_capability_cache();
|
|
|
|
for (name, script, stale_reason) in [
|
|
(
|
|
"cap-missing",
|
|
r#"#!/bin/sh
|
|
if [ "$1" = "-h" ]; then exit 0; fi
|
|
if [ "$1" = "studio" ] && [ "$2" = "provision-desktop-auth" ] && [ "$3" = "--help" ]; then exit 0; fi
|
|
exit 1
|
|
"#,
|
|
Some("desktop_capability_probe_failed"),
|
|
),
|
|
(
|
|
"cap-true-helper-missing",
|
|
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":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
|
|
"#,
|
|
None,
|
|
),
|
|
(
|
|
"cap-false-helper-ready",
|
|
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":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
|
|
exit 1
|
|
"#,
|
|
Some("cap_false"),
|
|
),
|
|
] {
|
|
let fake = fake_cli(name, script);
|
|
let bin = fake.bin.clone();
|
|
match (probe_managed_bin(bin.clone()).await, stale_reason) {
|
|
(ManagedProbe::Ready { bin: actual }, None) => assert_eq!(actual, bin),
|
|
(
|
|
ManagedProbe::Stale {
|
|
bin: actual,
|
|
reason,
|
|
},
|
|
Some(expected),
|
|
) => {
|
|
assert_eq!((actual, reason.as_str()), (bin, expected));
|
|
}
|
|
(probe, expected) => panic!("unexpected probe {probe:?}, expected {expected:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn managed_cli_capability_help_probe_runs_before_cache() {
|
|
use std::fs;
|
|
|
|
let _cache_guard = MANAGED_CAPABILITY_CACHE_TEST_LOCK.lock().await;
|
|
let _cache_home = ManagedCapabilityCacheHome::new("cache-hit");
|
|
|
|
remove_managed_capability_cache();
|
|
// `-h` always succeeds unless `modeh` exists; the desktop-capabilities
|
|
// probe always succeeds unless `modecap` exists. Toggling those lets us
|
|
// prove the ordering: -h runs on every probe (even a cache hit), while
|
|
// the heavier capability probe is skipped once the cache is warm.
|
|
let fake = fake_cli(
|
|
"cap-cache-hit",
|
|
r#"#!/bin/sh
|
|
log="$0.calls"
|
|
modeh="$0.modeh"
|
|
modecap="$0.modecap"
|
|
printf '%s\n' "$*" >> "$log"
|
|
if [ "$1" = "-h" ]; then
|
|
if [ -f "$modeh" ]; then exit 42; fi
|
|
exit 0
|
|
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":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
|
|
"#,
|
|
);
|
|
let bin = fake.bin.clone();
|
|
let calls = bin.with_extension("calls");
|
|
let modeh = bin.with_extension("modeh");
|
|
let modecap = bin.with_extension("modecap");
|
|
|
|
// Cold probe: runs -h and the capability probe, then caches the result.
|
|
assert!(matches!(
|
|
probe_managed_bin(bin.clone()).await,
|
|
ManagedProbe::Ready { .. }
|
|
));
|
|
let first_calls = fs::read_to_string(&calls).unwrap();
|
|
assert!(first_calls.contains("-h"));
|
|
assert!(first_calls.contains("studio desktop-capabilities --json"));
|
|
|
|
// Cache hit: -h still runs, but the capability probe is skipped (breaking
|
|
// it via `modecap` proves it is not invoked).
|
|
fs::write(&modecap, "broken").unwrap();
|
|
fs::write(&calls, "").unwrap();
|
|
assert!(matches!(
|
|
probe_managed_bin(bin.clone()).await,
|
|
ManagedProbe::Ready { .. }
|
|
));
|
|
assert_eq!(fs::read_to_string(&calls).unwrap(), "-h\n");
|
|
|
|
// A non-launchable CLI is caught by the -h probe even with a warm cache:
|
|
// preflight reports Stale (for repair) and never trusts the cache.
|
|
fs::write(&modeh, "broken").unwrap();
|
|
fs::write(&calls, "").unwrap();
|
|
assert!(matches!(
|
|
probe_managed_bin(bin).await,
|
|
ManagedProbe::Stale { .. }
|
|
));
|
|
assert_eq!(fs::read_to_string(&calls).unwrap(), "-h\n");
|
|
|
|
remove_managed_capability_cache();
|
|
}
|
|
|
|
const EXPECTED_ROOT_ID: &str =
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
const OTHER_ROOT_ID: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
const OWNER_TOKEN: &str = "desktop-owner-token";
|
|
|
|
fn install_test_owner() {
|
|
crate::desktop_backend_owner::install_test_owner(EXPECTED_ROOT_ID, OWNER_TOKEN);
|
|
}
|
|
|
|
fn desktop_ready_health(root_id: &str) -> String {
|
|
desktop_ready_health_with_owner(root_id, true)
|
|
}
|
|
|
|
fn desktop_owner_json(include_owner: bool) -> String {
|
|
if include_owner {
|
|
format!(
|
|
r#", "desktop_owner":{{"kind":"tauri","token_sha256":"{}"}}"#,
|
|
crate::desktop_backend_owner::token_sha256(OWNER_TOKEN)
|
|
)
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
|
|
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":2,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{root_id}"{owner}}}"#
|
|
)
|
|
}
|
|
|
|
async fn backend_server(health_body: impl Into<String>, route_status: &'static str) -> u16 {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let health_body = health_body.into();
|
|
|
|
tokio::spawn(async move {
|
|
for _ in 0..2 {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let mut buffer = [0; 2048];
|
|
let n = stream.read(&mut buffer).await.unwrap();
|
|
let request = String::from_utf8_lossy(&buffer[..n]);
|
|
let (status, body) = if request.starts_with("GET /api/health ") {
|
|
("200 OK", health_body.as_str())
|
|
} else if request.starts_with("POST /api/auth/desktop-login ") {
|
|
(route_status, "")
|
|
} else {
|
|
("404 Not Found", "")
|
|
};
|
|
let response = format!(
|
|
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
stream.write_all(response.as_bytes()).await.unwrap();
|
|
}
|
|
});
|
|
|
|
port
|
|
}
|
|
|
|
async fn probe_test_backend(
|
|
health_body: impl Into<String>,
|
|
route_status: &'static str,
|
|
) -> BackendProbe {
|
|
install_test_owner();
|
|
let port = backend_server(health_body, route_status).await;
|
|
let client = reqwest::Client::new();
|
|
let health = backend_health(&client, port).await.unwrap();
|
|
backend_desktop_auth_status(&client, port, &health, Some(EXPECTED_ROOT_ID)).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backend_health_without_desktop_capability_fields_is_still_candidate() {
|
|
let port = backend_server(
|
|
r#"{"status":"healthy","service":"Unsloth UI Backend"}"#,
|
|
"401 Unauthorized",
|
|
)
|
|
.await;
|
|
let client = reqwest::Client::new();
|
|
|
|
assert!(backend_health(&client, port).await.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
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":2,"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 { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backend_health_with_desktop_capability_fields_and_401_is_ready() {
|
|
let probe =
|
|
probe_test_backend(desktop_ready_health(EXPECTED_ROOT_ID), "401 Unauthorized").await;
|
|
|
|
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(
|
|
desktop_ready_health_with_owner(EXPECTED_ROOT_ID, false),
|
|
"401 Unauthorized",
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(probe, BackendProbe::Ready { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
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":2,"supports_desktop_auth":true,"supports_desktop_backend_ownership":true,"studio_root_id":"{EXPECTED_ROOT_ID}"}}"#,
|
|
),
|
|
"401 Unauthorized",
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(
|
|
probe,
|
|
BackendProbe::ExternalConflict {
|
|
reason,
|
|
..
|
|
} if reason == "desktop_backend_version_too_old"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backend_root_id_mismatch_is_old_before_auth_probe() {
|
|
let probe =
|
|
probe_test_backend(desktop_ready_health(OTHER_ROOT_ID), "401 Unauthorized").await;
|
|
|
|
assert!(matches!(
|
|
probe,
|
|
BackendProbe::Old {
|
|
reason,
|
|
..
|
|
} if reason == "studio_root_id_mismatch"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backend_missing_root_id_is_external_conflict_before_auth_probe() {
|
|
let probe = probe_test_backend(
|
|
r#"{"status":"healthy","service":"Unsloth UI Backend","desktop_protocol_version":1,"supports_desktop_auth":true}"#,
|
|
"401 Unauthorized",
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(
|
|
probe,
|
|
BackendProbe::ExternalConflict {
|
|
reason,
|
|
..
|
|
} if reason == "ambiguous_root_external_backend_active"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backend_expected_root_id_missing_is_external_conflict_before_auth_probe() {
|
|
install_test_owner();
|
|
let port = backend_server(desktop_ready_health(EXPECTED_ROOT_ID), "401 Unauthorized").await;
|
|
let client = reqwest::Client::new();
|
|
let health = backend_health(&client, port).await.unwrap();
|
|
|
|
assert!(matches!(
|
|
backend_desktop_auth_status(&client, port, &health, None).await,
|
|
BackendProbe::ExternalConflict {
|
|
reason,
|
|
..
|
|
} if reason == "ambiguous_root_external_backend_active"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backend_route_404_is_old() {
|
|
let probe =
|
|
probe_test_backend(desktop_ready_health(EXPECTED_ROOT_ID), "404 Not Found").await;
|
|
|
|
assert!(matches!(
|
|
probe,
|
|
BackendProbe::Old {
|
|
reason,
|
|
..
|
|
} if reason == "desktop_login_not_found"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
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":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",
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(
|
|
probe,
|
|
BackendProbe::Old {
|
|
reason,
|
|
..
|
|
} if reason == "cap_false"
|
|
));
|
|
}
|
|
}
|