Desktop: ask before quitting on top of a running install (#7550)

* Desktop: ask before quitting on top of a running install

This is the trigger neither #7492 nor #7490 addresses -- both start from a venv
that is already broken. Confirmed: neither PR touches cleanup_child_processes.

Quitting runs cleanup_child_processes -> install::stop_install, which SIGTERMs the
installer's process group. In the reported session that landed at "5/10 studio
deps", so the venv kept the CLI's dependencies and lost the server stack, and the
next launch died on `import structlog`. Three minutes of installing, destroyed
with no warning and no way back.

So ask. Only from the tray Quit item -- a deliberate action with a UI present. The
RunEvent::Exit path (OS shutdown, SIGTERM) is left alone: it must never block on a
dialog nobody can answer. The call already runs off the menu callback thread,
which is also what blocking_show requires.

Closing the window was already safe (it hides to tray); this closes the remaining
way to lose an install by accident.

* Tighten comments in desktop quit-during-install guard

* Condense comments in quit-during-install guard

---------

Co-authored-by: danielhanchen <unslothai@gmail.com>
This commit is contained in:
Daniel Han 2026-07-28 18:52:00 -07:00 committed by GitHub
commit 4f0cbf0d81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 38 additions and 0 deletions

View file

@ -783,6 +783,14 @@ pub fn record_install_intentional_stop(state: &InstallState, diagnostics: &Diagn
} }
} }
/// True while an installer runs; quitting now would leave a broken venv.
pub fn is_install_running(state: &InstallState) -> bool {
state
.lock()
.map(|install| install.child.is_some())
.unwrap_or(false)
}
/// Stop a running install process gracefully. /// Stop a running install process gracefully.
/// Unix: SIGTERM to process group -> wait up to 5s -> SIGKILL /// Unix: SIGTERM to process group -> wait up to 5s -> SIGKILL
/// Windows: hidden taskkill /T /F to terminate the installer tree /// Windows: hidden taskkill /T /F to terminate the installer tree

View file

@ -85,6 +85,33 @@ fn setup_custom_titlebar(app: &tauri::App) -> Result<(), Box<dyn std::error::Err
Ok(()) Ok(())
} }
/// Ask before quitting mid-install (true to proceed): `cleanup_child_processes` SIGTERMs the
/// installer, leaving a venv that looks healthy but cannot start. Tray Quit only, since
/// RunEvent::Exit must never block on a dialog nobody can answer.
fn confirm_quit_during_install(app: &tauri::AppHandle) -> bool {
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
let Some(install_state) = app.try_state::<install::InstallState>() else {
return true;
};
if !install::is_install_running(&install_state) {
return true;
}
app.dialog()
.message(
"Unsloth Studio is still installing. Quitting now stops it part-way and \
leaves the installation incomplete, so it will need to be repaired before \
it can start.",
)
.kind(MessageDialogKind::Warning)
.title("Installation in progress")
.buttons(MessageDialogButtons::OkCancelCustom(
"Quit anyway".to_string(),
"Keep installing".to_string(),
))
.blocking_show()
}
fn cleanup_child_processes(app: &tauri::AppHandle) { fn cleanup_child_processes(app: &tauri::AppHandle) {
let diagnostics_state = app let diagnostics_state = app
.try_state::<diagnostics::DiagnosticsState>() .try_state::<diagnostics::DiagnosticsState>()
@ -138,6 +165,9 @@ fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
// leaving the backend orphaned. // leaving the backend orphaned.
let app_handle = app.clone(); let app_handle = app.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
if !confirm_quit_during_install(&app_handle) {
return;
}
cleanup_child_processes(&app_handle); cleanup_child_processes(&app_handle);
app_handle.exit(0); app_handle.exit(0);
}); });