* studio: regenerate desktop launcher on `unsloth studio update` Today `unsloth studio update` only mutates the venv. The macOS .app bundle, the Linux .desktop file, and the shared launch-studio.sh stub bake their paths and `studio_install_id` at install time and never refresh. Users who update an existing Studio install report the Dock / Applications icon still pointing at the old launcher; only a fresh `curl ... install.sh | sh` fixes it because that path re-enters install.sh's create_studio_shortcuts. Wire the same logic into the update path: - install.sh: add --shortcuts-only. Skips the heavy install steps, resolves STUDIO_HOME / OS / DATA_DIR through the existing _resolve_studio_destinations + platform detection, then calls create_studio_shortcuts and exits. - unsloth_cli/commands/studio.py: after setup.sh succeeds, call install.sh with --shortcuts-only. Prefers a local checkout's install.sh (when STUDIO_LOCAL_REPO is set) or one shipped under _PACKAGE_ROOT, and falls back to fetching the upstream installer from https://unsloth.ai/install.sh for PyPI-installed users (the wheel does not ship install.sh). Net effect: `unsloth studio update` now refreshes the macOS .app stub, launcher script, studio.conf, and Linux .desktop entry on every update, so the desktop icon stays in sync with the venv that setup.sh just updated. Env-override and Tauri modes keep their existing behavior (no persistent menu shortcuts, but the launch-studio.sh is still regenerated). Windows is unchanged here; setup.ps1 already handles its own Start Menu / Desktop .lnk creation on update. * studio: also regenerate Windows .lnk shortcuts on update Mirror the macOS fix: install.ps1 gains --shortcuts-only that short-circuits to New-StudioShortcuts, and unsloth studio update calls it after setup.ps1 the same way it now does on macOS / Linux. PyPI installs do not ship install.ps1, so the Python helper fetches the upstream script from https://unsloth.ai/install.ps1 and pipes it into powershell.exe -Command - with an explicit Install-UnslothStudio call appended (irm | iex relies on the trailing @args, which is empty when launched from stdin). setup.ps1 alone never recreates the Start Menu / Desktop .lnk targets or the launch-studio.{ps1,vbs} scripts, so without this update users on Windows hit the same stale-icon regression that triggered the macOS PR. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: rename unsloth.exe to .deleteme before update on Windows Pip's editable reinstall calls uninstall first, which deletes every RECORD entry. unsloth.exe is one of them, and Windows refuses to delete a file whose image is mapped into the running process tree. The first unsloth studio update after install therefore fails with: OSError: [WinError 32] The process cannot access the file because it is being used by another process: ...\Scripts\unsloth.exe Windows does allow renaming an in-use exe, so move it aside before _run_setup_script kicks pip. pip then drops a fresh unsloth.exe at the original path; the *.exe.deleteme left behind is cleaned up at the start of the next update once the previous shim has exited. * studio: rename unsloth.exe from setup.ps1 to reliably bypass exe lock * studio: print python -m workaround when Windows exe lock blocks update * studio: use python -c hint (unsloth_cli has no __main__) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * install.sh: reshape --shortcuts-only Tauri guard to pass exit-order test * shorter comments in update / launcher regen logic * studio update: env-mode passthrough + non-silent shortcuts-only error * studio update: address codex/gemini PR review - Strip install.ps1's `Install-UnslothStudio @args` auto-invoke before appending an explicit `--shortcuts-only` call so PyPI Windows installs don't re-run the full installer over stdin. - subprocess.run(input=wrapper, ...) now uses encoding="utf-8" so box drawing chars in install.ps1 don't UnicodeEncodeError on CP1252. - Wrap _run_setup_script in try/except to restore unsloth.exe from .deleteme if setup fails, and mirror that rollback inside setup.ps1 when install_python_stack.py exits non-zero. - Capture subprocess return codes in _refresh_desktop_shortcuts and echo a one-line warning on non-zero so silent stale-shortcut failures surface. - Drop --local from the Windows lock-recovery hint so users on PyPI installs don't accidentally switch into editable-checkout mode. - Quote $VENV_ABS_BIN/unsloth in the install.sh shortcuts-only error so paths with spaces print legibly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio update: harden Windows refresh per multi-reviewer pass - PowerShell stdin path now writes the wrapper to a UTF-8 BOM tempfile and runs it via `-File`. `powershell.exe -Command -` decodes stdin with the OEM code page, which mangles box-drawing chars in the fetched install.ps1; -File reads the BOM and decodes UTF-8 cleanly. - _restore_self_exe_lock_windows now treats a zero-byte unsloth.exe as a partial-write and prefers the .deleteme copy. setup.ps1 mirrors the same check. - _release_self_exe_lock_windows uses os.replace for atomic overwrite so a stale .deleteme from an aborted prior update doesn't break the rename. - Lock-recovery hint mentions that --local should be re-added when the user installed from a repo checkout. * studio update: respect Tauri context and tidy Windows .deleteme Tauri's update.rs spawns `unsloth studio update`; without a signal, the CLI's _refresh_desktop_shortcuts would call install.{sh,ps1} --shortcuts-only and create duplicate ~/Applications/Unsloth Studio.app (or .desktop / .lnk) entries that collide with the Tauri bundle. - update.rs now sets UNSLOTH_TAURI_UPDATE=1 on the spawned child. - studio.py's update() skips _refresh_desktop_shortcuts when that env var is set; Tauri owns its own bundle entries. - After a successful Windows update, drop the .deleteme orphan so repeated updates don't accumulate stale binaries that could later be promoted by _restore_self_exe_lock_windows on a cross-version failure. - Tempfile for the PyPI-fallback PowerShell path now uses an unsloth-studio-refresh- prefix so AV/EDR rules and user greps can identify it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio update: drop obsolete WinError 32 hint, echo Tauri skip The rename trick in _release_self_exe_lock_windows + setup.ps1's restore now handle the .exe-lock case in-flow; the printed hint suggested re-running update via venv python, but that just re-enters the same update() and hits the same failure if the rename didn't help. Removing the misleading hint and its helper. Also surface a one-line typer.echo when refresh is skipped under UNSLOTH_TAURI_UPDATE so --verbose logs make the branch visible. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
423 lines
13 KiB
Rust
423 lines
13 KiB
Rust
use crate::diagnostics::{self, AttemptLog, DiagnosticsState};
|
|
use log::{error, info, warn};
|
|
use process_wrap::std::*;
|
|
use std::io::BufRead;
|
|
use std::process::{Command, ExitStatus, Stdio};
|
|
use std::sync::{Arc, Mutex};
|
|
use tauri::{AppHandle, Emitter};
|
|
|
|
// ── Types ──
|
|
|
|
pub struct UpdateProcess {
|
|
pub child: Option<Box<dyn ChildWrapper + Send>>,
|
|
pub intentional_stop: bool,
|
|
pub current_attempt: Option<AttemptLog>,
|
|
}
|
|
|
|
impl Default for UpdateProcess {
|
|
fn default() -> Self {
|
|
Self {
|
|
child: None,
|
|
intentional_stop: false,
|
|
current_attempt: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type UpdateState = Arc<Mutex<UpdateProcess>>;
|
|
|
|
pub fn new_update_state() -> UpdateState {
|
|
Arc::new(Mutex::new(UpdateProcess::default()))
|
|
}
|
|
|
|
// ── Spawn ──
|
|
|
|
fn spawn_update(
|
|
bin: &std::path::Path,
|
|
state: &UpdateState,
|
|
) -> Result<
|
|
(
|
|
Option<std::process::ChildStdout>,
|
|
Option<std::process::ChildStderr>,
|
|
),
|
|
String,
|
|
> {
|
|
let mut update = state.lock().map_err(|e| e.to_string())?;
|
|
if update.child.is_some() {
|
|
return Err("Update is already running.".to_string());
|
|
}
|
|
update.intentional_stop = false;
|
|
|
|
let mut cmd = Command::new(bin);
|
|
cmd.args(["studio", "update"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
// AppImage sets LD_LIBRARY_PATH to its bundled libs, which breaks Python
|
|
#[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 manages the legacy root; scrub so 'unsloth studio update' targets
|
|
// the same install the desktop app uses, not an inherited custom root.
|
|
cmd.env_remove("UNSLOTH_STUDIO_HOME");
|
|
cmd.env_remove("STUDIO_HOME");
|
|
// Signal to unsloth_cli that this update was initiated by the Tauri
|
|
// desktop bundle so it skips re-creating CLI launchers/.app/.desktop
|
|
// shortcuts (Tauri owns its own bundle entries).
|
|
cmd.env("UNSLOTH_TAURI_UPDATE", "1");
|
|
|
|
#[cfg(windows)]
|
|
let mut child: Box<dyn ChildWrapper + Send> = {
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
|
|
let child = cmd
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to spawn update: {}", e))?;
|
|
Box::new(child)
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
let mut child: Box<dyn ChildWrapper + Send> = {
|
|
let mut wrap = CommandWrap::from(cmd);
|
|
wrap.wrap(ProcessGroup::leader());
|
|
wrap.spawn()
|
|
.map_err(|e| format!("Failed to spawn update: {}", e))?
|
|
};
|
|
|
|
let stdout = child.stdout().take();
|
|
let stderr = child.stderr().take();
|
|
update.child = Some(child);
|
|
Ok((stdout, stderr))
|
|
}
|
|
|
|
// ── Stream ──
|
|
|
|
fn stream_output(
|
|
app: &AppHandle,
|
|
progress_event: &'static str,
|
|
diagnostics: DiagnosticsState,
|
|
attempt: AttemptLog,
|
|
stdout: Option<std::process::ChildStdout>,
|
|
stderr: Option<std::process::ChildStderr>,
|
|
) -> Vec<std::thread::JoinHandle<()>> {
|
|
let mut threads = Vec::new();
|
|
|
|
if let Some(out) = stdout {
|
|
let app_clone = app.clone();
|
|
let diagnostics_clone = diagnostics.clone();
|
|
let attempt_clone = attempt.clone();
|
|
threads.push(std::thread::spawn(move || {
|
|
let reader = std::io::BufReader::new(out);
|
|
for line in reader.lines() {
|
|
match line {
|
|
Ok(text) => {
|
|
diagnostics::append_phase_line(&attempt_clone.handle, "stdout", &text);
|
|
if let Some(step) = text.strip_prefix("[TAURI:STEP] ") {
|
|
diagnostics::record_step(&diagnostics_clone, &attempt_clone, step);
|
|
} else if let Some(progress) = text.strip_prefix("[TAURI:PROGRESS] ") {
|
|
diagnostics::record_progress(
|
|
&diagnostics_clone,
|
|
&attempt_clone,
|
|
progress,
|
|
);
|
|
} else if let Some(marker) = text.strip_prefix("[TAURI:DIAG] ") {
|
|
diagnostics::record_diag_marker(
|
|
&diagnostics_clone,
|
|
&attempt_clone,
|
|
marker,
|
|
);
|
|
}
|
|
info!("[update][stdout] {}", text);
|
|
let _ = app_clone.emit(progress_event, &text);
|
|
}
|
|
Err(e) => {
|
|
warn!("[update] Error reading stdout: {}", e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
if let Some(err) = stderr {
|
|
let app_clone = app.clone();
|
|
let attempt_clone = attempt.clone();
|
|
threads.push(std::thread::spawn(move || {
|
|
let reader = std::io::BufReader::new(err);
|
|
for line in reader.lines() {
|
|
match line {
|
|
Ok(text) => {
|
|
diagnostics::append_phase_line(&attempt_clone.handle, "stderr", &text);
|
|
warn!("[update][stderr] {}", text);
|
|
let _ = app_clone.emit(progress_event, &text);
|
|
}
|
|
Err(e) => {
|
|
warn!("[update] Error reading stderr: {}", e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
threads
|
|
}
|
|
|
|
// ── Wait ──
|
|
|
|
fn wait_for_exit(state: &UpdateState) -> Result<(ExitStatus, bool), String> {
|
|
const MAX_WAIT_ITERATIONS: u32 = 72_000; // 2h at 100ms intervals
|
|
for _ in 0..MAX_WAIT_ITERATIONS {
|
|
let mut update = state.lock().map_err(|e| e.to_string())?;
|
|
let intentional = update.intentional_stop;
|
|
|
|
match update.child.as_mut() {
|
|
Some(child) => match child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
update.child = None;
|
|
return Ok((status, intentional));
|
|
}
|
|
Ok(None) => {}
|
|
Err(e) => {
|
|
update.child = None;
|
|
return Err(format!("Error waiting for update: {}", e));
|
|
}
|
|
},
|
|
None if intentional => return Err("Update stopped.".to_string()),
|
|
None => return Err("Update process disappeared unexpectedly.".to_string()),
|
|
}
|
|
|
|
drop(update);
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
}
|
|
let _ = stop_update(state);
|
|
Err("Update timed out after 2 hours".to_string())
|
|
}
|
|
|
|
// ── Public API ──
|
|
|
|
pub fn run_backend_update(
|
|
app: AppHandle,
|
|
state: UpdateState,
|
|
diagnostics: DiagnosticsState,
|
|
) -> Result<(), String> {
|
|
run_backend_update_with_terminal_events(app, state, diagnostics, true, None)
|
|
}
|
|
|
|
pub(crate) fn run_backend_update_for_repair(
|
|
app: AppHandle,
|
|
state: UpdateState,
|
|
diagnostics: DiagnosticsState,
|
|
repair_group_id: String,
|
|
) -> Result<(), String> {
|
|
run_backend_update_with_terminal_events(app, state, diagnostics, false, Some(repair_group_id))
|
|
}
|
|
|
|
fn run_backend_update_with_terminal_events(
|
|
app: AppHandle,
|
|
state: UpdateState,
|
|
diagnostics: DiagnosticsState,
|
|
terminal_events: bool,
|
|
repair_group_id: Option<String>,
|
|
) -> Result<(), String> {
|
|
let attempt = match repair_group_id.as_deref() {
|
|
Some(group_id) => diagnostics::begin_repair_child(&diagnostics, group_id, "update"),
|
|
None => diagnostics::begin_update_attempt(&diagnostics),
|
|
};
|
|
if let Ok(mut update) = state.lock() {
|
|
update.current_attempt = Some(attempt.clone());
|
|
}
|
|
|
|
let bin = match crate::process::find_unsloth_binary() {
|
|
Some(bin) => bin,
|
|
None => {
|
|
let msg = "Unsloth binary not found. Cannot run update.".to_string();
|
|
diagnostics::finish_attempt(&diagnostics, &attempt, None, false, Some(msg.clone()));
|
|
clear_current_attempt(&state);
|
|
return Err(msg);
|
|
}
|
|
};
|
|
|
|
info!("[update] Starting backend update via {:?}", bin);
|
|
diagnostics::append_phase_line(
|
|
&attempt.handle,
|
|
"meta",
|
|
&format!("Starting backend update via {:?}", bin),
|
|
);
|
|
let progress_event = if terminal_events {
|
|
"update-progress"
|
|
} else {
|
|
"repair-progress"
|
|
};
|
|
let _ = app.emit(progress_event, "Starting backend update...");
|
|
|
|
let (stdout, stderr) = match spawn_update(&bin, &state) {
|
|
Ok(handles) => handles,
|
|
Err(msg) => {
|
|
diagnostics::finish_attempt(
|
|
&diagnostics,
|
|
&attempt,
|
|
None,
|
|
false,
|
|
Some(format!("spawn_update: {msg}")),
|
|
);
|
|
clear_current_attempt(&state);
|
|
return Err(msg);
|
|
}
|
|
};
|
|
let threads = stream_output(
|
|
&app,
|
|
progress_event,
|
|
diagnostics.clone(),
|
|
attempt.clone(),
|
|
stdout,
|
|
stderr,
|
|
);
|
|
|
|
let result = wait_for_exit(&state);
|
|
for handle in threads {
|
|
let _ = handle.join();
|
|
}
|
|
|
|
match result {
|
|
Ok((status, _)) if status.success() => {
|
|
diagnostics::finish_attempt(
|
|
&diagnostics,
|
|
&attempt,
|
|
Some(status.to_string()),
|
|
false,
|
|
None,
|
|
);
|
|
clear_current_attempt(&state);
|
|
info!("[update] Backend update complete");
|
|
if terminal_events {
|
|
let _ = app.emit("update-complete", ());
|
|
}
|
|
Ok(())
|
|
}
|
|
Ok((status, intentional)) if intentional => {
|
|
diagnostics::finish_attempt(
|
|
&diagnostics,
|
|
&attempt,
|
|
Some(status.to_string()),
|
|
true,
|
|
Some("Update stopped.".to_string()),
|
|
);
|
|
clear_current_attempt(&state);
|
|
info!("[update] Update stopped intentionally");
|
|
Err("Update stopped.".to_string())
|
|
}
|
|
Ok((status, intentional)) => {
|
|
let code = status.code().unwrap_or(-1);
|
|
let msg = format!("Update exited with code {}", code);
|
|
diagnostics::finish_attempt(
|
|
&diagnostics,
|
|
&attempt,
|
|
Some(status.to_string()),
|
|
intentional,
|
|
Some(msg.clone()),
|
|
);
|
|
clear_current_attempt(&state);
|
|
error!("[update] {}", msg);
|
|
if terminal_events {
|
|
let _ = app.emit("update-failed", &msg);
|
|
}
|
|
Err(msg)
|
|
}
|
|
Err(msg) => {
|
|
diagnostics::finish_attempt(&diagnostics, &attempt, None, false, Some(msg.clone()));
|
|
clear_current_attempt(&state);
|
|
error!("[update] {}", msg);
|
|
if terminal_events {
|
|
let _ = app.emit("update-failed", &msg);
|
|
}
|
|
Err(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn clear_current_attempt(state: &UpdateState) {
|
|
if let Ok(mut update) = state.lock() {
|
|
update.current_attempt = None;
|
|
}
|
|
}
|
|
|
|
pub fn record_update_intentional_stop(state: &UpdateState, diagnostics: &DiagnosticsState) {
|
|
let attempt = state
|
|
.lock()
|
|
.ok()
|
|
.and_then(|update| update.current_attempt.clone());
|
|
if let Some(attempt) = attempt {
|
|
diagnostics::finish_attempt(
|
|
diagnostics,
|
|
&attempt,
|
|
None,
|
|
true,
|
|
Some("intentional_stop".to_string()),
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn stop_update(state: &UpdateState) -> Result<(), String> {
|
|
let mut child = {
|
|
let mut update = match state.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => {
|
|
warn!("Update state mutex poisoned, recovering for cleanup");
|
|
poisoned.into_inner()
|
|
}
|
|
};
|
|
update.intentional_stop = true;
|
|
update.child.take()
|
|
};
|
|
|
|
let Some(ref mut child) = child else {
|
|
return Ok(());
|
|
};
|
|
|
|
let pid = child.id();
|
|
info!("Stopping update process group (pid {})", pid);
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
if pid > i32::MAX as u32 {
|
|
warn!("PID {} exceeds i32 range, using direct kill", pid);
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Ok(());
|
|
}
|
|
unsafe {
|
|
libc::kill(-(pid as i32), libc::SIGTERM);
|
|
}
|
|
for _ in 0..50 {
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
info!("Update exited gracefully with status: {:?}", status);
|
|
return Ok(());
|
|
}
|
|
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
warn!("Update did not exit gracefully, force killing");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
crate::process::force_kill_process_tree(pid, child, "Update");
|
|
return Ok(());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
info!("Update process group force stopped");
|
|
Ok(())
|
|
}
|
|
}
|