Repository navigation
fix(setup): make the model step honest and interruptible - #907
Merged
Merged
Conversation
added 2 commits
November 3, 2026 14:54
The setup wizard's model step could sit at "Downloading AI models" for up to 30 minutes with no way out, and closing the window did not work (#405). - warmup_models was a blocking Tauri command, and those run on the main thread, so the window could not be closed while it ran. It now runs on a blocking worker thread. - stop_backend stopped the setup child only on unix. On Windows it now ends the child's job, so the warmup and what it started end on close. - The warmup is waited on with the stall-based reader instead of a fixed 30 minutes, and stderr output (tqdm download progress) counts as activity. - warmup.py runs every model download inside bounded_downloads (#125), says which model it is on (WARMUP_START), tells an unreachable host apart (WARMUP_OFFLINE), and checks free disk space before downloading (WARMUP_NO_SPACE). - The wizard shows "Getting AI model 2 of 5: beat detection", offers Skip, and before it leaves the page says what did not arrive and that each model downloads the first time a feature needs it. Refs #405
- One setup child at a time: run_setup_child refuses a second while one runs, so Skip and close always find the child they mean to stop. - The warmup stall budget is 6 minutes: audio-separator waits up to 300 s for a read, measured as the longest silence of the real warmup behind a proxy that never answers. - WARMUP_OFFLINE is per model, so a model that failed for another reason is not blamed on the connection. - A failed space check is reported and does not stop the downloads; it counts only the models folder and never a torch.hub .partial file. - audio-separator's download_checks.json is healed like its other indexes, and load_or_heal clears the failed frames first: audio-separator leaves the file open, and Windows will not delete an open file. - The wizard names what Skip left out, says the first separation downloads its model only when it did not arrive, and ignores Retry while setup runs. Refs #405
thcp
added a commit
that referenced
this pull request
Nov 3, 2026
…ead (#908) Refs #405 ## What changes for the person running setup - The window now closes during the GPU step. Before, closing it during a CUDA torch install did nothing until pip finished: the whole step ran on the window's own thread. - Closing it ends what that step started: pip, and the step's own checks (nvidia-smi, the torch import and CUDA checks). On Windows they run in a job that the close ends, so no `python.exe` is left behind. - The pip progress line on the GPU step and on the karaoke split repair can now update while the download runs. Before, the step held the thread the window paints on. That follows from how Tauri runs the command; I did not watch it on a running app. - The same holds for the other setup steps that take seconds or minutes: checking the runtime, verifying and installing the runtime pack, downloading FFmpeg, starting the backend, and applying an in-app update. The window keeps painting and can close during each of them. ## Why, and how it works - **The cause.** A Tauri command that is not `async` runs on the main thread (tauri-macros 2.6.0, `body_blocking`). While one runs, the window cannot paint or take a close, and no other command reaches the shell. #907 fixed this for the model step. `ensure_torch_device` and `repair_torchvision` had the same problem, and so did six more commands. - **The fix.** Each of those commands is now `async` and does its work in `spawn_blocking`, as `warmup_models` does since #907. The pattern is one helper, `off_main_thread`, which `warmup_models` now uses too. - **The GPU step's own checks.** pip already ran through `run_setup_child` (#897, #907). nvidia-smi, the site-packages probe, the import checks and the CUDA verify did not. A CUDA verify wedged in the driver ran on for up to 2 minutes after StemDeck closed (#502). They now run through `run_setup_child` too, via `setup_probe_output`. - **Nothing starts after the close.** The GPU step runs pip passes and checks one after another. Once the window closes, the next one could start in the moment before StemDeck exits, and on Linux and macOS nothing would stop it. `stop_backend` now sets a `closing` flag, and `run_setup_child` refuses to start a child after it. - **The GPU step stops touching torch after the close.** A check stopped by the close reads as a failed check. Without a guard, the step would go on to move the working torch aside, or delete a build, while StemDeck exits. `still_open` is now checked before each move, the commit and the saved result. A swap left half done is put back at the start of the next GPU step (#731). - **The close waits for an update's renames.** `apply_app_update` replaces `backend/` and the executable with a few renames, and each one retries for up to about 5 s while a virus scanner holds a handle. Stopped between two renames, the install would have no `backend/` or no executable, with nothing left to repair it. - While the renames and the relaunch run, a close is held off (`prevent_close`). The update then relaunches and exits by itself. - A close that came first cancels the swap before it starts. - Before this change the main thread gave the same protection, only with a frozen window. - **One setup step at a time.** The main thread used to run these commands one after another. Now a reload of the wizard during a CUDA install could start the GPU step again beside the first one. Its nvidia-smi check would then be refused the one setup slot and read as "no GPU", and it could start the backend while pip was still writing into `python/`. - A `SetupStep` claim is now held by the GPU step, the torchvision repair, the model step, the runtime install and the FFmpeg download. A second one fails at once and says which step is running. - `start_backend` keeps its `starting` guard, and `apply_app_update` keeps its claim. - **A close in the middle of the other steps is recovered from.** - Runtime pack extract: `runtime.tmp` is cleared on the next try, and `ensure_workspace` puts `runtime.old` back. - FFmpeg: the same, with `ffmpeg.old`. - Backend start: the backend runs in its job on Windows, and watches StemDeck's pid elsewhere. ## Every command in the shell | Command | Was | Verdict | |---|---|---| | `ensure_torch_device` | blocking | **Fixed.** nvidia-smi, Python checks, several GB through pip. | | `repair_torchvision` | blocking | **Fixed.** A Python check, then a pip download. | | `start_backend` | blocking | **Fixed.** Spawns the backend and waits up to 90 s for health. | | `probe_runtime` | blocking | **Fixed.** Runs the interpreter and the FFmpeg pair. | | `verify_runtime_pack` | blocking | **Fixed.** SHA-256 of the runtime archive. | | `extract_runtime_pack` | blocking | **Fixed.** Hash and extract of the runtime archive. | | `ensure_external_assets` | blocking | **Fixed.** Downloads FFmpeg. | | `apply_app_update` | blocking | **Fixed.** Hash and extract of the update, up to 15 s backend stop. | | `warmup_models` | async (#907) | Unchanged, now through the shared helper. | | `ensure_workspace` | blocking | Stays. Creates folders, renames on one volume, copies two small JSON files. | | `runtime_pack_status` | blocking | Stays. Reads small files. | | `skip_model_warmup` | blocking | Stays. A lock and one job terminate. | | `build_target` | blocking | Stays. One file check. | | `open_url` | blocking | Stays. Spawns the browser opener and never waits on it. | | `native_file_drop` | blocking | Stays. A constant. | | `current_exports_dir` | blocking | Stays. A store read and one folder create. | | `store_get`, `store_set`, `reset_user_data` | blocking | Stay. The small JSON store. | | `mark_store_migration_done` | blocking | Stays. Writes an empty file. | | `download_runtime_pack`, `check_app_update`, `download_app_update`, `restart_backend`, `next_drop_signal`, `read_dropped_file`, `save_audio_file`, `pick_export_destination`, `download_to_path`, `reveal_saved`, `start_audio_drag`, `pick_stems_folder`, `pick_exports_folder` | async | Already off the main thread. | A test now holds this list: `no_command_that_can_run_long_holds_the_main_thread` fails if a command is blocking and not on the "stays" list. ## How it was verified Red then green: I ran each new test against the old code first. - `no_command_that_can_run_long_holds_the_main_thread` - Old code: failed, "these commands run on the main thread: probe_runtime, verify_runtime_pack, extract_runtime_pack, apply_app_update, ensure_external_assets, start_backend, ensure_torch_device, repair_torchvision". - Now: passes. - It reads `main.rs` and checks which commands are `async`, because that keyword is what tauri-macros uses to choose the main thread. No test here drives a real WebView's main thread. - `closing_the_window_ends_a_gpu_step_probe` - A stand-in interpreter that runs for 20 s, checked with the torchvision import check, then `stop_backend` as the close handler calls it. - Old code: failed, "the probe ran on for 20.17s". - Now: passes, ended at once. - `no_setup_child_starts_once_the_window_is_closing` - Old code: failed, a setup child was started after the close. - Now: passes. It also checks that `still_open` reports the close. - `a_second_setup_step_is_refused_while_one_runs` and `the_close_waits_for_an_update_swap_and_cancels_one_not_begun` - New. On the old code they do not compile, because the claims they test do not exist there. Neither was a behaviour test on the old code. - `cargo test` (Windows): 234 passed, 2 ignored. - `cargo fmt --check` and `cargo clippy --tests -- -D warnings` on Windows: clean. - Not compiled here: the Linux and macOS `cfg` lines (`verify_mps_torch`, the macOS branch of `repair_torchvision`, the unix stand-in interpreter). The PR's Rust check jobs cover them. - No real CUDA torch was installed anywhere: the tests use stand-in processes in throwaway folders. ## Guide and languages No Guide entry and no i18n change. No control changed, and no text in `desktop/ui` or `static/js` changed. ## Update path - Changes the desktop shell (Rust), so it needs a new binary, not a payload-only update. - Does not touch `uv.lock`. ## Not changed here - The GPU step has no Skip. Closing the window is the way out. A Skip would have to stop pip and also end the loop over CUDA builds, then put CPU torch back. That is its own change, with its own wording. - `python_stdlib_ok` (in `probe_runtime`) waits on `import encodings` with no time limit. It is now off the main thread, so a hang there no longer freezes the window, but it still has no timeout. - `apply_app_update` checks the archive's hash, then opens it again by path to extract it. `download_app_update` could now run in between and rewrite that file. Only the release URLs it accepts can be written there, and the page hides the download button by then. Hashing and extracting from one open file would close this gap. - A wizard reloaded while the torchvision repair runs on the fast path is refused the repair step. It says so in the console and goes on to start the backend while the first run's pip may still be writing torchvision. That is much narrower than before, but not closed. - A close during `start_backend`'s health wait, on Linux or macOS, leaves the backend running until its parent watchdog sees StemDeck gone, about 1 s later. On Windows the backend's job ends it at once. ## What needs a human On Windows, NVIDIA build, in a throwaway portable folder. No automated test here drives a real WebView2. 1. Unzip the NVIDIA portable build into a new folder, with no `data` folder next to it. 2. Start StemDeck. At the GPU step, check that the line shows pip's progress ("Downloading torch-... N / M MB") while it runs. 3. During that download, close the window. It should close within a few seconds. 4. In Task Manager, check that no `python.exe` and no `pip` from that folder remain. 5. Start again. The GPU step should run again from the start and finish, or fall back to CPU with the usual message.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #405
What changes for the person running setup
logs/setup.log.Why, and how it works
warmup_modelswas a blocking Tauri command. Tauri runs those on the main thread: see tauri-macros 2.6.0,body_blocking, and the Tauri docs.reveal_savedalready avoids this for the same reason.asyncand does its work inspawn_blocking.stop_backendonly signalled the setup child on unix.TerminateJobObject, which ends the warmup and anything it started.skip_model_warmuponly stops the setup child when that child is the model warmup. A stray call can never cut a pip install short.run_setup_childrefuses a second one. Without that, two children would share the one slot that stops them.child_output_streaming: 6 minutes of silence, with a 2-hour cap. It used to be a fixed 30 minutes.app/pipeline/warmup.py.bounded_downloadsfrom fix(separate): bound the separation model download and say why it failed #905:WARMUP_START <model> <n> <count>before each model. The shell passes it on as amodel-warmup-progressevent.WARMUP_OFFLINE <model>after a failure whose cause chain holds a DNS failure, a timeout, or a refused or dropped connection.WARMUP_NO_SPACE <needed> <free>when the disk cannot hold the models. Nothing downloads after it..partialname and moves the file into place only once it is whole. Tested..incompletefile and renames it. Checked by reading its source.load_or_heal. It now also heals audio-separator'sdownload_checks.json, which is written in place and was not covered.load_or_healnow clears the failed load's frames before it deletes anything. audio-separator reads its indexes withjson.load(open(...))and leaves the file open, and Windows will not delete an open file. That also makes the existing index healing ([Bug]: Stuck First Launch #502) work on Windows.How it was verified
Red then green: I ran each new test against the old code first.
cargo test(Windows), 223 passed:closing_the_window_mid_setup_ends_the_setup_childfailed on the old code ("the setup child ran on for 20.2s") and now passes.output_on_stderr_alone_keeps_a_streamed_child_alivefailed on the old code ("timed out after 3 seconds with no output") and now passes.skip_stops_the_model_warmup_and_nothing_elsea_second_setup_child_is_refused_while_one_runstests/test_pipeline_warmup.py:test_a_stalled_model_host_ends_the_model_instead_of_the_step. Old code: a host that never answers held the step for 20 s, until the test server gave up. New code: under 10 s, then the next model runs.test_a_download_stopped_midway_leaves_no_file_that_looks_completehard-kills a real warmup process in the middle of a download from a throttled local server. It checks that only a.partialfile is left, and that the next try downloads the whole file.tests/test_model_cache.py::test_a_half_written_vocal_split_index_is_fetched_againdrives audio-separator's own index reader against a half-writtendownload_checks.json.JSONDecodeError, every time.PermissionErroron Windows.python -m app.pipeline.warmupbehind a proxy on a closed local port. All four models that need a download reportedWARMUP_FAILEDthenWARMUP_OFFLINE. The run took 86 s and left no file behind.tests/e2e/setup-wizard.spec.mjs(new):desktop/uiwith a stand-in Tauri bridge.cargo fmt --check,cargo clippy --tests -- -D warningson Windows, ruff check and format, and the full pytest suite.cfg(unix)lines. The PR's Rust check jobs cover them.Guide and languages
No Guide entry and no i18n change. The setup wizard (
desktop/ui) runs before the backend exists, is English-only, and is not part of the in-app Guide. No control instatic/jschanged.Update path
uv.lock.Not changed here
ensure_torch_deviceandrepair_torchvisionare blocking commands too, so the GPU step holds the main thread in the same way.asyncand usespawn_blocking. It is left for its own PR, so this one stays about the model step.What needs a human
Run one clean first-run setup on Windows with a throttled network, in a throwaway data folder. No automated test here drives a real WebView2, so these need a person:
datafolder next to it.python.exefrom that folder should remain in Task Manager.