Compare commits
17 commits
main
...
fix/studio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6ecac97b9 | ||
|
|
7e2292f2e4 | ||
|
|
c1e13d41c8 | ||
|
|
6ee7985e85 | ||
|
|
5c1530186a | ||
|
|
984cafc1ea | ||
|
|
508d3720c4 | ||
|
|
ddb2c1d397 | ||
|
|
aaa00ae8d2 | ||
|
|
22c6c277ba | ||
|
|
e15ce94398 | ||
|
|
f749c90d87 | ||
|
|
4482f8b4f1 | ||
|
|
8f343038ef | ||
|
|
b40d422654 | ||
|
|
7b22a8584f | ||
|
|
a94c5b061b |
9 changed files with 1508 additions and 197 deletions
|
|
@ -236,6 +236,10 @@ export function useTauriBackend() {
|
|||
stopExternalServerPoll();
|
||||
if (preflight.can_auto_repair) {
|
||||
await startRepair();
|
||||
} else if (preflight.reason === "studio_runtime_startup_failed") {
|
||||
setBackendError(
|
||||
"The Unsloth backend refused to start with the current environment settings (for example UNSLOTH_CPU_THREADS). Fix or unset them, then restart Unsloth.",
|
||||
);
|
||||
} else {
|
||||
setBackendError(
|
||||
preflight.disposition === "owned_stale"
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
323
unsloth_cli/tests/test_studio_runtime_readiness.py
Normal file
323
unsloth_cli/tests/test_studio_runtime_readiness.py
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue