Skip to content

fix(setup): make the model step honest and interruptible - #907

Merged
thcp merged 2 commits into
big-releasefrom
fix/b34-setup-wizard-model-step
Nov 3, 2026
Merged

thcp merged 2 commits into
big-releasefrom
fix/b34-setup-wizard-model-step

Conversation

@thcp

@thcp thcp commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Refs #405

What changes for the person running setup

  • The model step says which model it is on: "Getting AI model 2 of 5: beat detection... Elapsed: 12s." It used to show three timed messages.
  • A Skip: download each model when first needed button appears once the first model starts. Skipping stops the download at once, and the wizard goes on to start StemDeck.
  • Before the wizard leaves the page, it says what did not arrive and waits for Continue:
    • Skipped: "AI model download skipped, so these were not downloaded: beat detection, song sections, ... StemDeck works without them: each one downloads the first time a feature needs it." If the separation model is among them, it adds: "so the first separation downloads the separation model before it starts."
    • Offline: "StemDeck could not reach the model download servers (no internet connection, or the connection stopped answering), so these AI models were not downloaded: ..." A model that failed for another reason is listed separately, with a pointer to logs/setup.log.
    • Disk too full: "Not enough free disk space for the AI models: about 1.8 GB needed, 0.4 GB free, so none was downloaded. ..."
    • Before this, a missing model only reached the browser console. The first sign was a feature that quietly did less.
  • Closing the window during the model download now closes it. On Windows it used to hang until the download ended or hit the 30-minute limit.
  • A dead connection now ends each model on its own:
    • The separation and beat models end in about 1.5 minutes.
    • The other models end at their own library's limit.
    • The next model still gets its turn.
  • A slow, healthy download is no longer killed at 30 minutes.

Why, and how it works

  • The hang on close.
    • warmup_models was a blocking Tauri command. Tauri runs those on the main thread: see tauri-macros 2.6.0, body_blocking, and the Tauri docs. reveal_saved already avoids this for the same reason.
    • So for the whole download the window could not handle a close, and no other command could reach the shell, Skip included.
    • It is now async and does its work in spawn_blocking.
  • The child left running on Windows.
    • stop_backend only signalled the setup child on unix.
    • The state now holds the setup child's label and, on Windows, its job (from fix(desktop): keep the portable WebView2 profile in data, end setup children with the app #897). Stopping it calls TerminateJobObject, which ends the warmup and anything it started.
    • skip_model_warmup only stops the setup child when that child is the model warmup. A stray call can never cut a pip install short.
    • Only one setup child runs at a time: run_setup_child refuses a second one. Without that, two children would share the one slot that stops them.
  • The deadline.
    • The warmup is now waited on with child_output_streaming: 6 minutes of silence, with a 2-hour cap. It used to be a fixed 30 minutes.
    • Output on stderr now also counts as activity in that reader. The downloads report progress there (tqdm), and the warmup's stdout stays quiet for as long as one model takes.
    • For pip, this can only make the stall budget more lenient. Its cap is unchanged.
    • Why 6 minutes: I ran the real warmup behind a proxy that never answers. Both streams went quiet for 300 s at most (audio-separator's own read timeout) and for 77 s at most anywhere else.
  • app/pipeline/warmup.py.
    • Every model runs inside bounded_downloads from fix(separate): bound the separation model download and say why it failed #905:
      • torch.hub downloads get the read timeout and the retries.
      • Sockets that set no timeout of their own get the read timeout. That covers Whisper's.
    • New stdout lines:
      • WARMUP_START <model> <n> <count> before each model. The shell passes it on as a model-warmup-progress event.
      • 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.
    • The space check:
      • It adds up each model's size, measured on a finished install, and subtracts what the models folder already holds. So a second setup run on a nearly full disk is not refused.
      • A check that cannot run is logged and does not stop the downloads.
  • A half-downloaded file after Skip. Checked per library:
    • torch.hub (separation and beat models) writes to a .partial name and moves the file into place only once it is whole. Tested.
    • The Hugging Face hub (sections) writes to an .incomplete file and renames it. Checked by reading its source.
    • Whisper checks the file's SHA-256 on every load.
    • The vocal-split model loads through load_or_heal. It now also heals audio-separator's download_checks.json, which is written in place and was not covered.
    • load_or_heal now clears the failed load's frames before it deletes anything. audio-separator reads its indexes with json.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_child failed on the old code ("the setup child ran on for 20.2s") and now passes.
    • output_on_stderr_alone_keeps_a_streamed_child_alive failed on the old code ("timed out after 3 seconds with no output") and now passes.
    • Also new:
      • skip_stops_the_model_warmup_and_nothing_else
      • a_second_setup_child_is_refused_while_one_runs
      • the status and progress parsers
      • the stall budget bound
  • tests/test_pipeline_warmup.py:
    • 7 of the new or changed tests failed on the old warmup.
    • One of them is 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_complete hard-kills a real warmup process in the middle of a download from a throttled local server. It checks that only a .partial file 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_again drives audio-separator's own index reader against a half-written download_checks.json.
    • Old code: JSONDecodeError, every time.
    • With the file listed but the frames not cleared: PermissionError on Windows.
    • Now: the file is fetched again and read.
  • The Python tests use local servers on throwaway ports and a fake free-space function. Proxies are switched off for them. There is no real network and no real model.
  • End to end with the real libraries and no real network:
    • I ran python -m app.pipeline.warmup behind a proxy on a closed local port. All four models that need a download reported WARMUP_FAILED then WARMUP_OFFLINE. The run took 86 s and left no file behind.
    • Behind a proxy that accepts and never answers, every model failed on its own and was reported offline, and the run still reached the last model. It took 758 s, with the stream silences above.
  • tests/e2e/setup-wizard.spec.mjs (new):
    • It serves desktop/ui with a stand-in Tauri bridge.
    • It covers the progress line, Skip and its notice, offline versus other failures, the no-space notice, and going straight on when every model arrived.
    • 3 of the first 4 tests failed on the old wizard.
  • Also run: cargo fmt --check, cargo clippy --tests -- -D warnings on Windows, ruff check and format, and the full pytest suite.
  • Not compiled here: the Linux and macOS 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 in 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

  • ensure_torch_device and repair_torchvision are blocking commands too, so the GPU step holds the main thread in the same way.
  • Their pip progress events therefore probably reach the screen only when they end. Not verified on a running app.
  • Fixing them is the same change as here: make them async and use spawn_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:

  1. Unzip the portable build into a new folder, with no data folder next to it.
  2. Throttle the network, for example with NetLimiter at 100 KB/s for StemDeck's python.exe, or use a slow hotspot.
  3. Start StemDeck. At the model step, check that the line names each model in turn ("Getting AI model 1 of 5: stem separation...").
  4. Close the window during the download. It should close within a few seconds, and no python.exe from that folder should remain in Task Manager.
  5. Start again and press Skip while the line says model 1 of 5. The notice should name the models that were not downloaded. Press Continue: StemDeck should open, and the first Split should show "Downloading the separation model N%".

Thales 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
thcp merged commit a716a05 into big-release Nov 3, 2026
13 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant