diff --git a/studio/frontend/src/hooks/use-tauri-backend.ts b/studio/frontend/src/hooks/use-tauri-backend.ts index 53122864e7..016316c9dd 100644 --- a/studio/frontend/src/hooks/use-tauri-backend.ts +++ b/studio/frontend/src/hooks/use-tauri-backend.ts @@ -236,6 +236,10 @@ export function useTauriBackend() { stopExternalServerPoll(); if (preflight.can_auto_repair) { await startRepair(); + } else if (preflight.reason === "studio_runtime_startup_failed") { + setBackendError( + "The Unsloth backend refused to start with the current environment settings (for example UNSLOTH_CPU_THREADS). Fix or unset them, then restart Unsloth.", + ); } else { setBackendError( preflight.disposition === "owned_stale" diff --git a/studio/src-tauri/src/commands.rs b/studio/src-tauri/src/commands.rs index 72ea9d6985..ddacbee71e 100644 --- a/studio/src-tauri/src/commands.rs +++ b/studio/src-tauri/src/commands.rs @@ -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 { + 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")); diff --git a/studio/src-tauri/src/desktop_backend_owner.rs b/studio/src-tauri/src/desktop_backend_owner.rs index c7d0a7b309..a0400eea5b 100644 --- a/studio/src-tauri/src/desktop_backend_owner.rs +++ b/studio/src-tauri/src/desktop_backend_owner.rs @@ -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, ¤t).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(); diff --git a/studio/src-tauri/src/install.rs b/studio/src-tauri/src/install.rs index 024b730735..bdefca22a1 100644 --- a/studio/src-tauri/src/install.rs +++ b/studio/src-tauri/src/install.rs @@ -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 { + 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 { + 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, 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); diff --git a/studio/src-tauri/src/preflight.rs b/studio/src-tauri/src/preflight.rs index 7ef5244754..fe9e4e6fcd 100644 --- a/studio/src-tauri/src/preflight.rs +++ b/studio/src-tauri/src/preflight.rs @@ -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 { 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(); } diff --git a/studio/src-tauri/src/preflight/managed.rs b/studio/src-tauri/src/preflight/managed.rs index 0d20f271c5..ed25e61523 100644 --- a/studio/src-tauri/src/preflight/managed.rs +++ b/studio/src-tauri/src/preflight/managed.rs @@ -36,6 +36,13 @@ struct DesktopCapability { version: Option, } +#[derive(Debug, Deserialize)] +struct DesktopRuntimeCheck { + runtime_ready: bool, + reason: Option, + module: Option, +} + #[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> { + 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::(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 { @@ -344,15 +478,19 @@ async fn probe_cli_capability(bin: &Path) -> Option { ); 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 { 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 { } } - 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::(&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> { + match probe_managed_install().await { + ManagedProbe::Ready { .. } => Ok(()), + ManagedProbe::Stale { reason, .. } => Err(Some(reason)), + _ => Err(None), + } +} diff --git a/studio/src-tauri/src/process.rs b/studio/src-tauri/src/process.rs index 56d9dd2e21..7311452856 100644 --- a/studio/src-tauri/src/process.rs +++ b/studio/src-tauri/src/process.rs @@ -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) { + 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) { + 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) {} + +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 { #[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::() + .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 { .collect() } +fn spawn_backend_command(cmd: Command) -> std::io::Result> { + 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 = 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 = { - 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 = { - // 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( stream: R, app: &AppHandle, @@ -993,63 +1285,6 @@ fn read_output_stream( } } } - - // 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, 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(()); } diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index 1bb0f42016..f06da08d90 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -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.""" diff --git a/unsloth_cli/tests/test_studio_runtime_readiness.py b/unsloth_cli/tests/test_studio_runtime_readiness.py new file mode 100644 index 0000000000..f5f4ab23ff --- /dev/null +++ b/unsloth_cli/tests/test_studio_runtime_readiness.py @@ -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}