Skip to content

fix(desktop): roll back a failed update swap and accept only this repo's assets - #891

Merged
thcp merged 1 commit into
big-releasefrom
fix/b23-updater-swap-safety
Nov 3, 2026
Merged

thcp merged 1 commit into
big-releasefrom
fix/b23-updater-swap-safety

Conversation

@thcp

@thcp thcp commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Three fixes to the desktop in-app updater and app-root lookup (Windows and Linux updater; the app-root change applies to every desktop build). All in desktop/src-tauri/src/main.rs.

What changes for the user

  • A failed update no longer leaves the app without its backend. The swap renames backend/ to backend.old, then the new backend/ into place, then swaps the exe. If any of those renames fails (for example a virus scanner holding a handle), the renames already done are now undone, newest first, so the previous install is left exactly as it was. Before, a failure on the second rename left no backend/ at all, and the next launch's cleanup deleted backend.old, the only copy, so the install needed a full re-download.
  • The launch cleanup keeps backend.old when there is no backend/app. Then it is the only copy of the app (a swap that stopped between its renames, for example a power cut), so it is left on disk instead of deleted.
  • The rename retry budget is about 5 s instead of 1.5 s, so a slower scan of the freshly extracted backend does not fail the update. A missing path is not retried, because it never clears by waiting and the update command runs on the window's thread.
  • Updates are accepted only from this repository's releases. The update URL check allowed any github.com path, so a script in the page could hand the updater a release archive from another repository, together with that repository's own checksum. It now requires https://github.com/stemdeckapp/stemdeck/releases/download/. The two small files read before an update (runtime-version.json and the .sha256 that the archive is checked against) now go through the same check; before, they had no check at all. objects.githubusercontent.com is no longer accepted as a starting URL: the frontend always passes the releases API's browser_download_url (verified against the v0.20.0 and v0.19.1 asset lists), and GitHub's redirect to its asset host is followed by the HTTP client, not by this check.
  • A release build no longer takes its app root from the working directory. A portable StemDeck.exe started from a terminal inside a source checkout ran that checkout's app/ instead of its own install. Only debug builds (cargo tauri dev) look at the working directory now. A release build inside the repo's target/ still finds the checkout through the exe's own path, as before.

