diff --git a/desktop/src-tauri/src/backendjob.rs b/desktop/src-tauri/src/backendjob.rs index 76ccc3a3..b4db32f8 100644 --- a/desktop/src-tauri/src/backendjob.rs +++ b/desktop/src-tauri/src/backendjob.rs @@ -19,6 +19,10 @@ //! The process is created suspended and only resumed once it is in the job, //! so the launcher cannot start the interpreter before the job applies. //! +//! The setup children, pip and the model warmup, start the same way, so +//! closing StemDeck mid-install ends them too; their job closes when they +//! finish. +//! //! macOS and Linux keep the plain spawn: there `stop_backend` sends SIGTERM //! first and the backend's shutdown stops its renders. @@ -26,13 +30,13 @@ use std::io; use std::process::{Child, Command}; #[cfg(windows)] -pub use windows_impl::{spawn_backend_process, KillOnCloseJob}; +pub use windows_impl::{spawn_in_job, KillOnCloseJob}; #[cfg(not(windows))] pub enum KillOnCloseJob {} #[cfg(not(windows))] -pub fn spawn_backend_process(cmd: &mut Command) -> io::Result<(Child, Option)> { +pub fn spawn_in_job(cmd: &mut Command) -> io::Result<(Child, Option)> { Ok((cmd.spawn()?, None)) } @@ -97,14 +101,14 @@ mod windows_impl { } } - /// Spawn the backend without a console window, inside a kill-on-close - /// job. When no job can be made the backend still starts, as before, - /// without one (logged): a missing safety net must not stop the app. - pub fn spawn_backend_process(cmd: &mut Command) -> io::Result<(Child, Option)> { + /// Spawn without a console window, inside a kill-on-close job. When no + /// job can be made the process still starts, as before, without one + /// (logged): a missing safety net must not stop the app. + pub fn spawn_in_job(cmd: &mut Command) -> io::Result<(Child, Option)> { let job = match KillOnCloseJob::new() { Ok(job) => job, Err(e) => { - eprintln!("[stemdeck] no job object for the backend: {e}"); + eprintln!("[stemdeck] no job object for a child process: {e}"); cmd.creation_flags(CREATE_NO_WINDOW); return Ok((cmd.spawn()?, None)); } @@ -124,7 +128,7 @@ mod windows_impl { return Err(e); } if !assigned { - eprintln!("[stemdeck] backend not in its job object: {assign_error}"); + eprintln!("[stemdeck] child process not in its job object: {assign_error}"); return Ok((child, None)); } Ok((child, Some(job))) @@ -157,7 +161,7 @@ mod windows_impl { if resumed { Ok(()) } else { - Err(io::Error::other("the backend process could not be resumed")) + Err(io::Error::other("the child process could not be resumed")) } } } @@ -241,7 +245,7 @@ mod windows_impl { #[test] fn a_grandchild_dies_when_the_job_closes() { - let (mut child, job) = spawn_backend_process(&mut tree()).expect("spawned"); + let (mut child, job) = spawn_in_job(&mut tree()).expect("spawned"); let job = job.expect("the backend is in a job"); let pid = child.id(); assert!( @@ -268,7 +272,7 @@ mod windows_impl { fn the_backend_is_resumed_and_runs_to_its_end() { let mut cmd = Command::new("cmd"); cmd.args(["/d", "/c", "exit 7"]); - let (mut child, _job) = spawn_backend_process(&mut cmd).expect("spawned"); + let (mut child, _job) = spawn_in_job(&mut cmd).expect("spawned"); assert!( eventually(|| matches!(child.try_wait(), Ok(Some(_)))), "left suspended" diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 76016b1b..3915ca97 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -437,7 +437,12 @@ fn main() { .cloned() .ok_or("tauri.conf.json describes no main window")?; main_window.drag_drop_enabled = dropin::NATIVE_FILE_DROP; - let builder = tauri::WebviewWindowBuilder::from_config(app.handle(), &main_window)?; + let mut builder = tauri::WebviewWindowBuilder::from_config(app.handle(), &main_window)?; + if let (Ok(root), Ok(data_dir)) = (app_root(), local_data_dir()) { + if let Some(dir) = portable_webview_dir(&root, &data_dir) { + builder = builder.data_directory(dir); + } + } // Arms the launch crash guard and clears it once the page loads. #[cfg(target_os = "linux")] let builder = dmabuf::watch_first_load(builder); @@ -1776,18 +1781,9 @@ fn warmup_models(state: tauri::State) -> Result) -> Result Result, +) -> Result { + // Held to the end of this function: dropping the job ends the child. + let (child, _job) = + backendjob::spawn_in_job(command).map_err(|e| format!("failed to start {label}: {e}"))?; + if let Ok(mut inner) = state.inner.lock() { + inner.setup_child_pid = Some(child.id()); + } + let output = wait(child); + if let Ok(mut inner) = state.inner.lock() { + inner.setup_child_pid = None; + } + output +} + /// Spawns the Python/uvicorn backend, waits for it to become healthy, and returns its URL. #[tauri::command] fn start_backend( @@ -1963,7 +1982,7 @@ fn start_backend( // On Windows also without a console window, and inside a job that // ends the whole backend tree with StemDeck (see backendjob.rs). - let (mut child, job) = backendjob::spawn_backend_process(&mut cmd) + let (mut child, job) = backendjob::spawn_in_job(&mut cmd) .map_err(|e| format!("failed to start backend: {e}"))?; // Release the reserved ports immediately after spawn so uvicorn can // bind them. Both, or the companion listener finds its own port held. @@ -2278,6 +2297,13 @@ fn is_portable_package(root: &Path) -> bool { root.join("portable.txt").is_file() } +/// Where a portable package keeps its WebView2 profile: in its data dir, like +/// everything else it writes (#399). `None` keeps the platform default, which +/// on Windows is %LOCALAPPDATA%\app.stemdeck.desktop\EBWebView. +fn portable_webview_dir(root: &Path, data_dir: &Path) -> Option { + is_portable_package(root).then(|| data_dir.join("webview")) +} + /// A persisted "cpu-only-package" device decision is only trustworthy while the /// *current* install is still the CPU-only build. When a user replaces the CPU /// package with the NVIDIA (CUDA) build in the same data dir, the leftover @@ -3161,26 +3187,16 @@ fn spawn_pip( .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - hide_console_window(&mut command); - let child = command - .spawn() - .map_err(|e| format!("failed to start {label}: {e}"))?; - - if let Ok(mut inner) = state.inner.lock() { - inner.setup_child_pid = Some(child.id()); - } - let output = child_output_streaming( - child, - stall, - PIP_HARD_CAP, - label, - pip_line_reporter(app.clone(), label.to_string()), - ); - if let Ok(mut inner) = state.inner.lock() { - inner.setup_child_pid = None; - } - output + run_setup_child(&mut command, state, label, |child| { + child_output_streaming( + child, + stall, + PIP_HARD_CAP, + label, + pip_line_reporter(app.clone(), label.to_string()), + ) + }) } /// Whether pip refused `--progress-bar raw` itself, as opposed to failing at @@ -4043,6 +4059,7 @@ fn stop_backend(state: &BackendState) { // Kill any in-progress setup-time subprocess (pip install, model warmup) // so it doesn't corrupt the venv/cache if the window is closed mid-setup (#140). + // On Windows its job (run_setup_child) ends it when StemDeck exits. #[cfg(unix)] if let Some(pid) = _setup_child_pid { // SAFETY: pid was stored immediately after spawn; we send SIGTERM best-effort. @@ -7908,6 +7925,58 @@ mod tests { ); } + #[test] + #[cfg(windows)] + fn what_a_setup_child_starts_does_not_outlive_it() { + // On Windows nothing ended pip or the model warmup when StemDeck + // closed, so either kept writing into python/ or the model cache. They + // run in a job that closes with StemDeck. Here the setup child starts + // its own child, which writes `started` at once and would write `late` + // two seconds on; once the setup child is done with, the job has to + // have ended that one too. + use std::os::windows::process::CommandExt; + let dir = make_tmp(); + let mut command = Command::new("cmd"); + command + .args(["/d", "/c"]) + .raw_arg( + "start /b cmd /d /c \"type nul > started & ping -n 3 127.0.0.1 >nul & type nul > late\"", + ) + .current_dir(dir.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let state = super::BackendState::default(); + + let output = super::run_setup_child(&mut command, &state, "setup child", |mut child| { + let pid = state.inner.lock().unwrap().setup_child_pid; + assert_eq!(pid, Some(child.id()), "the close handler can find it"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !dir.path().join("started").exists() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } + let status = child.wait().map_err(|e| e.to_string())?; + Ok(std::process::Output { + status, + stdout: Vec::new(), + stderr: Vec::new(), + }) + }) + .expect("ran"); + + assert!(output.status.success()); + assert_eq!(state.inner.lock().unwrap().setup_child_pid, None); + assert!( + dir.path().join("started").exists(), + "the setup child's own child never ran" + ); + std::thread::sleep(Duration::from_secs(5)); + assert!( + !dir.path().join("late").exists(), + "a process the setup child started outlived it" + ); + } + #[test] #[cfg(not(target_os = "macos"))] fn the_pip_stall_budget_outlasts_a_quiet_wheel_install() { @@ -10195,6 +10264,28 @@ configuration: --cc=clang\n"; assert!(super::is_portable_package(root.path())); } + #[test] + fn a_portable_package_keeps_its_webview_profile_in_data() { + // #399: the WebView2 profile went to %LOCALAPPDATA% even when every + // other file stayed in the portable folder. + let root = make_tmp(); + let data = root.path().join("data"); + fs::write(root.path().join("portable.txt"), "").unwrap(); + assert_eq!( + super::portable_webview_dir(root.path(), &data), + Some(data.join("webview")) + ); + } + + #[test] + fn an_installed_app_keeps_the_default_webview_profile() { + // Installers, macOS and Linux keep the profile where it always was, + // with whatever the user has stored in it. + let root = make_tmp(); + let data = root.path().join("data"); + assert_eq!(super::portable_webview_dir(root.path(), &data), None); + } + #[test] fn directory_has_entries_true_for_a_populated_dir() { let dir = make_tmp(); diff --git a/packaging/windows/README-WINDOWS.txt b/packaging/windows/README-WINDOWS.txt index 73c42fd2..d55a5ce1 100644 --- a/packaging/windows/README-WINDOWS.txt +++ b/packaging/windows/README-WINDOWS.txt @@ -15,6 +15,7 @@ Notes - This is a portable folder, not an installer. - No Start Menu shortcut, service, or registry integration is created. - Runtime, config, and logs stay under data/, next to StemDeck.exe, not in AppData. + So does the window's browser storage, in data/webview/. - FFmpeg is downloaded during first-run setup into data/ffmpeg/. - Demucs model weights are downloaded by the backend on first use into data/models/. - Your library is stored in data/jobs/, unless Documents/StemDeck/jobs already