Compare commits

...
Sign in to create a new pull request.

17 commits

Author SHA1 Message Date
pre-commit-ci[bot]
c6ecac97b9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 19:06:33 +00:00
Daniel Han
7e2292f2e4 Read RECORD directly, and only clear a marker this attempt created
Distribution.files drops entries whose paths no longer exist, which is
precisely the set the last change looked for, so on 3.13 (what both
desktop installers pick) the check could never fire. Verified against a
dist-info whose RECORD names a deleted file: files returned 3 of 4
entries and the damaged package read as complete. RECORD is parsed
directly now, and the test builds a real dist-info rather than a stub so
it would have caught this.

The marker is also not always ours. A retry after an interrupted install
finds the earlier one still there, and a repair that then fails to spawn
was deleting it, losing the classification the next launch depends on.
Creation reports whether it created the file; the pre-spawn and elevation
paths clear only then, and success still clears unconditionally.
2026-07-27 19:05:48 +00:00
Daniel Han
c1e13d41c8 Bound the drain, stat the files, and check the generation
A descendant that inherits stdout keeps the pipe open after the CLI leader
exits, so the drain sat outside the timeout and could wedge preflight for
good. Both probes now share one deadline across the wait and the drain.

Stat every file RECORD lists, not just the top-level names. An interrupted
replace can recreate a package directory and stop part-way through filling
it, which the directory check read as complete. 6656 files across
studio.txt's closure cost 158ms, and __pycache__ is skipped: deleting it
is not damage.

Cleanup after an exit can take seconds, so a start in that window already
owns the backend by the time the crash event fires. The frontend sets
"Server stopped unexpectedly" on it unconditionally, which showed a
healthy replacement as failed.
2026-07-27 18:35:15 +00:00
pre-commit-ci[bot]
6ee7985e85 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 18:09:37 +00:00
Daniel Han
5c1530186a Merge main into fix/studio-install-readiness 2026-07-27 18:06:00 +00:00
Daniel Han
984cafc1ea Close the gaps three probes left open
The owner check and the unlink were separate steps, so a start landing
between them still lost its metadata to the backend it replaced. Both
now run under the same lock as the write, which is the only writer.

Probe output is drained into a bounded tail rather than kept whole. The
binary being probed is a possibly damaged install by construction, so an
import stuck printing is exactly the input this path can meet, and the
payload is the last JSON line either way.

RECORD can outlive the files it lists: an interrupted replace deletes the
package and leaves the metadata behind. The top-level names it records
are stat'd now. 298 distributions in a working venv, none flagged, 44ms.
2026-07-27 18:02:06 +00:00
Daniel Han
508d3720c4 Tighten comments across the install readiness changes 2026-07-27 17:54:28 +00:00
pre-commit-ci[bot]
ddb2c1d397 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 17:44:16 +00:00
Daniel Han
aaa00ae8d2 Answer four ways an install can look ready when it is not
Roots before the walk: reached as someone else's dependency first, a
studio.txt line marked itself seen and skipped its own pin. datasets asks
for huggingface-hub>=0.25,<2 and studio.txt pins ==0.36.2, so the pin
never got to decide.

A start rejected because one is already running is not a spawn failure.
The loser was clearing the winner's marker while pip was still inside the
venv, which is the one signal an interrupted install leaves behind.

A CLI too old for desktop-runtime-check is the CLI those interrupted
installs shipped with, and -h passes without touching the backend, so
accepting it left exactly those users on the crash path. It is stale for
its own reason now; the update installs one that can answer.

Owner metadata is removed only while it is still ours. Cleanup after an
exit can spend seconds terminating descendants, and a start in that
window has already written the next backend's file to the same path.
2026-07-27 17:43:27 +00:00
pre-commit-ci[bot]
22c6c277ba [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 17:20:41 +00:00
Daniel Han
e15ce94398 Check what the backend actually needs to start, not just studio.txt
The readiness probe walked only the requirement file's own lines, so a
package that reaches the venv as someone else's dependency read as
present. starlette is the live case: studio/backend/main.py imports it,
FastAPI is what pulls it in, and run.py imports main inside run_server,
so the probe passed and the server died on launch.

Walk the installed metadata instead, markers applied and extras skipped.
Version bounds stay limited to studio.txt's own pins: install_python_stack
installs with --no-deps, so a transitive bound can read unsatisfied in a
venv that works, and reporting it would send repair round a loop it
cannot break. 56 distributions on a full closure, 46ms, no imports.
2026-07-27 17:19:52 +00:00
Daniel Han
f749c90d87 Keep the install marker once the script has touched the venv
install.sh decides on elevation at :1931, before it creates the venv at
:2120, so only a spawn failure and that exit prove pip never ran. A
nonzero exit or a failed wait can land part-way through replacing a
package, and the runtime probe does not reach transitive dependencies,
so clearing the marker there throws away the only remaining signal.

Also treat a hung CLI as stale on its own timeout rather than falling
through to two more probes, and let repair report an environment the
backend rejects instead of reinstalling over a healthy tree.
2026-07-27 17:19:52 +00:00
pre-commit-ci[bot]
4482f8b4f1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-27 16:25:56 +00:00
Daniel Han
8f343038ef 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.
2026-07-27 16:25:07 +00:00
Daniel Han
b40d422654 Clear the install marker on elevation failures too for PR #7490
The code 2 exit keeps the marker for the elevated run that follows, but every
terminal failure of that run, a denied prompt or a failed apt-get included,
returned without clearing it. That pinned a working install into repair the
same way a failed update did. finish_elevation_failure is the one path all of
them take.
2026-07-27 13:22:30 +00:00
Daniel Han
7b22a8584f Fix the readiness probe deadlock and marker lifecycle for PR #7490
The runtime probe waits on child exit with stdout piped and undrained, so a
CLI printing more than the pipe buffer holds blocks mid-write and the probe
times out. It imports the whole backend, which is exactly what emits stray
stdout, so a healthy install gets reported broken and sent to repair after a
10s stall on every launch. Drain concurrently instead, here and in the
desktop-capabilities probe next to it.

The install marker was cleared only on success, so a failed update, or a
cancelled elevation prompt, left it behind and pinned a working install into
repair it may not be able to finish offline. Clear it on every terminal
outcome and treat marker bookkeeping as best effort rather than failing the
install over it.

Skip pip flag lines in studio.txt: an unparseable line reported the install
broken, and repair reinstalls the same file, so that never cleared. Allow
prereleases against a floor for the same reason.
2026-07-27 11:44:37 +00:00
Wasim Yousef Said
a94c5b061b Fix interrupted Studio install recovery 2026-07-27 11:33:38 +02:00
9 changed files with 1508 additions and 197 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

@ -18,6 +18,20 @@ async fn managed_install_ready_after_repair() -> bool {
crate::preflight::managed_install_ready().await
}
/// The update can install the newer CLI and only then reach a rejected
/// environment value. No reinstall changes that, so report it instead.
fn unrepairable_after_update(reason: Option<&str>) -> Option<String> {
match reason {
Some(crate::preflight::STUDIO_RUNTIME_STARTUP_FAILED) => Some(
"Unsloth is installed, but the backend refuses to start with the current \
environment settings (for example UNSLOTH_CPU_THREADS). Fix or unset them, \
then try again."
.to_string(),
),
_ => None,
}
}
fn should_emit_repair_failed(msg: &str) -> bool {
!msg.contains("NEEDS_ELEVATION")
}
@ -532,39 +546,37 @@ pub async fn start_managed_repair(
let repair_group_id = install::take_pending_repair_group_for_resume(&install_state)
.unwrap_or_else(|| diagnostics::begin_repair_group(&diagnostics_state));
let _ = app.emit("repair-progress", "Updating existing Unsloth install...");
let update_app = app.clone();
let update_state = update_state.inner().clone();
let update_diagnostics = diagnostics_state.clone();
let update_repair_group_id = repair_group_id.clone();
let update_result = tokio::task::spawn_blocking(move || {
update::run_backend_update_for_repair(
update_app,
update_state,
update_diagnostics,
update_repair_group_id,
)
})
.await
.map_err(|e| format!("Repair update task panicked: {e}"))?;
if install::managed_install_in_progress() {
info!("Interrupted installation detected; skipping incremental repair update");
let _ = app.emit(
"repair-progress",
"Previous installation was interrupted. Running bundled installer...",
);
} else {
let _ = app.emit("repair-progress", "Updating existing Unsloth install...");
let update_app = app.clone();
let update_state = update_state.inner().clone();
let update_diagnostics = diagnostics_state.clone();
let update_repair_group_id = repair_group_id.clone();
let update_result = tokio::task::spawn_blocking(move || {
update::run_backend_update_for_repair(
update_app,
update_state,
update_diagnostics,
update_repair_group_id,
)
})
.await
.map_err(|e| format!("Repair update task panicked: {e}"))?;
match update_result {
Ok(()) if managed_install_ready_after_repair().await => {
info!("Managed repair complete after update");
diagnostics::finish_repair_group(&diagnostics_state, &repair_group_id, "success", None);
let _ = app.emit("repair-complete", ());
return Ok(());
}
Ok(()) => {
warn!("Managed repair update finished, but preflight is still not ready; falling back to installer");
let _ = app.emit(
"repair-progress",
"Update finished, but Unsloth is still not ready. Running bundled installer...",
);
}
Err(msg) => {
if msg.to_ascii_lowercase().contains("already running") {
error!("Managed repair update conflict: {}", msg);
let post_update = if matches!(update_result, Ok(())) {
Some(crate::preflight::managed_install_state().await)
} else {
None
};
if let Some(Err(reason)) = post_update.as_ref() {
if let Some(msg) = unrepairable_after_update(reason.as_deref()) {
warn!("Managed repair stopping: {}", msg);
diagnostics::finish_repair_group(
&diagnostics_state,
&repair_group_id,
@ -574,15 +586,49 @@ pub async fn start_managed_repair(
let _ = app.emit("repair-failed", &msg);
return Err(msg);
}
}
warn!(
"Managed repair update failed, falling back to bundled installer: {}",
msg
);
let _ = app.emit(
"repair-progress",
"Update failed. Running bundled installer...",
);
match update_result {
Ok(()) if matches!(post_update, Some(Ok(()))) => {
info!("Managed repair complete after update");
diagnostics::finish_repair_group(
&diagnostics_state,
&repair_group_id,
"success",
None,
);
let _ = app.emit("repair-complete", ());
return Ok(());
}
Ok(()) => {
warn!("Managed repair update finished, but preflight is still not ready; falling back to installer");
let _ = app.emit(
"repair-progress",
"Update finished, but Unsloth is still not ready. Running bundled installer...",
);
}
Err(msg) => {
if msg.to_ascii_lowercase().contains("already running") {
error!("Managed repair update conflict: {}", msg);
diagnostics::finish_repair_group(
&diagnostics_state,
&repair_group_id,
"failed",
Some(msg.clone()),
);
let _ = app.emit("repair-failed", &msg);
return Err(msg);
}
warn!(
"Managed repair update failed, falling back to bundled installer: {}",
msg
);
let _ = app.emit(
"repair-progress",
"Update failed. Running bundled installer...",
);
}
}
}
@ -744,6 +790,19 @@ mod tests {
.unwrap_err();
assert!(error.contains("Directory does not exist"));
}
#[test]
fn rejected_settings_stop_repair_instead_of_reinstalling() {
// This arm is the one that would otherwise reinstall over a healthy tree.
let msg =
super::unrepairable_after_update(Some(crate::preflight::STUDIO_RUNTIME_STARTUP_FAILED))
.expect("a rejected setting must stop repair");
assert!(msg.contains("UNSLOTH_CPU_THREADS"));
assert!(
super::unrepairable_after_update(Some("studio_runtime_missing_dependency")).is_none()
);
assert!(super::unrepairable_after_update(None).is_none());
}
#[test]
fn repair_elevation_is_not_a_terminal_repair_failure() {
assert!(!super::should_emit_repair_failed("NEEDS_ELEVATION"));

View file

@ -265,7 +265,22 @@ impl BackendOwnerState {
self.write()
}
/// Only while the file is still ours. Cleanup after an exit can take seconds,
/// and a start in that window already wrote the next metadata to this path.
pub(crate) fn remove(self) {
// Held across the check and the unlink: a start that lands between the
// two would otherwise have its file deleted by the backend it replaced.
let _guard = lock_metadata();
if let Ok(Some(on_disk)) = read_metadata(&self.path) {
if on_disk.token_sha256 != self.metadata.token_sha256 {
warn!(
"Leaving desktop backend owner metadata at {}: a newer backend owns it",
self.path.display()
);
return;
}
}
// Unreadable counts as ours, or it would outlive every backend.
remove_metadata_file(&self.path);
}
@ -304,7 +319,16 @@ impl BackendOwnerState {
}
}
/// Serializes activation against cleanup. Both run in this process, and the
/// check in remove() only holds if no write can land inside it.
static METADATA_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_metadata() -> std::sync::MutexGuard<'static, ()> {
METADATA_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn write_metadata(path: &Path, metadata: &DesktopBackendMetadata) -> Result<(), String> {
let _guard = lock_metadata();
let parent = path
.parent()
.ok_or_else(|| "desktop owner metadata path has no parent".to_string())?;
@ -978,6 +1002,32 @@ mod tests {
dir.join("desktop_backend.json")
}
#[test]
fn a_replaced_owner_file_survives_the_previous_backend_cleanup() {
let path = temp_metadata_path("replaced-owner");
let exited = BackendOwnerState::from_metadata(path.clone(), metadata(1, Some(8888)));
let mut current = metadata(2, Some(8899));
current.token = "next-backend-token".to_string();
current.token_sha256 = token_sha256("next-backend-token");
write_metadata(&path, &current).unwrap();
exited.remove();
let left = read_metadata(&path).unwrap().unwrap();
assert_eq!(left.token_sha256, token_sha256("next-backend-token"));
}
#[test]
fn an_owner_still_holding_the_file_removes_it() {
let path = temp_metadata_path("own-owner");
let owner = BackendOwnerState::from_metadata(path.clone(), metadata(1, Some(8888)));
write_metadata(&path, &metadata(1, Some(8888))).unwrap();
owner.remove();
assert!(read_metadata(&path).unwrap().is_none());
}
fn closed_port() -> u16 {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();

View file

@ -7,6 +7,8 @@ use std::process::{Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Manager};
const INSTALL_IN_PROGRESS_MARKER: &str = ".desktop-install-in-progress";
// ── Types ──
pub struct InstallProcess {
@ -38,6 +40,84 @@ pub fn new_install_state() -> InstallState {
use crate::process::trim_line_endings;
fn install_in_progress_marker_path() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
Ok(home
.join(".unsloth")
.join("studio")
.join(INSTALL_IN_PROGRESS_MARKER))
}
pub(crate) fn managed_install_in_progress() -> bool {
install_in_progress_marker_path()
.map(|path| path.is_file())
.unwrap_or(false)
}
/// Whether this process created the marker rather than finding one an earlier
/// interrupted install left. Clearing someone else's drops the only signal that
/// its venv is half-written. Process-wide because the marker is one file.
static MARKER_CREATED_HERE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn create_install_in_progress_marker() -> Result<(), String> {
let path = install_in_progress_marker_path()?;
let created = create_install_in_progress_marker_at(&path)?;
MARKER_CREATED_HERE.store(created, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
/// Clear only a marker this attempt created. Used where the venv was never
/// touched: a failed spawn, and the elevation exits.
fn clear_own_install_marker() {
if MARKER_CREATED_HERE.load(std::sync::atomic::Ordering::Relaxed) {
clear_install_marker_best_effort();
}
}
/// Ok(true) when this call created the file, Ok(false) when one was there.
fn create_install_in_progress_marker_at(path: &Path) -> Result<bool, String> {
let parent = path
.parent()
.ok_or_else(|| format!("Invalid install marker path: {}", path.display()))?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
Err(error) => Err(format!("Failed to create {}: {}", path.display(), error)),
}
}
fn clear_install_in_progress_marker() -> Result<(), String> {
let path = install_in_progress_marker_path()?;
clear_install_in_progress_marker_at(&path)
}
/// Clear only where the script provably never touched the venv: it failed to
/// spawn, or it asked for elevation, which install.sh decides (:1931) before it
/// creates the venv (:2120). Later on any failure can leave pip part-way
/// through, and the marker is the only signal. Never fatal: it costs a fast path.
fn clear_install_marker_best_effort() {
if let Err(msg) = clear_install_in_progress_marker() {
warn!("[install] {}", msg);
}
MARKER_CREATED_HERE.store(false, std::sync::atomic::Ordering::Relaxed);
}
fn clear_install_in_progress_marker_at(path: &Path) -> Result<(), String> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("Failed to remove {}: {}", path.display(), error)),
}
}
// ── Script Resolution ──
/// Returns (script_path, args) depending on dev vs production mode.
@ -129,6 +209,10 @@ fn emit_complete(app: &AppHandle) {
// ── Spawn ──
/// A start rejected because one is already running, kept apart from a real
/// spawn failure so the loser of the race leaves the winner's marker alone.
const INSTALL_ALREADY_RUNNING: &str = "Installation is already running.";
/// Spawns the install script in a process group.
/// Returns (stdout, stderr) handles for streaming.
/// The GroupChild is stored in state so stop_install() can kill the entire tree.
@ -145,7 +229,7 @@ fn spawn_script(
> {
let mut install = state.lock().map_err(|e| e.to_string())?;
if install.child.is_some() {
return Err("Installation is already running.".to_string());
return Err(INSTALL_ALREADY_RUNNING.to_string());
}
install.intentional_stop = false;
install.needed_packages.clear();
@ -443,9 +527,19 @@ fn run_install_with_event_mode(
&format!("Using script: {}", script.display()),
);
if let Err(msg) = create_install_in_progress_marker() {
warn!("[install] {}", msg);
}
let (stdout, stderr) = match spawn_script(&script, &args, &state) {
Ok(handles) => handles,
Err(msg) => {
// Nothing ran, so nothing is half-installed. Unless the marker is
// not ours: another installer owns it, or an earlier interrupted
// one left it, and either way its signal is not ours to drop.
if msg != INSTALL_ALREADY_RUNNING {
clear_own_install_marker();
}
diagnostics::finish_attempt(
&diagnostics,
&attempt,
@ -475,6 +569,7 @@ fn run_install_with_event_mode(
match result {
Ok((status, _)) if status.success() => {
clear_install_marker_best_effort();
diagnostics::finish_attempt(
&diagnostics,
&attempt,
@ -508,6 +603,7 @@ fn run_install_with_event_mode(
let _ = app.emit(event_mode.needs_elevation_event(), &packages);
Err("NEEDS_ELEVATION".to_string())
} else {
// Keep the marker: the script ran, so pip may be part-way through.
let msg = format!("Installer exited with code {}", code);
diagnostics::finish_attempt(
&diagnostics,
@ -530,6 +626,7 @@ fn run_install_with_event_mode(
Err(msg)
}
Err(msg) => {
// Same: the wait failed but the script was already running.
diagnostics::finish_attempt(&diagnostics, &attempt, None, false, Some(msg.clone()));
clear_current_attempt(&state);
if event_mode.emit_terminal_events() {
@ -569,6 +666,8 @@ pub fn record_pending_elevation_canceled(
let Some(attempt) = attempt else {
return false;
};
// The resumed run the elevation exit left the marker for is not happening.
clear_own_install_marker();
diagnostics::finish_attempt(
diagnostics,
&attempt,
@ -841,6 +940,8 @@ fn finish_elevation_failure(
exit_status: Option<String>,
message: String,
) {
// Terminal, like a cancelled prompt: the resumed run is not happening.
clear_own_install_marker();
if let Some(attempt) = attempt {
diagnostics::finish_attempt(
diagnostics,
@ -877,6 +978,31 @@ fn capped_output_text(bytes: &[u8]) -> String {
mod tests {
use super::*;
#[test]
fn install_marker_persists_until_explicit_success_cleanup() {
let directory = std::env::temp_dir().join(format!(
"unsloth-install-marker-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let marker = directory.join(INSTALL_IN_PROGRESS_MARKER);
assert!(create_install_in_progress_marker_at(&marker).unwrap());
assert!(marker.is_file());
// A second attempt finds the first one's marker and does not own it.
assert!(!create_install_in_progress_marker_at(&marker).unwrap());
assert!(marker.is_file());
clear_install_in_progress_marker_at(&marker).unwrap();
assert!(!marker.exists());
clear_install_in_progress_marker_at(&marker).unwrap();
let _ = std::fs::remove_dir_all(directory);
}
#[test]
fn elevated_output_cap_is_utf8_boundary_safe() {
let text = "é".repeat(40_000);

View file

@ -8,8 +8,8 @@ use crate::desktop_backend_owner::{
};
use backend::probe_existing_backends;
use log::warn;
pub use managed::managed_install_ready;
use managed::probe_managed_install;
pub use managed::{managed_install_ready, managed_install_state};
use std::path::PathBuf;
use types::{BackendProbe, ManagedProbe};
pub use types::{DesktopPreflightDisposition, DesktopPreflightResult, ExternalBackendConflict};
@ -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 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.
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,39 @@ mod tests {
}
}
#[test]
fn rejected_backend_settings_do_not_auto_repair() {
assert!(!stale_reason_is_repairable(STUDIO_RUNTIME_STARTUP_FAILED));
for reason in [
"studio_runtime_missing_dependency",
"studio_runtime_import_failed",
"studio_runtime_check_unsupported",
"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(
@ -566,16 +609,33 @@ mod tests {
(
"cap-missing",
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-runtime-check" ] && [ "$3" = "--json" ]; then
printf '{"runtime_ready":true}'
exit 0
fi
if [ "$1" = "studio" ] && [ "$2" = "provision-desktop-auth" ] && [ "$3" = "--help" ]; then exit 0; fi
exit 1
"#,
Some("desktop_capability_probe_failed"),
),
(
"runtime-missing-dependency",
r#"#!/bin/sh
if [ "$1" = "studio" ] && [ "$2" = "desktop-runtime-check" ] && [ "$3" = "--json" ]; then
printf '{"runtime_ready":false,"reason":"missing_dependency","module":"structlog"}'
exit 1
fi
exit 1
"#,
Some("studio_runtime_missing_dependency"),
),
(
"cap-true-helper-missing",
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-runtime-check" ] && [ "$3" = "--json" ]; then
printf '{"runtime_ready":true}'
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"}'
exit 0
@ -587,7 +647,10 @@ exit 1
(
"cap-false-helper-ready",
r#"#!/bin/sh
if [ "$1" = "-h" ]; then exit 0; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-runtime-check" ] && [ "$3" = "--json" ]; then
printf '{"runtime_ready":true}'
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"}'
exit 0
@ -618,26 +681,147 @@ exit 1
#[cfg(unix)]
#[tokio::test]
async fn managed_cli_capability_help_probe_runs_before_cache() {
async fn a_hung_cli_is_stale_without_running_the_legacy_fallback() {
// An older CLI rejects the unknown command at once, so only a broken one
// reaches the timeout, and two more 10s probes would treble the wait.
let _cache_guard = MANAGED_CAPABILITY_CACHE_TEST_LOCK.lock().await;
let _cache_home = ManagedCapabilityCacheHome::new("runtime-hang");
remove_managed_capability_cache();
let fake = fake_cli(
"runtime-hang",
r#"#!/bin/sh
sleep 120
"#,
);
let started = std::time::Instant::now();
let probe = probe_managed_bin(fake.bin.clone()).await;
assert!(
matches!(&probe, ManagedProbe::Stale { reason, .. } if reason == "studio_runtime_probe_timeout"),
"a hung CLI must be stale on the timeout, got {probe:?}"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(20),
"the legacy fallback ran after the timeout, tripling the wait"
);
remove_managed_capability_cache();
}
#[cfg(unix)]
#[tokio::test]
async fn managed_runtime_probe_survives_more_stdout_than_the_pipe_holds() {
// The probe imports the whole backend, so output can exceed the 64 KiB
// pipe buffer, and waiting before draining wedges the child mid-write.
// This CLI writes ~1 MiB before the payload, so the drain both keeps up
// and keeps only the tail, which still has to contain the payload.
let _cache_guard = MANAGED_CAPABILITY_CACHE_TEST_LOCK.lock().await;
let _cache_home = ManagedCapabilityCacheHome::new("runtime-noisy");
remove_managed_capability_cache();
let fake = fake_cli(
"runtime-noisy",
r#"#!/bin/sh
if [ "$1" = "studio" ] && [ "$2" = "desktop-runtime-check" ] && [ "$3" = "--json" ]; then
awk 'BEGIN { while (i++ < 40000) print "import chatter on stdout" }'
printf '{"runtime_ready":true}\n'
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"}'
exit 0
fi
exit 1
"#,
);
let started = std::time::Instant::now();
let probe = probe_managed_bin(fake.bin.clone()).await;
assert!(
matches!(probe, ManagedProbe::Ready { .. }),
"noisy but healthy CLI must probe Ready, got {probe:?}"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(9),
"probe hit the 10s timeout instead of draining stdout"
);
remove_managed_capability_cache();
}
#[cfg(unix)]
#[tokio::test]
async fn a_cli_predating_the_runtime_check_is_stale_for_its_own_reason() {
// Every published CLI satisfies MIN_DESKTOP_BACKEND_VERSION but has no
// `desktop-runtime-check`, so click exits 2 with an empty stdout. It is
// what the interrupted installs shipped with, and -h passes without
// touching the backend, so keep it apart from a CLI that cannot answer.
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::Stale { reason, .. }
if reason == "studio_runtime_check_unsupported"),
"CLI without the runtime-check subcommand must be stale for it, got {probe:?}"
);
// And a CLI that cannot launch at all keeps the plain probe failure.
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() {
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.
// Runtime readiness runs on every probe; the 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"
moderuntime="$0.moderuntime"
modecap="$0.modecap"
printf '%s\n' "$*" >> "$log"
if [ "$1" = "-h" ]; then
if [ -f "$modeh" ]; then exit 42; fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-runtime-check" ] && [ "$3" = "--json" ]; then
if [ -f "$moderuntime" ]; then
printf '{"runtime_ready":false,"reason":"missing_dependency","module":"structlog"}'
exit 42
fi
printf '{"runtime_ready":true}'
exit 0
fi
if [ "$1" = "studio" ] && [ "$2" = "desktop-capabilities" ] && [ "$3" = "--json" ]; then
@ -650,37 +834,42 @@ exit 1
);
let bin = fake.bin.clone();
let calls = bin.with_extension("calls");
let modeh = bin.with_extension("modeh");
let moderuntime = bin.with_extension("moderuntime");
let modecap = bin.with_extension("modecap");
// Cold probe: runs -h and the capability probe, then caches the result.
// Cold probe runs both checks, then caches the static capability.
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-runtime-check --json"));
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).
// Cache hit still checks the runtime, but skips the capability probe.
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");
assert_eq!(
fs::read_to_string(&calls).unwrap(),
"studio desktop-runtime-check --json\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();
// A broken runtime is caught even with a warm capability cache.
fs::write(&moderuntime, "broken").unwrap();
fs::write(&calls, "").unwrap();
assert!(matches!(
probe_managed_bin(bin).await,
ManagedProbe::Stale { .. }
ManagedProbe::Stale { reason, .. }
if reason == "studio_runtime_missing_dependency"
));
assert_eq!(fs::read_to_string(&calls).unwrap(), "-h\n");
assert_eq!(
fs::read_to_string(&calls).unwrap(),
"studio desktop-runtime-check --json\n"
);
remove_managed_capability_cache();
}

View file

@ -36,6 +36,13 @@ struct DesktopCapability {
version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DesktopRuntimeCheck {
runtime_ready: bool,
reason: Option<String>,
module: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct ManagedCapabilityCache {
schema: u16,
@ -264,7 +271,6 @@ fn write_cached_capability(fingerprint: &ManagedBinFingerprint, capability: &Des
}
async fn run_cli_probe(bin: &Path, args: &[&str]) -> bool {
let started = Instant::now();
let mut cmd = Command::new(bin);
cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
@ -275,6 +281,89 @@ async fn run_cli_probe(bin: &Path, args: &[&str]) -> bool {
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
}
}
}
/// Room for the payload and what preceded it. Bounded because the binary being
/// probed is a possibly damaged install, and keeping every byte lets an import
/// stuck printing grow this buffer for the whole timeout.
const PROBE_TAIL_BYTES: usize = 64 * 1024;
/// Budget for a whole probe, the drain included. A descendant that inherited
/// stdout keeps the pipe open after the leader exits, so a drain outside the
/// timeout would wedge preflight for good.
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
/// Drained while the wait runs: the backend import can print more than the pipe
/// holds, and waiting first deadlocks, timing out a healthy install into repair.
fn drain_probe_tail(mut stdout: tokio::process::ChildStdout) -> tokio::task::JoinHandle<Vec<u8>> {
tokio::spawn(async move {
let mut tail = Vec::new();
let mut chunk = [0u8; 8192];
while let Ok(read) = stdout.read(&mut chunk).await {
if read == 0 {
break;
}
tail.extend_from_slice(&chunk[..read]);
if tail.len() > PROBE_TAIL_BYTES {
tail.drain(..tail.len() - PROBE_TAIL_BYTES);
}
}
tail
})
}
/// The probe could not answer: it would not run, or exited without a payload.
/// Distinct from a CLI that is simply too old to know the command.
const RUNTIME_PROBE_FAILED: &str = "studio_runtime_probe_failed";
/// A hung binary, kept out of the fallback below: an older CLI rejects the
/// unknown command at once, so only a broken one reaches the timeout.
const RUNTIME_PROBE_TIMEOUT: &str = "studio_runtime_probe_timeout";
/// A CLI predating the runtime probe. Repairable: the update installs one that
/// can answer, so the next preflight checks the venv for real.
const RUNTIME_CHECK_UNSUPPORTED: &str = "studio_runtime_check_unsupported";
/// True when the CLI is older than `desktop-runtime-check` yet still launches.
/// Its usage error is indistinguishable from a crashed probe, so `--help`
/// resolves the command without running it.
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);
cmd.args(["studio", "desktop-runtime-check", "--json"])
.stdout(Stdio::piped())
.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");
}
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
// probe subprocesses must follow the same isolation as process.rs.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
@ -288,28 +377,73 @@ async fn run_cli_probe(bin: &Path, args: &[&str]) -> bool {
let Ok(mut child) = cmd.spawn() else {
info!(
"Managed preflight probe {:?} failed to spawn in {}ms",
args,
"Managed runtime probe failed to spawn in {}ms",
started.elapsed().as_millis()
);
return false;
return Err(RUNTIME_PROBE_FAILED.to_string());
};
let Some(stdout) = child.stdout.take() else {
return Err(RUNTIME_PROBE_FAILED.to_string());
};
let ok = match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) => status.success(),
let mut reader = drain_probe_tail(stdout);
let deadline = tokio::time::Instant::now() + PROBE_TIMEOUT;
let status = match tokio::time::timeout_at(deadline, child.wait()).await {
Ok(Ok(status)) => status,
_ => {
let _ = child.kill().await;
let _ = child.wait().await;
false
reader.abort();
info!(
"Managed runtime probe timed out in {}ms",
started.elapsed().as_millis()
);
return Err(RUNTIME_PROBE_TIMEOUT.to_string());
}
};
let output = match tokio::time::timeout_at(deadline, &mut reader).await {
Ok(Ok(output)) => output,
Ok(Err(_)) => return Err(RUNTIME_PROBE_FAILED.to_string()),
Err(_) => {
reader.abort();
info!(
"Managed runtime probe left stdout open in {}ms",
started.elapsed().as_millis()
);
return Err(RUNTIME_PROBE_TIMEOUT.to_string());
}
};
let payload = String::from_utf8_lossy(&output)
.lines()
.rev()
.find_map(|line| serde_json::from_str::<DesktopRuntimeCheck>(line).ok());
let result = match payload {
Some(payload) if status.success() && payload.runtime_ready => Ok(()),
Some(payload) => {
let reason = match payload.reason.as_deref() {
Some("missing_dependency") => {
info!(
"Managed runtime probe missing dependency module={}",
payload.module.as_deref().unwrap_or("unknown")
);
"studio_runtime_missing_dependency"
}
Some("backend_import_failed") => "studio_runtime_import_failed",
Some("backend_startup_failed") => super::STUDIO_RUNTIME_STARTUP_FAILED,
_ => RUNTIME_PROBE_FAILED,
};
Err(reason.to_string())
}
None => Err(RUNTIME_PROBE_FAILED.to_string()),
};
info!(
"Managed preflight probe {:?} finished ok={} in {}ms",
args,
ok,
"Managed runtime probe finished ok={} in {}ms",
result.is_ok(),
started.elapsed().as_millis()
);
ok
result
}
async fn probe_cli_capability(bin: &Path) -> Option<DesktopCapability> {
@ -344,15 +478,19 @@ async fn probe_cli_capability(bin: &Path) -> Option<DesktopCapability> {
);
return None;
};
let Some(mut stdout) = child.stdout.take() else {
let Some(stdout) = child.stdout.take() else {
return None;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
let mut reader = drain_probe_tail(stdout);
let deadline = tokio::time::Instant::now() + PROBE_TIMEOUT;
match tokio::time::timeout_at(deadline, child.wait()).await {
Ok(Ok(status)) if status.success() => {}
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
reader.abort();
info!(
"Managed desktop-capabilities probe timed out in {}ms",
started.elapsed().as_millis()
@ -360,6 +498,7 @@ async fn probe_cli_capability(bin: &Path) -> Option<DesktopCapability> {
return None;
}
_ => {
reader.abort();
info!(
"Managed desktop-capabilities probe exited unsuccessfully in {}ms",
started.elapsed().as_millis()
@ -368,10 +507,10 @@ async fn probe_cli_capability(bin: &Path) -> Option<DesktopCapability> {
}
}
let mut output = Vec::new();
if stdout.read_to_end(&mut output).await.is_err() {
let Ok(Ok(output)) = tokio::time::timeout_at(deadline, &mut reader).await else {
reader.abort();
return None;
}
};
let capability = serde_json::from_slice::<DesktopCapability>(&output).ok();
info!(
@ -410,22 +549,23 @@ fn desktop_capability_ready(capability: &DesktopCapability) -> bool {
pub(super) async fn probe_managed_bin(bin: PathBuf) -> ManagedProbe {
let started = Instant::now();
// Always verify the managed CLI actually launches before trusting the cache.
// A matching capability fingerprint does not prove the binary can still run:
// its venv interpreter or a runtime dependency can be broken while the
// path/size/mtime/markers are unchanged, so the -h probe runs first and a
// non-launchable install is reported Stale for repair. The capability cache
// below still skips the heavier desktop-capabilities probe on a hit.
if !run_cli_probe(&bin, &["-h"]).await {
// Runtime readiness is intentionally uncached: a matching capability
// fingerprint proves protocol compatibility, not that the venv is complete.
if let Err(reason) = probe_cli_runtime(&bin).await {
// A CLI too old for this subcommand is what the interrupted installs
// shipped with, and -h passes without touching the backend.
let reason = if reason == RUNTIME_PROBE_FAILED && predates_runtime_check(&bin).await {
RUNTIME_CHECK_UNSUPPORTED.to_string()
} else {
reason
};
info!(
"Managed preflight: cli unusable for {:?} in {}ms",
"Managed preflight: runtime unusable for {:?} reason={} in {}ms",
bin,
reason,
started.elapsed().as_millis()
);
return ManagedProbe::Stale {
bin,
reason: "cli_unusable".to_string(),
};
return ManagedProbe::Stale { bin, reason };
}
if let Some(fingerprint) = managed_bin_fingerprint(&bin) {
@ -478,6 +618,16 @@ pub(super) async fn probe_managed_bin(bin: PathBuf) -> ManagedProbe {
pub(super) async fn probe_managed_install() -> ManagedProbe {
let started = Instant::now();
let result = match crate::process::find_unsloth_binary() {
Some(bin) if crate::install::managed_install_in_progress() => {
info!(
"Managed preflight: interrupted desktop installation marker found for {:?}",
bin
);
ManagedProbe::Stale {
bin,
reason: "install_incomplete".to_string(),
}
}
Some(bin) => probe_managed_bin(bin).await,
None => ManagedProbe::Missing,
};
@ -492,3 +642,13 @@ pub(super) async fn probe_managed_install() -> ManagedProbe {
pub async fn managed_install_ready() -> bool {
matches!(probe_managed_install().await, ManagedProbe::Ready { .. })
}
/// Ready, or the reason it is not, so repair can tell an unrepairable cause
/// apart from a stale install.
pub async fn managed_install_state() -> Result<(), Option<String>> {
match probe_managed_install().await {
ManagedProbe::Ready { .. } => Ok(()),
ManagedProbe::Stale { reason, .. } => Err(Some(reason)),
_ => Err(None),
}
}

View file

@ -90,13 +90,18 @@ impl OwnedBackendHandle {
}
}
fn remove_owner_metadata(self) {
fn cleanup_after_exit(self) {
match self {
Self::Spawned {
owner: Some(owner), ..
mut child,
owner,
pid,
..
} => {
terminate_exited_backend_tree(pid, &mut child);
remove_optional_owner(owner);
}
| Self::Adopted { owner, .. } => owner.remove(),
Self::Spawned { owner: None, .. } => {}
Self::Adopted { owner, .. } => owner.remove(),
}
}
}
@ -316,6 +321,163 @@ pub(crate) fn trim_line_endings(bytes: &[u8]) -> &[u8] {
&bytes[..end]
}
#[cfg(unix)]
fn unix_process_group_exists(pid: u32) -> bool {
if pid > i32::MAX as u32 {
return false;
}
let result = unsafe { libc::kill(-(pid as i32), 0) };
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(unix)]
fn terminate_exited_backend_tree(pid: u32, _child: &mut Box<dyn ChildWrapper + Send>) {
if !unix_process_group_exists(pid) {
return;
}
info!("Stopping surviving backend process group (pid {})", pid);
unsafe {
libc::kill(-(pid as i32), libc::SIGTERM);
}
for _ in 0..50 {
if !unix_process_group_exists(pid) {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
warn!(
"Backend descendants did not exit gracefully, force killing group (pid {})",
pid
);
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
#[cfg(windows)]
fn terminate_exited_backend_tree(pid: u32, child: &mut Box<dyn ChildWrapper + Send>) {
info!("Stopping surviving backend job (pid {})", pid);
if let Err(error) = child.kill() {
warn!("Failed to terminate backend job (pid {}): {}", pid, error);
return;
}
if let Err(error) = child.wait() {
warn!("Failed to wait for backend job (pid {}): {}", pid, error);
}
}
#[cfg(not(any(unix, windows)))]
fn terminate_exited_backend_tree(_pid: u32, _child: &mut Box<dyn ChildWrapper + Send>) {}
struct BackendExit {
owned: OwnedBackendHandle,
status: String,
intentional: bool,
}
enum BackendExitPoll {
Running,
Exited(BackendExit),
Stop,
QueryFailed(String),
}
fn poll_spawned_backend_exit(state: &BackendState, generation: u64) -> BackendExitPoll {
let mut proc = match state.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if proc.generation != generation {
return BackendExitPoll::Stop;
}
let intentional = proc.intentional_stop;
let status = match proc
.owned
.as_mut()
.and_then(OwnedBackendHandle::spawned_child_mut)
{
Some(child) => match child.try_wait() {
Ok(Some(status)) => status.to_string(),
Ok(None) => return BackendExitPoll::Running,
Err(error) => return BackendExitPoll::QueryFailed(error.to_string()),
},
None => return BackendExitPoll::Stop,
};
let Some(owned) = proc.owned.take() else {
return BackendExitPoll::Stop;
};
proc.port = None;
proc.diagnostics_session = None;
proc.adopted_watchdog_generation = None;
BackendExitPoll::Exited(BackendExit {
owned,
status,
intentional,
})
}
fn current_backend_generation(state: &BackendState) -> u64 {
match state.lock() {
Ok(guard) => guard.generation,
Err(poisoned) => poisoned.into_inner().generation,
}
}
/// Cleanup can take seconds terminating descendants, and a start in that window
/// already owns the backend. The frontend sets "Server stopped unexpectedly" on
/// this event unconditionally, so a healthy replacement would be shown as failed.
fn exit_is_reportable(intentional: bool, exited: u64, current: u64) -> bool {
!intentional && exited == current
}
fn monitor_spawned_backend_exit(
app: AppHandle,
state: BackendState,
diagnostics_state: DiagnosticsState,
backend_log: BackendLog,
generation: u64,
) {
let mut query_failure_reported = false;
loop {
match poll_spawned_backend_exit(&state, generation) {
BackendExitPoll::Running => {
query_failure_reported = false;
std::thread::sleep(Duration::from_millis(100));
}
BackendExitPoll::QueryFailed(error) => {
if !query_failure_reported {
warn!("Failed to query backend process status: {}", error);
query_failure_reported = true;
}
std::thread::sleep(Duration::from_millis(100));
}
BackendExitPoll::Stop => return,
BackendExitPoll::Exited(exit) => {
info!("Backend exited with status: {}", exit.status);
exit.owned.cleanup_after_exit();
diagnostics::record_backend_exit(
&diagnostics_state,
&backend_log.session_id,
Some(exit.status),
exit.intentional,
None,
);
let current = current_backend_generation(&state);
if exit_is_reportable(exit.intentional, generation, current) {
error!("Backend process exited unexpectedly (crash detected)");
let _ = app.emit("server-crashed", ());
} else if generation != current {
info!("Backend {} was replaced during cleanup", generation);
}
return;
}
}
}
}
/// Windows `CREATE_NO_WINDOW` flag — suppresses console windows for child processes.
#[cfg(windows)]
pub(crate) const CREATE_NO_WINDOW: u32 = 0x08000000;
@ -389,6 +551,13 @@ pub fn find_unsloth_binary() -> Option<std::path::PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_backend_replaced_during_cleanup_is_not_reported_as_a_crash() {
assert!(exit_is_reportable(false, 7, 7));
assert!(!exit_is_reportable(false, 7, 8));
assert!(!exit_is_reportable(true, 7, 7));
}
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
@ -444,6 +613,119 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn spawned_backend_exit_cleans_surviving_process_group() {
use std::os::unix::process::CommandExt;
let mut command = Command::new("sh");
command.args(["-c", "trap '' HUP; sleep 1; sleep 30 & exit 7"]);
command.process_group(0);
let child = command.spawn().unwrap();
let pid = child.id();
let state = new_backend_state();
{
let mut proc = state.lock().unwrap();
proc.generation = 4;
proc.owned = Some(OwnedBackendHandle::spawned(Box::new(child), None, pid, 4));
}
assert!(matches!(
poll_spawned_backend_exit(&state, 4),
BackendExitPoll::Running
));
let exit = loop {
match poll_spawned_backend_exit(&state, 4) {
BackendExitPoll::Running => {
std::thread::sleep(Duration::from_millis(50));
}
BackendExitPoll::Exited(exit) => break exit,
BackendExitPoll::QueryFailed(error) => panic!("{error}"),
BackendExitPoll::Stop => panic!("monitor stopped before observing exit"),
}
};
assert!(exit.status.contains('7'));
assert!(!exit.intentional);
let group_survived = unix_process_group_exists(pid);
exit.owned.cleanup_after_exit();
assert!(group_survived);
assert!(!unix_process_group_exists(pid));
assert!(matches!(
poll_spawned_backend_exit(&state, 4),
BackendExitPoll::Stop
));
assert!(!state.lock().unwrap().has_owned_backend());
}
#[cfg(windows)]
fn windows_process_is_alive(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{
OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
};
unsafe {
let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
if handle.is_null() {
return false;
}
let status = WaitForSingleObject(handle, 0);
let _ = CloseHandle(handle);
status == WAIT_TIMEOUT
}
}
#[cfg(windows)]
#[test]
fn spawned_backend_exit_cleans_surviving_job() {
let temp = temp_studio_dir("backend-job-cleanup");
let pid_file = temp.join("descendant.pid");
let quoted_path = pid_file.to_string_lossy().replace('\'', "''");
let script = format!(
"$child = Start-Process \"$env:SystemRoot\\System32\\ping.exe\" \
-ArgumentList '-n','30','127.0.0.1' -WindowStyle Hidden -PassThru; \
Set-Content -LiteralPath '{quoted_path}' -Value $child.Id -NoNewline; exit 7"
);
let mut command = Command::new("powershell.exe");
command.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
let child = spawn_backend_command(command).unwrap();
let pid = child.id();
let state = new_backend_state();
{
let mut proc = state.lock().unwrap();
proc.generation = 4;
proc.owned = Some(OwnedBackendHandle::spawned(child, None, pid, 4));
}
let exit = loop {
match poll_spawned_backend_exit(&state, 4) {
BackendExitPoll::Running => std::thread::sleep(Duration::from_millis(50)),
BackendExitPoll::Exited(exit) => break exit,
BackendExitPoll::QueryFailed(error) => panic!("{error}"),
BackendExitPoll::Stop => panic!("monitor stopped before observing exit"),
}
};
let descendant_pid = fs::read_to_string(&pid_file)
.unwrap()
.parse::<u32>()
.unwrap();
let descendant_survived = windows_process_is_alive(descendant_pid);
exit.owned.cleanup_after_exit();
assert!(exit.status.contains('7'));
assert!(descendant_survived);
for _ in 0..20 {
if !windows_process_is_alive(descendant_pid) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(!windows_process_is_alive(descendant_pid));
fs::remove_dir_all(temp).unwrap();
}
fn listening_non_studio_port() -> (u16, mpsc::Sender<()>, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
@ -548,6 +830,29 @@ fn backend_args(port: u16) -> Vec<String> {
.collect()
}
fn spawn_backend_command(cmd: Command) -> std::io::Result<Box<dyn ChildWrapper + Send>> {
let mut wrap = CommandWrap::from(cmd);
#[cfg(windows)]
{
use windows::Win32::System::Threading::{
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW as WINDOWS_CREATE_NO_WINDOW,
};
// Keep this backend tree independently terminable after its leader exits.
wrap.wrap(CreationFlags(
CREATE_NEW_PROCESS_GROUP | WINDOWS_CREATE_NO_WINDOW,
));
wrap.wrap(JobObject);
}
#[cfg(unix)]
wrap.wrap(ProcessGroup::leader());
let child: Box<dyn ChildWrapper + Send> = wrap.spawn()?;
Ok(child)
}
/// Spawn the backend process and wire up stdout/stderr reader threads.
pub fn start_backend(
app: &AppHandle,
@ -634,46 +939,17 @@ pub fn start_backend(
let backend_log = diagnostics::begin_backend_session(diagnostics_state, port, generation);
// On Windows, launch the backend directly with hidden-window flags.
// The app process is assigned to a KILL_ON_JOB_CLOSE job in main.rs, so
// children inherit crash-safe cleanup without the buggy per-child JobObject wrapper.
#[cfg(windows)]
let mut child: Box<dyn ChildWrapper + Send> = {
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
let child = cmd.spawn().map_err(|e| {
let msg = format!("Failed to spawn backend: {}", e);
diagnostics::record_backend_start_failure(
diagnostics_state,
Some(port),
Some(generation),
"spawn_backend",
&msg,
);
msg
})?;
Box::new(child)
};
#[cfg(unix)]
let mut child: Box<dyn ChildWrapper + Send> = {
// Keep the backend tree in a process group on Unix for cleanup.
let mut wrap = CommandWrap::from(cmd);
wrap.wrap(ProcessGroup::leader());
wrap.spawn().map_err(|e| {
let msg = format!("Failed to spawn backend: {}", e);
diagnostics::record_backend_start_failure(
diagnostics_state,
Some(port),
Some(generation),
"spawn_backend",
&msg,
);
msg
})?
};
let mut child = spawn_backend_command(cmd).map_err(|e| {
let msg = format!("Failed to spawn backend: {}", e);
diagnostics::record_backend_start_failure(
diagnostics_state,
Some(port),
Some(generation),
"spawn_backend",
&msg,
);
msg
})?;
let backend_pid = child.id();
let stdout = child.stdout().take();
@ -731,6 +1007,22 @@ pub fn start_backend(
});
}
{
let app_handle = app.clone();
let state_clone = Arc::clone(state);
let diagnostics_clone = diagnostics_state.clone();
let backend_log_clone = backend_log.clone();
std::thread::spawn(move || {
monitor_spawned_backend_exit(
app_handle,
state_clone,
diagnostics_clone,
backend_log_clone,
generation,
);
});
}
Ok(generation)
}
@ -897,7 +1189,7 @@ async fn validate_candidate_port(
/// Read lines from a child process stream (stdout or stderr).
/// For stdout, parse TAURI_PORT=(\d+) candidates for async validation.
/// When stdout closes and the stop was not intentional, emit server-crashed.
/// Process lifecycle is owned by monitor_spawned_backend_exit.
fn read_output_stream<R: std::io::Read>(
stream: R,
app: &AppHandle,
@ -993,63 +1285,6 @@ fn read_output_stream<R: std::io::Read>(
}
}
}
// Stream closed. Only the stdout reader checks for crashes.
if !is_stderr {
let mut exit_record: Option<(String, bool)> = None;
let mut emit_crash = false;
if let Ok(mut proc) = state.lock() {
if proc.generation != generation {
return;
}
let intentional = proc.intentional_stop;
let exited = if let Some(child) = proc
.owned
.as_mut()
.and_then(OwnedBackendHandle::spawned_child_mut)
{
match child.try_wait() {
Ok(Some(status)) => {
info!("Backend stdout stream ended with status: {}", status);
exit_record = Some((status.to_string(), intentional));
true
}
Ok(None) => {
warn!("Backend stdout stream ended, but process is still running");
false
}
Err(e) => {
warn!("Failed to query backend status after stdout closed: {}", e);
false
}
}
} else {
false
};
if exited {
if let Some(owned) = proc.owned.take() {
owned.remove_owner_metadata();
}
proc.port = None;
proc.diagnostics_session = None;
emit_crash = !intentional;
}
}
if let Some((status, intentional)) = exit_record {
diagnostics::record_backend_exit(
diagnostics_state,
&backend_log.session_id,
Some(status),
intentional,
None,
);
}
if emit_crash {
error!("Backend process stdout closed unexpectedly (crash detected)");
let _ = app.emit("server-crashed", ());
}
}
}
fn wait_for_child_exit(child: &mut Box<dyn ChildWrapper + Send>, label: &str) -> bool {
@ -1128,6 +1363,7 @@ fn stop_spawned_backend(
&& try_exact_port_http_shutdown(port, "Spawned backend")
&& wait_for_child_exit(&mut child, "Backend")
{
terminate_exited_backend_tree(pid, &mut child);
remove_optional_owner(owner);
return Ok(());
}
@ -1158,6 +1394,7 @@ fn stop_spawned_backend(
}
if wait_for_child_exit(&mut child, "Backend") {
terminate_exited_backend_tree(pid, &mut child);
remove_optional_owner(owner);
return Ok(());
}

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import csv
import importlib.util
import hashlib
import hmac
@ -18,7 +19,7 @@ import types
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import List, Literal, Optional
import typer
@ -270,6 +271,123 @@ def _load_run_module():
return _RUN_MODULE
def _canonical_distribution_name(name: str) -> str:
"""PEP 503 normalisation, so PyJWT / pyjwt / py_jwt compare equal."""
return re.sub(r"[-_.]+", "-", name).lower()
def _studio_requirement_roots(run_mod):
"""Requirements studio.txt names directly, markers already applied."""
from packaging.requirements import InvalidRequirement, Requirement
requirements = Path(run_mod.__file__).with_name("requirements") / "studio.txt"
roots = []
for line in requirements.read_text(encoding = "utf-8").splitlines():
line = line.partition("#")[0].strip()
# pip flags (-r, --extra-index-url) are not requirements. Treating one
# as broken is permanent: repair reinstalls the same file.
if not line or line.startswith("-"):
continue
try:
requirement = Requirement(line)
except InvalidRequirement:
continue
if requirement.marker and not requirement.marker.evaluate():
continue
roots.append(requirement)
return roots
def _recorded_files_missing(installed) -> bool:
"""Whether RECORD lists files the venv no longer has.
An interrupted replace can recreate a package directory and stop part-way
through filling it, so the directory existing proves nothing. RECORD is read
directly because Distribution.files drops entries that no longer exist,
which is exactly the set this looks for. 6656 files across studio.txt's
closure cost 158ms, and __pycache__ is skipped: deleting it is not damage.
"""
# egg-info has no RECORD, and its file lists say nothing about completeness.
record = installed.read_text("RECORD")
if record is None:
return False
for row in csv.reader(record.splitlines()):
if not row or not row[0]:
continue
parts = PurePosixPath(row[0]).parts
# .dist-info is the metadata itself; .data and ../ land outside the tree.
if parts[0] == ".." or parts[0].endswith((".dist-info", ".data")):
continue
if "__pycache__" in parts or parts[-1].endswith(".pyc"):
continue
if not installed.locate_file(row[0]).exists():
return True
return False
def _missing_studio_requirement(run_mod):
"""First requirement studio.txt needs that this venv cannot supply.
Walks dependencies too: starlette reaches the venv only via FastAPI and is
imported inside run_server, so a direct-only check calls a doomed install
ready. Metadata only, no imports, ~80ms on a full venv.
"""
from importlib.metadata import PackageNotFoundError, distribution
from packaging.requirements import InvalidRequirement, Requirement
pending = []
seen = set()
def visit(requirement, is_root):
"""Missing name, or None; queues the requirement's dependencies."""
try:
installed = distribution(requirement.name)
except PackageNotFoundError:
return requirement.name
# Metadata outlives the package: wheels store METADATA first, so a killed
# unpack leaves a version behind. RECORD is last, so its absence marks it.
if installed.files is None or _recorded_files_missing(installed):
return requirement.name
# Only studio.txt pins are enforced: install_python_stack uses --no-deps,
# so transitive bounds read unsatisfied in venvs that work. A prerelease
# satisfying a floor is not a broken install either.
if (
is_root
and requirement.specifier
and not requirement.specifier.contains(installed.version, prereleases = True)
):
return requirement.name
for dependency in installed.requires or []:
try:
parsed = Requirement(dependency)
except InvalidRequirement:
continue
# No extra is requested, so extras-only dependencies do not apply.
if parsed.marker and not parsed.marker.evaluate({"extra": ""}):
continue
pending.append(parsed)
return None
# Roots are seen up front: reached as a dependency first, a root would never
# meet its own pin (datasets wants huggingface-hub<2, studio.txt ==0.36.2).
roots = _studio_requirement_roots(run_mod)
seen.update(_canonical_distribution_name(root.name) for root in roots)
for requirement in roots:
missing = visit(requirement, True)
if missing:
return missing
while pending:
requirement = pending.pop()
key = _canonical_distribution_name(requirement.name)
if key in seen:
continue
seen.add(key)
missing = visit(requirement, False)
if missing:
return missing
return None
def _find_setup_script() -> Optional[Path]:
"""Find studio/setup.sh or studio/setup.ps1.
@ -2826,6 +2944,51 @@ def desktop_capabilities(
typer.echo(f"{key}: {value}")
@studio_app.command("desktop-runtime-check", hidden = True)
def desktop_runtime_check(
_json_output: bool = typer.Option(
False,
"--json",
help = "Emit machine-readable JSON.",
),
):
try:
run_mod = _load_run_module()
missing = _missing_studio_requirement(run_mod)
if missing:
raise ModuleNotFoundError(
f"No distribution named {missing!r}",
name = missing,
)
except ModuleNotFoundError as exc:
payload = {
"runtime_ready": False,
"reason": "missing_dependency",
"module": exc.name,
}
# SystemExit is not an Exception. run.py raises it for rejected settings like
# UNSLOTH_CPU_THREADS; escaping emits no payload, so the app reinstalls.
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,
"reason": "backend_import_failed",
"error_type": type(exc).__name__,
}
else:
payload = {"runtime_ready": True}
typer.echo(json.dumps(payload, sort_keys = True))
if not payload["runtime_ready"]:
raise typer.Exit(code = 1) from None
@studio_app.command("provision-desktop-auth", hidden = True)
def provision_desktop_auth():
"""Create/repair desktop auth state for the local machine."""

View file

@ -0,0 +1,323 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import importlib
import json
import sys
from types import SimpleNamespace
from pathlib import Path
import pytest
import typer
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
def _missing_structlog():
raise ModuleNotFoundError("No module named 'structlog'", name = "structlog")
def test_desktop_runtime_check_reports_missing_dependency_as_json(monkeypatch, capsys):
studio = importlib.import_module("unsloth_cli.commands.studio")
monkeypatch.setattr(studio, "_load_run_module", _missing_structlog)
with pytest.raises(typer.Exit) as exited:
studio.desktop_runtime_check(_json_output = True)
assert exited.value.exit_code == 1
assert json.loads(capsys.readouterr().out) == {
"runtime_ready": False,
"reason": "missing_dependency",
"module": "structlog",
}
def test_desktop_runtime_check_reports_success(monkeypatch, capsys):
studio = importlib.import_module("unsloth_cli.commands.studio")
monkeypatch.setattr(studio, "_load_run_module", lambda: object())
monkeypatch.setattr(studio, "_missing_studio_requirement", lambda _run_mod: None)
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
def test_desktop_runtime_check_catches_later_startup_dependency(monkeypatch, capsys, tmp_path):
studio = importlib.import_module("unsloth_cli.commands.studio")
backend = tmp_path / "backend"
requirements = backend / "requirements"
requirements.mkdir(parents = True)
(requirements / "studio.txt").write_text(
"definitely-missing-studio-package\n",
encoding = "utf-8",
)
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
with pytest.raises(typer.Exit):
studio.desktop_runtime_check(_json_output = True)
payload = json.loads(capsys.readouterr().out)
assert payload["module"] == "definitely-missing-studio-package"
def test_desktop_runtime_check_ignores_pip_flag_lines(monkeypatch, capsys, tmp_path):
"""Repair reinstalls the same file, so an unparseable line breaks it forever."""
studio = importlib.import_module("unsloth_cli.commands.studio")
backend = tmp_path / "backend"
requirements = backend / "requirements"
requirements.mkdir(parents = True)
(requirements / "studio.txt").write_text(
"--extra-index-url https://example.invalid/simple\n-r base.txt\n",
encoding = "utf-8",
)
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
def test_desktop_runtime_check_accepts_a_prerelease_over_a_floor(monkeypatch, capsys, tmp_path):
studio = importlib.import_module("unsloth_cli.commands.studio")
backend = tmp_path / "backend"
requirements = backend / "requirements"
requirements.mkdir(parents = True)
(requirements / "studio.txt").write_text("example-package>=1.0\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 = "2.0.0b1",
files = [],
requires = None,
read_text = lambda _n: None,
),
)
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
def test_desktop_runtime_check_rejects_version_mismatch(monkeypatch, capsys, tmp_path):
studio = importlib.import_module("unsloth_cli.commands.studio")
backend = tmp_path / "backend"
requirements = backend / "requirements"
requirements.mkdir(parents = True)
(requirements / "studio.txt").write_text("example-package==2.0\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 = "1.0",
files = [],
requires = None,
read_text = lambda _n: None,
),
)
with pytest.raises(typer.Exit):
studio.desktop_runtime_check(_json_output = True)
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 METADATA first, so an interrupted unpack leaves a
readable version behind. RECORD is last, so its absence marks the 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,
requires = None,
read_text = lambda _n: 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 _fake_distributions(monkeypatch, installed):
metadata = importlib.import_module("importlib.metadata")
def _distribution(name):
try:
version, requires = installed[name]
except KeyError:
raise metadata.PackageNotFoundError(name) from None
return SimpleNamespace(
version = version,
files = [],
requires = requires,
read_text = lambda _n: None,
)
monkeypatch.setattr(metadata, "distribution", _distribution)
def test_desktop_runtime_check_rejects_a_missing_transitive_dependency(
monkeypatch, capsys, tmp_path
):
"""starlette reaches the venv only as a FastAPI dependency, so a direct-only
check calls the install ready and the server dies on start."""
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>=0.115\n", encoding = "utf-8")
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
_fake_distributions(monkeypatch, {"fastapi": ("0.140.5", ["starlette>=0.40"])})
with pytest.raises(typer.Exit):
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out)["module"] == "starlette"
def test_desktop_runtime_check_ignores_optional_and_circular_dependencies(
monkeypatch, capsys, tmp_path
):
"""Extras-only dependencies are not missing, and a cycle must terminate."""
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)
_fake_distributions(
monkeypatch,
{
"fastapi": ("0.140.5", ['uvicorn; extra == "standard"', "starlette"]),
# Transitive bounds are not enforced: --no-deps installs leave unmet
# ones on venvs that work.
"starlette": ("0.1", ["fastapi>=99"]),
},
)
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}
def test_desktop_runtime_check_reports_a_rejected_setting_instead_of_exiting(monkeypatch, capsys):
"""run.py raises SystemExit for values like UNSLOTH_CPU_THREADS=invalid, and
with no payload the app reinstalls over a 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"]
def test_a_root_pin_is_checked_even_when_a_dependency_names_it_first(monkeypatch, capsys, tmp_path):
"""datasets wants huggingface-hub<2, studio.txt pins ==0.36.2. Reached as a
dependency first, the pin would never get to decide."""
studio = importlib.import_module("unsloth_cli.commands.studio")
backend = tmp_path / "backend"
requirements = backend / "requirements"
requirements.mkdir(parents = True)
(requirements / "studio.txt").write_text(
"datasets==4.3.0\nhuggingface-hub==0.36.2\n",
encoding = "utf-8",
)
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
_fake_distributions(
monkeypatch,
{
"datasets": ("4.3.0", ["huggingface-hub>=0.25,<2"]),
"huggingface-hub": ("1.25.1", None),
},
)
with pytest.raises(typer.Exit):
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out)["module"] == "huggingface-hub"
def test_a_record_without_its_files_is_reported_missing(monkeypatch, capsys, tmp_path):
"""An interrupted replace can recreate the package directory and stop
part-way through filling it, so the directory existing proves nothing.
Built as a real dist-info rather than a stub: Distribution.files drops
entries that no longer exist, which is the whole set this looks for."""
studio = importlib.import_module("unsloth_cli.commands.studio")
metadata = importlib.import_module("importlib.metadata")
backend = tmp_path / "backend"
requirements = backend / "requirements"
requirements.mkdir(parents = True)
(requirements / "studio.txt").write_text("structlog\n", encoding = "utf-8")
run_mod = SimpleNamespace(__file__ = str(backend / "run.py"))
monkeypatch.setattr(studio, "_load_run_module", lambda: run_mod)
site_packages = tmp_path / "site-packages"
dist_info = site_packages / "structlog-25.1.0.dist-info"
dist_info.mkdir(parents = True)
(dist_info / "METADATA").write_text(
"Metadata-Version: 2.1\nName: structlog\nVersion: 25.1.0\n",
encoding = "utf-8",
)
(dist_info / "RECORD").write_text(
"structlog/__init__.py,,\n"
"structlog/processors.py,,\n"
"structlog/__pycache__/__init__.cpython-313.pyc,,\n"
"structlog-25.1.0.dist-info/RECORD,,\n",
encoding = "utf-8",
)
installed = next(iter(metadata.distributions(path = [str(site_packages)])))
monkeypatch.setattr(metadata, "distribution", lambda _name: installed)
with pytest.raises(typer.Exit):
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out)["module"] == "structlog"
# The directory back but still a file short is the interrupted replace.
(site_packages / "structlog").mkdir()
(site_packages / "structlog" / "__init__.py").touch()
with pytest.raises(typer.Exit):
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out)["module"] == "structlog"
# Complete, and the never-written .pyc must not count as damage.
(site_packages / "structlog" / "processors.py").touch()
studio.desktop_runtime_check(_json_output = True)
assert json.loads(capsys.readouterr().out) == {"runtime_ready": True}