How it was verified

  • Red then green: five Rust unit tests, each failing on origin/big-release for the stated reason and passing with the change:
    • the_launch_sweep_keeps_backend_old_when_it_is_the_only_backend (before: backend.old/app deleted)
    • a_failed_backend_install_puts_the_previous_backend_back (before: backend/ missing after the failed rename)
    • only_our_own_release_assets_are_downloadable_as_updates, extended with another repository, a lookalike repo name, a dot-segment path, a non-release path of this repo and an asset-host URL (before: https://github.com/someone/stemdeck/releases/download/v1/x.zip accepted)
    • update_checksum_and_runtime_id_fetches_are_held_to_the_same_urls (before: the small-file fetch went to the network)
    • only_debug_builds_take_the_app_root_from_the_working_directory (before: the checkout was returned with the release flag)
  • Added after review: a_failed_exe_install_puts_the_previous_backend_and_exe_back (a failure at the last rename undoes all four renames).
  • cargo fmt --check, cargo clippy --tests -- -D warnings and cargo test (205 passed) pass on Windows. The Linux and macOS compile is left to the PR's Rust check jobs (no platform-only block was changed).
  • Independent Rust and security reviews of the diff.

Update path

  • This changes the shell binary. It does not touch uv.lock, so the runtime id is unchanged.
  • The updater code that runs is the one in the installed build, so these fixes protect the updates that start from a build containing this change, not the update to it.

Known gaps, not changed here

  • After a failed and rolled-back update the files on disk are intact, but the backend was already stopped and is not restarted, so the window needs an app restart. This was already the case before.
  • download_app_update still takes the expected checksum from the page, so a script in the page could install an older release of this repository. It can no longer point at another repository.

What to verify by hand on an installed build

  1. Real release URLs still pass (Windows portable, and Linux if available). Install a portable build made from this change in a user-writable folder, then let it check for an update against a newer published release. Expected: either the in-app update is offered, or the dialog shows the full-download note for changed Python dependencies. Not expected: an error containing refusing to download an update from. The DevTools console line [catalog] in-app update unavailable: shows the exact reason.
  2. If an in-app update is offered, apply it. Expected: the app restarts on the new version, and on the launch after that backend.old and StemDeck.exe.old are gone from the install folder.
  3. Working directory is ignored. Open a terminal, cd into a StemDeck source checkout, and start the installed StemDeck.exe by its full path. Expected: it opens the installed app with its own settings and library location, not the checkout.
  4. A forced failure of the backend rename (a handle held by a scanner) cannot be set up reliably by hand; the unit test covers the rollback on a temp folder.

…o's assets

A failed rename in the middle of the in-app update swap left the install
with no backend/, and the next launch's sweep deleted backend.old, the only
copy, so the app needed a full re-download. The swap now undoes the renames
already done when one fails, the sweep keeps backend.old while there is no
backend/app, and the rename retry budget is about 5 s (a missing path fails
at once).

The update URL check accepted any github.com path, and the checksum and
runtime id fetches had no check at all, so the page could point the updater
at another repository's release. All three now require
https://github.com/stemdeckapp/stemdeck/releases/download/.

Release builds no longer take the app root from the working directory, so a
portable exe started inside a source checkout runs its own install.
@thcp
thcp merged commit 1c8f39b into big-release Nov 3, 2026
13 checks passed
thcp added a commit that referenced this pull request Nov 3, 2026
…ision (#895)

Three small repairs to how the desktop shell swaps torch for a CUDA
build and checks torchvision on launch. Builds on #891.

## What changes for the user

- **A CUDA install that verified stays put.** After a good CUDA swap,
the shell deletes the backup of the bundled CPU torch. If that delete
stopped part way (a file held open, for example by an antivirus scan),
the half-deleted backup still had its `manifest.txt`, so the next launch
treated it as an interrupted swap and restored half a CPU torch over the
working CUDA one. The commit now removes `manifest.txt` first, so a
delete that stops part way leaves nothing that counts as a backup. The
next snapshot clears that manifest-less leftover before it moves
anything, so a stale entry cannot block the move or be restored later.
- **A damaged RECORD or manifest cannot point outside its folder.**
`owned_entries` takes the first path segment of each line in torch's
`RECORD` and kept it if `site.join(name).exists()`. On Windows that is
true for names that are not entries at all: `C:` (from a line such as
`C:\x,,`) joins to that drive's current directory, and `...` or `. ` are
trimmed to site-packages itself. `discard_installed` would then delete
them. A name is now kept only if `read_dir(site)` lists it, which never
returns any of those. `restore` uses the same rule for the names in
`manifest.txt`, matched against `read_dir` of the backup, because a
manifest written by an older build from such a RECORD could hold them.
- **A missing torchvision is put back.** On a CPU torch install, the
launch check only repaired a CUDA torchvision; a torchvision that was
gone (a pip run that stopped between uninstall and install) was reported
as healthy, and the karaoke split failed at import every time. A missing
`torchvision/version.py` beside `+cpu` torch now gets the same CPU
torchvision install. A `version.py` that exists but cannot be read is
still left alone, so a healthy offline machine is not sent to the
network on every launch. A CUDA torch is not touched.

## How it was verified

- Red then green on Windows, `cargo test`:
-
`torchswap::tests::a_commit_that_stops_part_way_leaves_no_backup_to_restore`
(Windows only: it holds a backup file open with `share_mode(0)`).
Before: `assertion failed: !has_backup(&site)`. With only the commit
change, the second `snapshot` failed on the leftover. After: passes.
-
`torchswap::tests::a_record_line_naming_no_entry_in_site_is_not_owned`.
Before: owned entries included `"C:"`, `"..."`, `". "` and `".. "`.
After: passes. The test only reads, so running it against the old code
was safe.
- `torchswap::tests::a_manifest_line_that_names_no_entry_is_ignored`
(manifest lines `...` and `. `). Before: `restore` removed the test's
site-packages and then failed. After: passes, CPU torch and the
neighbour package are intact. It works in a temp dir, so the red run
could only delete that.
- `tests::a_missing_torchvision_beside_cpu_torch_is_repaired` and the
extended `tests::the_repair_decision_reads_the_installed_tree`. The
helper's signature changed to take `Option<&str>`, so the old code
cannot compile these; the old behaviour is visible in the removed `let
(Some(torch), Some(vision)) = ... else { return "ok" }`.
- The manifest test has no `C:` line on purpose: against the old code it
would make `restore` delete from the drive's current directory, so a red
run is not safe on any machine. The same `read_dir` rule covers it.
- Full `cargo test`: 209 passed, 0 failed, 2 ignored. `cargo fmt
--check` and `cargo clippy --tests -- -D warnings` clean.
- No Python, frontend or i18n change. No new control, so no Guide entry.

## Update path

- This changes the shell binary only. `uv.lock` is unchanged, so the
`runtimeId` gate is not affected and existing installs receive it
through the in-app update.

## What I have to check by hand

On a Windows machine with an NVIDIA GPU, using a throwaway install of
the desktop build:

1. Run setup so the CUDA torch installs and verifies. Confirm
`setup.log` says `verified` and that `.stemdeck-torch-backup` next to
`site-packages` is gone.
2. On a CPU install, delete `site-packages/torchvision` and
`torchvision-*.dist-info`, then launch. Confirm `setup.log` says
`torchvision is missing beside torch ...; installing
torchvision==...+cpu`, and that the karaoke split works afterwards.
3. Launch again and confirm no second repair runs.
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