Skip to content

0.16.1: macOS song sections, settings data loss, songs coming back from the bin, and 14 more fixes - #541

Merged
thcp merged 30 commits into
mainfrom
0.16.1
Sep 1, 2026
Merged

0.16.1: macOS song sections, settings data loss, songs coming back from the bin, and 14 more fixes#541
thcp merged 30 commits into
mainfrom
0.16.1

Conversation

@thcp

@thcp thcp commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Ships the 0.16.1 fix set to main. Eighteen merged changes: sixteen fixes from a pre-release bug scan, one refactor, and one feature.

Why this release exists

#506 fixed the macOS AppleDouble bug but is unreleased, so every macOS 0.16.0 user still has a broken runtime: import matplotlib.pyplot fails, which kills allin1_infer, which kills automatic song sections. Overwriting the 0.16.0 assets would not reach them, the runtime reinstall is gated on a version-string comparison (desktop/ui/setup.js), so a new version is the only route.

What is in it

Data loss and user-visible bugs

Security

Robustness

Presentation

Feature

Verification

ruff check       All checks passed
ruff format      101 files already formatted
pytest tests/    947 passed, 2 failed
npm run test:js  11/11
playwright       85 passed
i18n audit       clean
cargo fmt        clean
cargo clippy     0 errors
cargo test       60 passed, 1 failed

Every fix was individually confirmed present on this branch by grepping for its introduced symbol, rather than trusting merged state, which is how #527 was caught having merged into an orphaned branch instead of the release branch (recovered as #540).

The 3 failures are all pre-existing and reproduce on main:

  • test_all_stems_zip_ogg and test_ogg_is_still_streamed, most likely a local ffmpeg built without libvorbis. Unconfirmed, not filed.
  • a_free_port_is_granted_as_asked, a known parallel-execution flake. It probes port 21000 with std::net::TcpListener (which sets SO_REUSEADDR) then asserts claim_port (socket2, without it) binds the same port. Not equivalent, and there is a TOCTOU gap.

Tested on a real Windows build

The branch was packaged with make-portable.ps1 -CpuOnly and driven by hand. What that covered:

Two things reviewers should know before tagging

make-portable.ps1 now has a parser pass and a real run. It parses clean under Windows PowerShell 5.1, and the full CPU-only Windows package built end to end from this branch, exit code 0. The earlier caveat here is resolved.

Nothing compiles the Linux Rust shell until release time (#531). ci.yml never invokes cargo; Linux Rust is built only by linux-release.yml. #518 ships a change living entirely inside #[cfg(all(unix, not(target_os = "macos")))], which could only be type-checked by temporarily widening the cfg gate. If the release build fails, look there first.

#510 is not manually testable. The update check runs automatically at startup and only surfaces when GitHub has a newer non-prerelease release, so the host allowlist in the installer is not reachable by hand. It exercises itself at release time.

Not included

The Unraid template still pins 0.16.0, deliberately left for a separate decision.

thcp added 26 commits August 31, 2026 17:55
macos-check.yml and windows-check.yml trigger on pull_request and run on
[self-hosted, macOS, ARM64] and [self-hosted, windows, x64] with no fork
guard, on a public repository.

cargo fmt/build/clippy all execute code the pull request supplies: build.rs,
proc-macro crates, a swapped Cargo.toml dependency or [patch], and the test
bodies themselves. permissions: {} limits what the token can reach; it does
nothing about code execution on the machine.

That machine is the same one that builds, signs and uploads every macOS DMG
and runtime pack. Neither check workflow cleans its workspace and the release
workflows only remove .build and dist, so an implant in ~/.cargo, ~/.npm,
~/.rustup or the persistent _work tree would survive straight into the next
release. GitHub's public-repo default only gates first-time contributors, so
one trivial merged pull request is enough to unlock this for a later one.

Gate both jobs on the pull request coming from this repository. The
workflow_dispatch path is unaffected, so a fork's Rust change can still get
its compiler pass when a maintainer asks for one -- which keeps the reason
the trigger exists (#421: cfg-gated Rust merging without ever being compiled,
because ci.yml is entirely ubuntu-latest).

Refs #511
#506 replaced Archive::unpack with a per-entry unpack_in loop so AppleDouble
"._" members could be skipped. That loop reproduced the iteration but not the
two things unpack does around it.

Directories are now applied last, reverse-sorted by path, the way
Archive::_unpack does (tar-rs#242): a directory carries its own mode, so
creating it inline in archive order means a 0o555 member exists before its
contents are written and the next file inside it fails with EACCES. On a
runtime pack that contains one, first-run setup dies with no fallback.
`ditto` and `tar` both record such modes faithfully, so this was reachable.

`destination` is canonicalized before the loop again, which on Windows
supplies the \\?\ prefix so member paths over 260 characters still extract.

Traversal protection was never affected and is unchanged: unpack_in rejects
ParentDir components, strips RootDir/Prefix, and canonicalizes against dst on
every entry, so zip-slip, absolute members and symlink escapes stay blocked.
This was a robustness regression, not a security one.

Deferring costs nothing on a streaming archive: directory entries carry no
data, so only their metadata is applied in the second pass.

The new test builds a .tar.zst whose directory member is 0o555 and whose file
member follows it, which is the order that broke. Verified it fails against
inline creation and passes with the deferral.

Refs #508
_save() called write_text, which truncates first and writes second. A process
that died in between -- app quit, the parent watchdog's SIGTERM, power loss --
left settings.json present and unparsable.

_load() then could not tell that apart from a first run: it caught the parse
error, returned {}, and the next set_*() persisted a single key over both
settings.json and the per-user mirror that exists to protect it. A real user
lost port and allow_network from both copies, with only a warning in the log.
jobs_dir is the worse case, since losing it makes a relocated library look
empty -- precisely what the mirror was added to prevent.

Three changes:

- _atomic_write_json() writes through a uniquely-named same-directory temp and
  replaces, so an interrupted write cannot truncate what was already there.
  _save() and _mirror_settings() both use it; the mirror previously used a
  fixed ".json.tmp" name that two writers could interleave on.

- _load() distinguishes absent from unusable. Absent stays a first run. A file
  that exists but does not parse -- or parses to something other than an
  object -- is moved aside as settings.json.corrupt-<timestamp>, so the bytes
  survive for diagnosis instead of being overwritten by the next save, and the
  mirror is consulted before falling back to defaults.

- Settings recovered from the mirror are written straight back to the primary.
  Recovering only into memory would last until the next start, which would
  read a now-absent primary and quietly return to defaults again.

The tests cover the exact sequence that lost data: a torn write, then a
restart. Four of them fail against the old _load and pass against this one.

Refs #509
…oot with a bad registry

Two ways the registry stranded state.

A cancel arriving between the worker's _pop_next() and _set_running() found
the job in neither the queue nor the running slot. discard() returned False so
cancel_job never finalised it, running_id() was None so nothing was
terminated, and the worker then dropped it silently. The job stayed at
"queued" forever: absent from the queue view, still counted by pending_count
against the capacity limit, its uploaded source (up to 400 MB) never freed,
and -- because "queued" is persisted -- re-queued on every restart.

The worker is the sole consumer and owns the job once it has popped it, so it
now closes out the cancellation itself via _finalise_dropped_job(). Extracted
rather than inlined so the drop path can be tested directly, and so an
already-terminal job is explicitly left alone rather than having a real result
rewritten.

Separately, restore() caught (OSError, JSONDecodeError, TypeError,
ValueError). A registry.json that was valid JSON but not an object -- a
top-level list, null, a bare string -- makes _migrate call data.get() and
raise AttributeError, which that tuple does not name. restore() runs at import
time, so the backend simply never started, with no self-healing path and no
way for a user to recover short of deleting the file by hand. Orphan recovery
and the persist that follows it sat outside the guard entirely, so an
unreadable jobs_dir was fatal at startup for the same reason.

Both are now caught broadly and logged. A registry we cannot use should cost
the user their job list, not their app.

The cancel test drives the real worker loop rather than calling the helper, so
it also fails if the worker stops calling it. Verified: reverting the worker's
call fails that test, and reverting restore()'s except fails five others.

Refs #520
claim_sse_slot() runs in the handler and release_sse_slot() lived in the
stream's finally. An async generator that is never started never runs its
finally, so a client that disconnected before the response body began held a
slot for the life of the process: StreamingResponse raises inside
stream_response on its first send(), before __anext__ is ever called, and the
generator body never executes.

Two hundred of those and every job-progress stream and the queue stream answer
503 with nothing actually connected, until a restart. It does not need malice
either -- a flaky network or a rapidly reloaded page leaks slots the same way,
just slower.

Claiming cannot simply move inside the generator: by the time it runs the
response headers have gone out and there is no status code left, so hitting
the cap could no longer answer 503. SseSlot keeps the claim in the handler and
makes release idempotent, with __del__ as the backstop for the never-started
case -- collecting the generator collects the closure holding the slot.

_held is assigned before the claim because __del__ runs on a half-built object
too: a refused claim would otherwise raise AttributeError out of __del__
rather than releasing nothing. The test for that caught it.

queue.py takes the same guard, so both streams that share the budget also
share the fix.

Verified: removing the __del__ backstop fails the never-started test.

Refs #513
…loop

Four related holes, all reachable with one unauthenticated request.

The trim range had no ceiling. `end` is a float that reaches
np.zeros(int(round(duration * sample_rate))) in the click renderer, so
?start=0&end=20000&count_in=1 asked for roughly 7 GB, and another 7 GB in the
int16 conversion. A larger value raised MemoryError inside a blanket except,
which silently shipped the export with no click rather than failing.
_validate_trim_range bounds it by the job's own recorded duration, with a
six-hour backstop for a job whose duration was never recorded, and a second of
slack because ffprobe's duration can sit a hair under the decoded length.

The click render ran synchronously inside async handlers, so all of that
allocation and a Python loop over every beat blocked the event loop -- every
SSE progress stream and the queue worker stalled behind it. It goes through
asyncio.to_thread now. The buffer is float32 rather than float64: the output is
16-bit PCM, so the extra mantissa was never audible and a long export was
allocating twice what it needed.

The click cache pruned without keep=, so a render larger than the cache budget
evicted itself the instant it was written and ffmpeg was handed a missing -i.
That is the #482 bug, unfixed on this path. The mixdown write had the same gap
against a concurrent render's prune.

The body-size guard was scoped to paths ending /sections or /beats, leaving
/api/search, /api/playlist, /api/settings and the JSON branch of /api/jobs
uncapped -- Starlette buffers the whole body, then json.loads runs it on the
event loop. A 200 MB body to /api/search stalled every other request with no
valid job or prior state needed. It now applies by method, exempting multipart
uploads, which stream to disk under their own 400 MB limit. A chunked request
with no Content-Length used to skip the check entirely and fall through to an
unbounded request.body(); it gets a 411 now.

Six of the eight new tests fail against the old code.

Refs #512
… before the CPU retry

The persistent worker teardown sat after the finally block, not inside it. An
exception out of the read loop -- proc.stderr.read(1) raising OSError when the
API thread's terminate() races the read, or _set() raising -- propagated
without it, so _worker still held the process and the next job's _get_worker()
saw a matching device and a live poll() and reused a worker whose CUDA state
followed an exception. ml-pipeline.md is explicit that any non-success must
tear it down.

A second path missed it entirely: the pipe check raises before the try, so a
worker that came back without stdin/stderr stayed cached and would be handed
to every subsequent job. Found while writing the test for the first one.

separate() also had no cancel check between its two attempts. The rmtree of a
multi-GB partial result takes seconds and nothing is registered for cancel
during it, so a cancel landing there was invisible and the full CPU pass ran to
completion -- 10+ minutes -- before JobCancelled was finally raised. The UI
showed "Cancelling" throughout.

_kill_worker now reaps after kill(). Without communicate() a worker wedged in
an uninterruptible CUDA call becomes a zombie whose pipes close only when the
Popen refcount happens to drop; vocal_split.py already pairs the two.

An entry-point cancel check was tried and removed. It broke
test_cancel_kills_worker_next_job_spawns_fresh, which verifies that a cancelled
job tears the worker down so the next one spawns fresh -- a check that never
spawns has no worker to tear down, and that test encodes the rule this change
is meant to protect. The queue worker already gates dispatch on cancellation,
so the fallback check covers the gap that was actually reported.

Verified: reverting each half fails its test.

Refs #514
Three stages ignored cancel entirely.

The local-upload ffmpeg calls used subprocess.run(), which cannot be
interrupted: POST /cancel sets the flag but nothing looks at it until the call
returns. Cancelling during "Preparing audio..." on a 400 MB .mp4 was a no-op
for up to TIMEOUT_FFMPEG per call, twice over on that path since it runs both
the video extract and the transcode. Both go through _run_registered_ffmpeg
now, mirroring collect._run_ffmpeg, which registers for exactly this reason.

Only demucs_worker armed the parent-death watchdog, and only separate.py
exported STEMDECK_PARENT_PID. A Force-Quit during a vocal split therefore
orphaned an onnxruntime process holding the GPU with nobody to collect the
result, and a section pass outlived the parent whose TIMEOUT_SECTIONS was its
only bound. The watchdog moves to app/core/process.py -- where process_exists
already lived for it -- and all three workers arm it, all three spawn sites
export the pid. Poll interval stays 1.0s, matching what demucs_worker used.

A running vocal split was uncancellable by construction: cancel_job returns
early for a done job, and a split only ever runs on a done job, so the flag was
never even set while the split held _pipeline_lock and stalled the import
queue for its full duration. Cancel now terminates the worker for that case;
the split's own error path marks it failed and releases the lock.

_run_registered_ffmpeg deregisters in a finally, so a failure cannot leave a
stale entry that a later cancel would terminate on the wrong job.

Two existing test files needed updating rather than fixing:
test_worker_parent_watchdog targeted demucs_worker._arm_parent_watchdog, now
app.core.process.arm_parent_watchdog; test_video_status stubbed
subprocess.run, which the extract no longer calls. Behaviour is unchanged in
both.

Refs #519
Fixes #511.

`macos-check.yml` and `windows-check.yml` trigger on `pull_request` and
run on `[self-hosted, macOS, ARM64]` / `[self-hosted, windows, x64]`
with no fork guard, on a public repository.

`cargo fmt`, `cargo build` and `cargo clippy` all execute code the PR
supplies: `build.rs`, proc-macro crates, a swapped `Cargo.toml`
dependency or `[patch]`, and the test bodies. `permissions: {}` limits
the token, not code execution.

That runner is the same machine that builds, signs and uploads every
macOS DMG and runtime pack. Neither check workflow cleans its workspace,
and the release workflows only `rm -rf .build dist` -- so an implant in
`~/.cargo`, `~/.npm`, `~/.rustup` or the persistent `_work` tree
survives into the next release. GitHub's public-repo default only gates
*first-time* contributors, so one trivial merged PR unlocks this for a
later one.

## Change

Both jobs gain:

```yaml
if: >-
  github.event_name != 'pull_request' ||
  github.event.pull_request.head.repo.full_name == github.repository
```

## Why not move to GitHub-hosted runners

That would lose the reason the trigger exists. `ci.yml` is entirely
`ubuntu-latest`, and a Linux runner cannot type-check code behind
`#[cfg(target_os = "macos")]` or `#[cfg(windows)]` at all -- it is
stripped before semantic analysis. #421 added ~600 lines of mostly
cfg-gated Rust and every CI check passed without compiling any of it.

`workflow_dispatch` is unaffected, so a fork's Rust change can still get
a real compiler pass when a maintainer triggers one.

## Verification

Both files parse and the guard resolves as intended:

```
macos-check.yml   if: github.event_name != 'pull_request' || ...head.repo.full_name == github.repository
windows-check.yml if: github.event_name != 'pull_request' || ...head.repo.full_name == github.repository
```

Behaviour: `workflow_dispatch` runs (first clause), same-repo PRs run
(second clause), fork PRs are skipped.

Worth pairing later with a workspace clean at the start of the release
workflows, so a compromised check run cannot persist into a build. Not
done here to keep this change minimal.
…tor (#523)

Fixes #508. Follow-up to #506 -- a regression introduced by that PR.

#506 replaced `Archive::unpack` with a per-entry `unpack_in` loop so
AppleDouble `._` members could be skipped. The loop reproduced the
iteration but not the two things `unpack` does around it.

## 1. Directory deferral

`Archive::_unpack` (tar-0.4.45 `archive.rs:245-265`):

```rust
// Delay any directory entries until the end (they will be created if needed by
// descendants), to ensure that directory permissions do not interfere with
// descendant extraction.
directories.sort_by(|a, b| b.path_bytes().cmp(&a.path_bytes()));
```

A directory carries its own mode. Created inline in archive order, a
`0o555` member exists before its contents are written, and the next file
inside it fails `File::create` with EACCES --
`unpack_without_apple_double` returns `Err` and first-run setup dies
with no fallback. `ditto` and `tar` both record such modes faithfully,
so this was reachable, not theoretical.

Deferring costs nothing on a streaming archive: directory entries carry
no data, so the second pass applies metadata only.

## 2. `destination` canonicalization

Restored before the loop. On Windows this supplies the `\\?\`
extended-length prefix so member paths over 260 characters still
extract.

## Not affected: traversal protection

`unpack_in` rejects `ParentDir` components, strips `RootDir`/`Prefix`,
and calls `validate_inside_dst` -- which canonicalizes on every entry --
so zip-slip, absolute members and symlink escapes were blocked
throughout. This was a robustness regression, not a security one.

## Impact

Latent on shipped artifacts. The published 0.16.0 pack has no
restrictive directory members: extracting it end to end produced 27,797
files and 0 sidecars. Windows takes the zip path for updates, so the
long-path loss was theoretical there. Fixed because the next pack is not
guaranteed to be as forgiving.

## Verification

New test `extract_tar_archive_survives_a_read_only_directory_member`
builds a `.tar.zst` whose `0o555` directory member precedes its file
member -- the order that broke -- and asserts the file extracts and the
directory keeps its archived mode.

Confirmed the test is not vacuous: with directories created inline it
**fails**; with deferral it **passes**.

```
cargo fmt --check   OK
cargo clippy        0 errors
cargo test          59 passed, 0 failed
```
…gs (#524)

Fixes #509. This is the bug that lost real settings: a user's
`settings.json` went from five keys to one, dropping `port` and
`allow_network` from both the file and its backup.

## Cause

`_save()` used `write_text`, which truncates first and writes second:

```python
_SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8")
```

Every other persistence path in the codebase already used temp+rename --
including `_mirror_settings()` eleven lines below, whose comment says "a
torn write here would be restored verbatim into the user's next
install", and `registry.persist()`. **The primary file was the only
unprotected writer.**

The sequence:

1. `_save()` truncates; the process dies mid-write. The file now exists
and does not parse.
2. `_load()` caught the parse error and returned `{}` --
**indistinguishable from a first run**.
3. The next `set_*()` persisted a one-key file, then mirrored it **over
the good backup**.

`jobs_dir` is the worst case: `config._stored_jobs_dir()` falls back to
the default and a relocated library looks empty, exactly the failure the
mirror was added to prevent.

## Changes

**`_atomic_write_json()`** -- uniquely-named same-directory temp, then
`replace`. Used by both `_save()` and `_mirror_settings()`. The mirror
previously used a fixed `.json.tmp` name that two concurrent writers
could interleave on; that goes away too.

**`_load()` distinguishes absent from unusable:**

```
parses             -> use it
absent             -> defaults (genuine first run)
exists, unparsable -> rename to settings.json.corrupt-<timestamp>
                      seed from mirror if present, else defaults
```

Renaming rather than overwriting keeps the bytes for diagnosis. Deleting
them, or letting the next save clobber them, destroys the only evidence
of what the user had configured.

A file that is valid JSON but not an object (`[1,2,3]`) is treated as
unusable too -- returning it would make every later `.get()` raise.

**Recovered settings are written straight back to the primary.**
Recovering only into memory would last until the next start, which would
read a now-absent primary and silently return to defaults.

## Verification

New `tests/test_settings_durability.py`, 8 tests covering the exact loss
sequence.

Confirmed not vacuous: reverting `_load()` alone makes **4 of them
fail**:

```
FAILED test_an_unreadable_file_is_not_mistaken_for_a_first_run
FAILED test_an_unreadable_file_is_kept_for_diagnosis
FAILED test_recovered_settings_are_written_back_immediately
FAILED test_a_non_object_settings_file_is_treated_as_unusable
```

```
ruff check       All checks passed
ruff format      already formatted
pytest tests/    901 passed, 2 failed
```

The 2 failures are `test_stems_api.py::test_all_stems_zip_ogg` and
`::test_ogg_is_still_streamed`. **Both fail identically on `0.16.1`
without this change** -- verified by checking out the base branch and
re-running. They look like a local ffmpeg build without libvorbis, not a
code defect, but worth a look separately.

## Note for reviewers

`test_a_failed_write_leaves_the_previous_settings_intact` deliberately
avoids `monkeypatch.undo()`. The same `monkeypatch` instance carries
conftest's `_SETTINGS_PATH` isolation, so undoing mid-test points
assertions at the developer's real settings file -- which is exactly
what happened while writing this, and is worth knowing about for any
future test in this area.
…oot with a bad registry (#526)

Fixes #520. Prerequisite for #521 -- that fix builds on `restore()`'s
hardening here.

## 1. A cancel in the pop-to-claim window stranded the job forever

The worker pops at `jobqueue.py:213` and only claims with
`_set_running()` at `:229`. In between, the job is in neither the queue
nor the running slot.

A cancel landing there:

1. `cancel_job` sets `cancel_requested = True`
2. `jobqueue.discard()` returns **False** -- already popped -- so it
never sets `status="cancelled"`, never calls `cleanup_job_dir`, never
persists
3. `running_id()` is still `None`, so the terminate branch is skipped
4. The worker sees the flag and `continue`s -- "drop it silently"

The job stayed at `"queued"`:

- **Permanent capacity loss** -- `pending_count` counts `status ==
"queued"`, so `register_if_capacity` counts it forever
- **Invisible** -- not in `_queue`, not running, so absent from the
queue view
- **Disk never freed** -- a queued upload holds its source, up to 400 MB
- **Resurrected every restart** -- `"queued"` is in `_RESUMABLE`, hence
`_PERSISTED`

The worker is the sole consumer and owns the job once popped, so it now
finalises the cancellation itself. Extracted as
`_finalise_dropped_job()` rather than inlined, so the drop path is
directly testable and so an already-terminal job is explicitly left
alone rather than having a real result rewritten.

## 2. A malformed registry.json stopped the backend booting

`restore()` caught `(OSError, json.JSONDecodeError, TypeError,
ValueError)`. A `registry.json` that is valid JSON but not an object --
`[1,2,3]`, `null`, `"a string"` -- makes `_migrate` call `data.get()`
and raise **`AttributeError`**, which that tuple does not name.

`restore_registry(JOBS_DIR)` runs at module scope in `app/main.py`, so
the import fails and the backend never starts. No self-healing, no
user-visible recovery short of deleting the file by hand.

Orphan recovery and the trailing `persist()` sat **outside** the try
entirely, so an unreadable `jobs_dir` was fatal at startup for the same
reason.

Both are now caught broadly and logged. A registry we cannot use should
cost the user their job list, not their app.

## Verification

New `tests/test_registry_resilience.py`, 11 tests.

Confirmed not vacuous, both halves independently:

- Reverting the worker's `_finalise_dropped_job` call ->
`test_cancel_between_pop_and_claim_finalises_the_job` **fails**
- Reverting `restore()`'s except back to the tuple -> **5 tests fail**

The cancel test deliberately drives the real `_worker_loop` rather than
calling the helper, so it also catches the worker simply not calling it
-- an earlier draft called the helper directly and did **not** catch
that.

```
ruff check       All checks passed
ruff format      94 files already formatted
pytest tests/    904 passed, 2 failed
```

The 2 failures are `test_stems_api.py::test_all_stems_zip_ogg` and
`::test_ogg_is_still_streamed`, which fail identically on `0.16.1`
without this change (verified on the base branch). Local ffmpeg without
libvorbis, most likely -- unrelated, and not filed.
download_app_update took its URL straight from the WebView and verified the
download against a SHA-256 supplied by the same caller, so the checksum proved
the bytes arrived intact -- not that they came from us. apply_app_update then
extracts that archive over StemDeck's own executable and backend/ and
relaunches.

Reachability is narrower than it first looks: in the normal flow the URL is
appAsset.browser_download_url from the GitHub API, no XSS was found, and the
CSP is script-src 'self'. But the page is served over http by the Python
backend, which Tauri treats as a remote origin, and these app-defined commands
are not ACL-gated by the capability config -- both facts the code already
documents. A LAN attacker reaches this once network access is enabled.

validate_release_url pins the host to github.com and
objects.githubusercontent.com (GitHub redirects release assets to the latter)
and requires https, so bytes cannot be swapped in flight on a network where
the page itself is already plain http.

Deliberately a new function rather than the existing validate_download_url:
that one permits only 127.0.0.1/localhost, serves a different caller, and
would reject every legitimate release URL.

apply_app_update also re-verifies now. It trusted that whatever sat at the
archive path was what download_app_update had approved, so anything able to
write into data/downloads between the two calls was extracted unchecked. The
verified digest is recorded next to the archive and re-checked before
extraction.

The test covers host lookalikes (github.com.evil.example, notgithub.com) as
well as plain rejection, since a substring check would pass those.

Refs #510
)

Fixes #510.

## The problem

`download_app_update` took `plan.app_url` straight from the WebView and
verified against `plan.app_sha256` from the same caller. A checksum
supplied alongside the URL proves the bytes arrived intact, not that
they came from us -- an attacker supplies both. `apply_app_update` then
extracts that archive over `StemDeck.exe` and `backend/` and relaunches.

## Reachability

Narrower than "remote code execution", but real:

- In the normal flow the URL is `appAsset.browser_download_url` from the
GitHub API
- No XSS was found in the frontend audit; CSP is `script-src 'self'`
with no `unsafe-inline`
- **However**, `catalog.js:2386-2393` documents that the page is served
over http by the Python backend -- a remote origin to Tauri -- and that
these app-defined commands are **not ACL-gated** by the capability
config. A LAN attacker reaches this once "Make StemDeck available on
your network" is on.

So: missing defence-in-depth on a code-execution path.

## Change

```rust
const RELEASE_ASSET_HOSTS: [&str; 2] = ["github.com", "objects.githubusercontent.com"];

fn validate_release_url(url: &str) -> Result<(), String> { ... }
```

Both hosts are needed -- GitHub redirects release assets to
`objects.githubusercontent.com`. `https` is required, so bytes cannot be
swapped in flight on a network where the page itself is already plain
http.

**Deliberately a new function, not `validate_download_url`.** #510
originally suggested reusing that one; it permits *only*
`127.0.0.1`/`localhost`, serves a different caller, and would reject
every legitimate release URL.

**`apply_app_update` re-verifies.** It trusted that whatever sat at the
archive path was what `download_app_update` approved, so anything able
to write into `data/downloads` between the two calls was extracted
unchecked. The verified digest is recorded beside the archive and
re-checked before extraction.

## Verification

New test `only_our_own_release_assets_are_downloadable_as_updates`,
covering host lookalikes a naive substring check would pass:

```
github.com.evil.example   rejected
notgithub.com             rejected
http://github.com/...     rejected (scheme)
file:///etc/passwd        rejected
```

```
cargo fmt --check   OK
cargo clippy        0 errors
cargo test          58 passed, 1 failed
```

The 1 failure is `tests::a_free_port_is_granted_as_asked`. **It fails
3/3 on `0.16.1` without this change too** -- verified by checking out
the base branch and running the full suite three times. It is the known
parallel-execution flake: it probes port 21000 with
`std::net::TcpListener` (which sets `SO_REUSEADDR`) then asserts
`claim_port` (socket2, no `SO_REUSEADDR`) binds the same port. Currently
failing 100% on this machine; unrelated to this PR and not filed.

## Left for a follow-up

`fetch_text` (`main.rs:943-966`) will still GET any URL the WebView
passes as `runtimeIdUrl`/`appShaUrl`. Weaker than this -- the body is
not returned on success, only reachability and parse outcome leak -- but
it is the same class and would suit the same allowlist. Kept out of
scope here.
child_output_with_timeout read stdout and stderr only once try_wait() had
reported an exit. A child that outruns the OS pipe buffer blocks in write()
with nobody reading, so it never exits, so try_wait() never reports an exit,
and the call ends at the timeout with no output at all.

warmup_models pipes both streams, and its model downloads emit tqdm progress
to stderr in proportion to how long they take rather than how large they are.
So the failure lands on slow connections -- the users warmup exists to spare a
mid-pipeline download.

Each stream now drains on its own thread. Both are needed: draining one and
then the other reintroduces the deadlock on whichever is second. The readers
are joined rather than detached on the timeout path too, since killing the
child closes its ends and dropping the handles would leak two threads per
timeout.

command_output_with_timeout had the same shape and now delegates, so the two
cannot drift apart again.

The test writes 512 KiB to each stream, comfortably past any pipe buffer.
Against the old implementation it fails after burning the full 20-second
timeout; with concurrent draining it completes in 0.11s. Worth noting because
a measurement of a real cold-cache warmup on a fast connection showed only
8,360 bytes of stderr -- under the buffer, which is why this had not been seen
in practice rather than why it could not happen.

Refs #516
#529)

Fixes #516. **Targets `fix/510-updater-url-allowlist`** -- both touch
`main.rs` and the plan sequences the Rust changes. Merge #528 first.

## The problem

`child_output_with_timeout` read the pipes only after `try_wait()`
reported an exit:

```rust
loop {
    if let Some(status) = child.try_wait()? {
        // read_to_end on stdout/stderr -- only reachable once the child is gone
    }
    thread::sleep(Duration::from_millis(100));
}
```

A child that outruns the OS pipe buffer blocks in `write()` with nobody
reading, so it never exits, so `try_wait()` never reports an exit. The
call ends at the timeout with no output.

`warmup_models` pipes both streams (`main.rs:1414-1415`) and its model
downloads emit tqdm progress to stderr in proportion to how **long**
they take, not how large they are. The failure therefore lands on slow
connections -- exactly the users warmup exists to spare a mid-pipeline
download.

## Change

Each stream drains on its own thread. Both are required: draining one
then the other reintroduces the deadlock on whichever is second. Readers
are joined rather than detached on the timeout path, since killing the
child closes its ends and dropping the handles would leak two threads
per timeout.

`command_output_with_timeout` had the identical shape and now delegates,
so the two cannot drift apart again.

## Verification -- the deadlock is reproduced, not assumed

New test `a_chatty_child_is_drained_rather_than_deadlocked` writes 512
KiB to each stream.

| implementation | result |
|---|---|
| read-after-exit (old) | **FAILED** -- `"chatty child timed out after
20 seconds"`, took 20.09s |
| concurrent draining (this PR) | passed in 0.11s |

Worth recording: when I filed #516 I measured a real cold-cache warmup
on a fast connection and found only **8,360 bytes** of stderr -- under
the buffer. That is why this had not been hit in practice, not why it
could not happen. The test settles it.

```
cargo fmt --check   OK
cargo clippy        0 errors
cargo test          59 passed, 1 failed
```

The 1 failure is `tests::a_free_port_is_granted_as_asked`, the known
parallel-execution flake, failing 3/3 on the base branch without this
change.

## One thing to check in review

While rewriting `command_output_with_timeout` I initially sliced out the
`#[cfg(windows)]` attribute above `hide_console_window`, which `cargo
check` caught immediately. Restored, and both `hide_console_window`
definitions keep their cfg guards -- worth a glance since a
Linux/macOS-only compile would not exercise the Windows one.
download_linux_ffmpeg fetched a tarball from a rolling URL on a single host,
extracted it with the system tar, marked the binaries executable and ran them,
with no integrity check of any kind. Windows verifies against BtbN's published
checksums.sha256 and macOS against four pinned hashes; Linux verified nothing.
verify_ffmpeg only proves the binary runs and has the encoders we need, which
says nothing about where it came from.

Pinned rather than verified against upstream's .md5 companion, which was the
obvious move but is worth less than it looks: MD5 is broken for collisions,
and the companion is served by the same host as the tarball, so anyone able to
replace one can replace the other. It evidences corruption, not authenticity.

The pinned hash was computed from the artifact whose MD5 matched upstream's
published 7fa72b652e19bf84c9461e332ea1cdf3, so this pin is anchored to what
upstream currently vouches for.

The URL is a rolling one, so this needs a manual bump when upstream publishes
a new build; the current one is dated 2024-08-24. A stale pin fails closed
with a checksum error rather than silently accepting whatever arrives.
STEMDECK_FFMPEG_URL still overrides, and skips the check -- an override points
somewhere we cannot have a hash for, so vouching for it is the caller's
business, matching how the macOS override already behaves.

Note this code cannot be compiled on macOS or by ci.yml, which does not build
the Rust shell at all. It was type-checked by temporarily widening its cfg
gate to #[cfg(unix)]; cargo check reported no errors.

Refs #518
Fixes #518. **Targets `fix/516-drain-child-pipes`** -- last of the
sequential Rust changes. Merge #529 first.

## The problem

`download_linux_ffmpeg` fetched a tarball from a rolling URL on a single
non-CDN host, extracted it with the system `tar`, marked the binaries
executable and ran them -- with **no integrity check of any kind**.

- **Windows** verifies against BtbN's published `checksums.sha256`
- **macOS** verifies against four pinned hashes
- **Linux** verified nothing

`verify_ffmpeg` only proves the binary runs and has the encoders
StemDeck needs. That says nothing about where it came from.

## Why a pinned SHA256 rather than upstream's .md5

The obvious move was the `.md5` companion, matching the Windows shape. I
checked it and it is worth less than it looks:

- **MD5 is broken for collisions**
- **The companion is served by the same host as the tarball.** Anyone
able to replace one can replace the other. It evidences corruption, not
authenticity.
- Upstream publishes no `.sha256` (confirmed: 404)

So this pins our own SHA256, the way the macOS path already does. The
pinned value was computed from the artifact whose MD5 matched upstream's
published `7fa72b652e19bf84c9461e332ea1cdf3`, so the pin is anchored to
what upstream currently vouches for.

```
url    .../ffmpeg-release-amd64-static.tar.xz   (last-modified 2024-08-24)
md5    7fa72b652e19bf84c9461e332ea1cdf3          (matches upstream)
sha256 abda8d77ce8309141f83ab8edf0596834087c52467f6badf376a6a2a4c87cf67
```

**Trade-off:** the URL is rolling, so this needs a manual bump when
upstream publishes a new build. A stale pin fails closed with a checksum
error rather than silently accepting whatever arrives, which is the
right direction to fail. `STEMDECK_FFMPEG_URL` still overrides and skips
the check -- an override points somewhere we cannot have a hash for,
matching how the macOS override already behaves.

## Verification -- and a gap reviewers should know about

**This code cannot be compiled on macOS**, and **`ci.yml` does not build
the Rust shell at all** -- Linux Rust is compiled only by
`linux-release.yml`, at release time. So a type error here would first
surface during a release.

To check it locally I temporarily widened the cfg gate on the two
constants and `download_linux_ffmpeg` from `#[cfg(all(unix,
not(target_os = "macos")))]` to `#[cfg(unix)]` and ran `cargo check`:

```
error count: 0
```

Then restored all 7 gates (verified by count).

```
cargo fmt --check   OK
cargo clippy        0 errors
cargo test          59 passed, 1 failed (the known port flake, fails on base too)
```

## Worth filing separately

There is no `linux-check.yml`. #421 added `macos-check.yml` and
`windows-check.yml` precisely because `ci.yml` is 100% ubuntu and could
not compile cfg-gated code for those platforms -- but nothing compiles
the **Linux** Rust either, despite the runner being ubuntu. That is a
real hole and it is what made this PR awkward to verify. Happy to open
an issue.
…ly (#532)

Fixes #513. Independent of the other open PRs; branches off `0.16.1`.

## The leak

`claim_sse_slot()` runs in the handler; `release_sse_slot()` lived in
the stream's `finally`.

**An async generator that is never started never runs its `finally`.**
`StreamingResponse.__call__` awaits `stream_response`, whose first
statement is `await send({"type": "http.response.start", ...})`. If the
client is already gone that raises `ClientDisconnect` *before* `async
for` ever calls `__anext__` -- so the generator body never executes and
the slot is held for the life of the process.

Connect to `/api/jobs/<id>/events`, RST immediately, repeat 200 times:
every progress stream and the queue stream answer 503 with zero live
connections, until a restart. `_sse_active` has no ceiling reset and no
reconciliation.

This does not need malice. A flaky network, or a page reloaded rapidly,
leaks slots the same way.

## Why not just claim inside the generator

That was the obvious fix and it does not work. By the time the generator
runs, the response headers have gone out -- there is no status code left
to send, so hitting the cap could no longer answer 503. The claim has to
stay in the handler.

## The fix

`SseSlot` keeps the claim in the handler and makes release
**idempotent**, with `__del__` as the backstop for the never-started
case: collecting the generator collects the closure holding the slot.
The stream still releases on its normal path, and the two cannot
double-count.

`queue.py` takes the same guard, so both streams sharing the budget
share the fix.

## A bug the tests caught in the fix itself

`_held` is assigned **before** the claim:

```python
def __init__(self) -> None:
    self._held = False
    claim_sse_slot()   # may raise 503
    self._held = True
```

`__del__` runs on a half-built object too. Without that first line, a
refused claim raised `AttributeError` out of `__del__` instead of
releasing nothing -- `test_a_refused_claim_holds_nothing` failed on the
first run and found it.

## Verification

New `tests/test_sse_slot_budget.py`, 5 tests. Confirmed not vacuous --
removing the `__del__` backstop fails
`test_a_slot_dropped_without_release_is_reclaimed`.

```
ruff check       All checks passed
ruff format      94 files already formatted
pytest tests/    898 passed, 2 failed
```

The 2 failures are the pre-existing ogg pair, which fail identically on
`0.16.1`.

## One caveat for reviewers

`_sse_active` is a plain module-level int with no lock. That predates
this change, and everything normally touches it from the event loop --
but `__del__` can run on whichever thread triggers collection, so the
backstop is a slightly wider surface than the existing code had. Given
the value only ever moves by one and the alternative was a permanent
leak, that felt like the right trade; worth a second opinion. A lock
around both helpers would close it if you want belt and braces.
…loop (#533)

Fixes #512. Independent; branches off `0.16.1`.

Four related holes, each reachable with a single unauthenticated
request.

## 1. The trim range had no ceiling

`end: float | None = Query(default=None, gt=0)` -- no `le=`, and the
only cross-check was `start >= end`. It reaches:

```python
buf = np.zeros(int(round(duration * sample_rate)), dtype=np.float64)
```

`?start=0&end=20000&count_in=1` asks for ~7 GB, plus another ~7 GB in
the int16 conversion. A larger value raises `MemoryError` **inside a
blanket `except`**, which silently shipped the export with no click
rather than failing.

`_validate_trim_range` bounds it by the job's own `duration_sec`, with a
six-hour backstop for a job whose duration was never recorded, and 1s of
slack -- ffprobe's duration can sit a hair under the decoded length and
the UI legitimately asks for the very end of a track. Wired into all
three handlers that accept a range.

## 2. The render blocked the event loop

`_click_lane` was a plain `def` called inside `async def`. All of the
above allocation plus a Python loop over every beat ran on the loop, so
every SSE progress stream and the queue worker stalled behind it. Now
`asyncio.to_thread`.

Buffer switched to **float32**: output is 16-bit PCM, so the extra
mantissa was never audible, and a long export was allocating twice what
it needed.

## 3. The click cache evicted its own render

`_prune_mixdown_cache(_CLICK_CACHE_DIR)` was called **without `keep=`**.
A render larger than the 500 MB budget evicted itself the instant it was
written, and ffmpeg was handed a missing `-i`. That is the #482 bug,
unfixed on this path. The mixdown write had the same gap against a
*concurrent* render's prune; both now pass `keep=`.

## 4. The body guard covered two paths and had a chunked bypass

Scoped by path suffix (`/sections`, `/beats`), leaving `/api/search`,
`/api/playlist`, `/api/playlist/preview`, `/api/settings` and the JSON
branch of `/api/jobs` uncapped. A 200 MB body to `/api/search` stalled
every other request.

Now applied by **method**, exempting multipart uploads (they stream to
disk under their own 400 MB limit). A chunked request with no
`Content-Length` made `declared` `None` and skipped the check entirely
-- it gets a **411** now rather than falling through to an unbounded
`request.body()`.

## Verification

New `tests/test_request_body_limits.py`, 8 tests. Confirmed not vacuous
-- reverting the guard scope and the trim bound fails **6 of 8**.

```
ruff check       All checks passed
ruff format      94 files already formatted
pytest tests/    901 passed, 2 failed (pre-existing ogg pair)
```

## Two review notes

**`_EDITOR_BODY_LIMIT` was renamed to `_JSON_BODY_LIMIT`** since it is
no longer editor-specific. `tests/test_jobs_api.py` imported it directly
and was updated.

**411 is an unusual status to return.** The alternative was streaming
and counting the body ourselves, which is more code in a middleware that
runs on every request. Given nothing in the app sends chunked JSON,
refusing it outright seemed proportionate -- but if any client does,
this would break it.
… before the CPU retry (#534)

Fixes #514. Independent; branches off `0.16.1`.

## 1. Worker teardown sat outside the `finally`

```python
    finally:
        _done_evt.set()
        set_proc(job.id, None)
        wt.join(timeout=2)

    if job_ok is not True:      # <- outside
        _kill_worker()
```

An exception out of the read loop -- `proc.stderr.read(1)` raising
`OSError` when the API thread's `terminate()` races the read, or
`_set()` raising -- propagated without it. `_worker` still held the
process, so the next job's `_get_worker()` saw a matching device and a
live `poll()` and **reused a worker whose CUDA state followed an
exception**.

`.claude/rules/ml-pipeline.md`: *"torn down after **any** non-success
(cancellation or failure) -- post-exception CUDA state can't be trusted.
Both halves of this rule matter; don't relax either side."*

## 2. A second path missed teardown entirely

The pipe check raises **before** the `try`:

```python
if proc.stdin is None or proc.stderr is None:
    raise RuntimeError("demucs worker has no stdin/stderr pipe")
```

so a worker that came back without pipes stayed cached and would be
handed to every subsequent job. Found while writing the test for #1 --
it failed for this reason rather than the one I was targeting.

## 3. Cancel was dropped before the CPU retry

`separate()` had no cancel check between attempts. The `rmtree` of a
multi-GB partial takes seconds with nothing registered for cancel, so a
cancel landing there was invisible and the **entire CPU pass ran to
completion** -- 10+ minutes -- before `JobCancelled` was raised at the
end. The UI showed "Cancelling" throughout.

## 4. `_kill_worker` killed without reaping

`kill()` only sends the signal. Without `communicate()` a worker wedged
in an uninterruptible CUDA call becomes a zombie whose pipes close only
when the `Popen` refcount happens to drop. `vocal_split.py` already
pairs the two.

## A check I tried and removed

I initially added a cancel check at the top of `_run_demucs`. It broke
`test_cancel_kills_worker_next_job_spawns_fresh`, which verifies that a
cancelled job tears the worker down so the next spawns fresh -- a check
that never spawns has no worker to tear down, so the test's premise
collapses. That test encodes exactly the rule this PR is protecting, so
the check went rather than the test.

The queue worker already gates dispatch on cancellation, and the
fallback check covers the window that was actually reported, so nothing
is lost.

## Verification

New `tests/test_separate_worker_lifecycle.py`, 3 tests. Confirmed not
vacuous -- reverting the teardown placement and the fallback check fails
them.

```
ruff check       All checks passed
pytest tests/    896 passed, 2 failed (pre-existing ogg pair)
```

`tests/test_separate_fallback.py` (13 tests) still passes unchanged --
worth noting, since that is the suite that pushed back on the
entry-point check.
…op (#535)

Fixes #519. Independent; branches off `0.16.1`.

## 1. Two ffmpeg calls were unregistered, so cancel could not reach them

`runner.py:79` and `:130` used `subprocess.run()`, which cannot be
interrupted -- `POST /cancel` sets the flag but nothing looks at it
until the call returns.

Cancelling during "Preparing audio..." on a 400 MB `.mp4` was a no-op
for up to `TIMEOUT_FFMPEG` **per call**, and that path runs both the
video extract and the transcode.

Both now go through `_run_registered_ffmpeg`, mirroring
`collect._run_ffmpeg` which registers for exactly this reason. It
deregisters in a `finally`, so a failure cannot leave a stale entry a
later cancel would terminate on the wrong job.

## 2. Two of three workers never armed the parent-death watchdog

`STEMDECK_PARENT_PID` was set in exactly one place (`separate.py`) and
read in exactly one place (`demucs_worker.py`).

- A **Force-Quit during a vocal split** orphaned an onnxruntime process
holding the GPU, with nobody to collect the result.
- A **section pass** outlived the parent whose `TIMEOUT_SECTIONS` was
its only bound.

The watchdog moves to `app/core/process.py` -- where `process_exists`
already lived specifically for it -- so all three workers share one
implementation rather than three copies. All three spawn sites export
the pid.

**Poll interval preserved at 1.0s.** I initially wrote 5s in the shared
version, which would have silently slowed the existing demucs watchdog.
Caught before commit.

## 3. A running vocal split was uncancellable by construction

`cancel_job` returns early for a `done` job -- and a vocal split only
ever runs on a done job, so `cancel_requested` was never even set, while
the split held `_pipeline_lock` and stalled the whole import queue for
its duration.

Cancel now terminates the worker for that specific case. The split's own
error path marks it failed and releases the lock, so nothing else was
needed.

## Verification

New `tests/test_cancellation_reach.py`, 12 tests. Confirmed not vacuous
-- reverting the vocal-split cancel and one parent-pid export fails 2.

Two of them are deliberately structural
(`test_every_worker_spawn_exports_the_parent_pid`,
`test_every_worker_arms_the_watchdog`): they assert across *all three*
workers, so a fourth worker added later without the watchdog fails the
suite rather than silently orphaning a process.

```
ruff check       All checks passed
ruff format      94 files already formatted
pytest tests/    905 passed, 2 failed (pre-existing ogg pair)
```

## Two existing test files updated, not fixed

- `test_worker_parent_watchdog.py` targeted
`demucs_worker._arm_parent_watchdog` and `demucs_worker.threading`; both
moved to `app.core.process`.
- `test_video_status.py` stubbed `runner_mod.subprocess.run`, which the
extract no longer calls; it now stubs `_run_registered_ffmpeg`.

Behaviour is unchanged in both cases -- worth a look to confirm you
agree the seams moved sensibly.
…multitrack (#536)

engineMode() returns "chunked" unless the user has set the audioEngine flag to
"0", and on that path audioEngine owns the clock while the multitrack is
mounted with url: null for visuals only. transport.js handled this everywhere
via `audioEngine ?? multitrack`; main.js imported only `multitrack` and called
it bare.

So on the default configuration:

- The footer scrub bar did nothing. It is a full-size cursor: pointer overlay,
  and clicking or dragging anywhere on it moved neither the playhead nor the
  audio.
- [ and ] seeked the silent multitrack, so nothing happened.
- I and O read multitrack.getCurrentTime(), which is pinned at 0 there, so
  "set loop in at playhead" always wrote 0 no matter where the playhead was,
  and "set loop out" always wrote max(0, loopStart + 0.5).

Space and ruler clicks were unaffected because they live in transport.js.

Rather than patching five call sites, transport.js exports the accessor it was
already using internally, plus setPlayheadTime. Seeking now goes through
setPlayheadTime, which also updates the playhead marker, footer times and
presence playhead -- none of which the old multitrack.setTime call did, so the
scrub bar would have left the marker stale even had it worked. Same anti-drift
shape as apply_ffmpeg_path in #506.

The keydown guard excluded HTMLInputElement but not HTMLTextAreaElement, so
Space in the settings log viewer started playback instead of scrolling. Fixed
alongside, since the guard was being edited anyway.

The test is structural rather than DOM-driven: the bug was not a wrong value
from a function, it was reaching for the wrong object, so it asserts main.js
holds no bare multitrack playback calls. All 8 checks fail against the previous
code.

Refs #515
Deno was fetched from releases/latest/download with no version pin and no
checksum, into every published GHCR image. It is the JS runtime yt-dlp feeds
YouTube's challenge payload to, so it executes against untrusted input, and
every build took whatever Deno published that day. The desktop packaging
scripts already pin and verify QuickJS with the rationale written out in a
comment -- Docker was the one path that did not follow it. Now pinned to
v2.9.6 with a per-arch SHA256 verified before the zip is unpacked.

linux-release.yml and windows-release.yml uploaded with
action-gh-release, whose fail_on_unmatched_files defaults to false, and
neither asserted the files existed first -- macOS is the only path that did.
A missing updater asset therefore published a release that went green while
the in-app updater 404'd for every installed user. Both now check every asset
before uploading and set fail_on_unmatched_files.

make-portable.ps1 set $PSNativeCommandErrorActionPreference, which exists only
in PowerShell 7+, while CI invokes it with `powershell` -- Windows PowerShell
5.1, where it does nothing and $ErrorActionPreference does not cover native
commands either. A failed CPU-torch --force-reinstall was ignored, leaving
whatever torch was already resolved in place, and the zip labelled CPU shipped
a non-CPU torch; the later import checks still passed because torch imports
fine either way. Assert-LastExitCode now guards the venv creation and all
three pip installs. The preference stays for a pwsh run.

Switching the invocation to pwsh would have been the tidier fix, but nothing
in this repo uses pwsh and the runner is self-hosted, so there is no way to
confirm PowerShell 7 is installed there. An exit-code check works on both.

trivy-action was pinned to @master, the only unpinned action in the repo while
everything else is SHA-pinned. Now on v0.36.0's commit.

Both Deno hashes were verified against the published artifacts, including a
negative control confirming a wrong hash is rejected.

Refs #517
Reported in discussion #507: changing the size of a selection meant clicking
and dragging again from scratch, which restarts the whole thing and loses the
precision you had. The numeric fields are the only exact route, and finding a
seamless loop through them is guesswork.

The region could not be touched at all -- .loop-region was pointer-events:
none, so every pointerdown reached the create-drag underneath, which resets
both edges to the click position. Adjusting one edge necessarily destroyed the
other.

Three gestures now:

- a handle at either edge moves that edge alone, so a loop can be tightened one
  side at a time;
- the body moves both edges together, preserving length, so a loop found by ear
  can be slid without being re-measured;
- a press that does not travel still seeks, which is what clicking inside the
  selection did before the region took pointer events. Losing that would have
  been a regression for anyone who just wants to move the playhead.

The handles are 12px and overhang the 2px border on both sides, because an edge
you cannot reliably grab is the finnicky behaviour this is meant to replace.
They only render on hover or while dragging, so the selection looks the same at
rest.

create-drag already guarded with `e.target.closest(".loop-region")` -- dead
code until now, since the element could not receive events. It is a real guard
again, alongside the stopPropagation on the region's own handler.

The geometry lives in its own module rather than in transport.js, which cannot
be imported outside a browser: it pulls in state.js, which touches document at
module load. Same reason playbackStems.js is separate. That makes the clamping
testable, which is the half that matters -- an edge crossing its partner, or a
region pushed against either end of the track. MIN_LOOP_SEC moves with it and
now has one definition.

Pointer events throughout, so this works with touch and pen.

Closes #538.
Reported on macOS: a song deleted by clearing the trash, or by Settings ->
"Reset app data", returns later. Deletion had two halves and both swallowed
their failures, so several independent paths produced the same symptom.

The root cause is that restore() adopts any job-shaped directory it finds.
That is right for a library whose registry was lost and wrong for a job the
user deleted whose files outlived the delete. Nothing on the server knew the
difference, so the only thing standing between a failed delete and a
resurrected song was a client-side tombstone -- which "Reset app data" wipes
on its way out.

The registry now keeps its own deletion record. Orphan recovery skips those
ids, so a directory that survives a delete stays gone regardless of what the
client does. Records are pruned once their directory is finally absent, so the
set stays bounded rather than growing for the life of the install.

_rmtree_job reports whether the files actually went away instead of logging
and returning None, and retries once: on macOS the usual failure is Finder or
Spotlight creating a .DS_Store between rmtree's scan and its final rmdir,
which leaves "Directory not empty" on a directory that is about to be empty
again. delete_job records the deletion either way and tells the caller when
files remain.

reset_all returns what it could not remove and records the survivors, and
/api/reset reports the count instead of an unconditional {"ok": true} that the
frontend took as licence to wipe its own tombstone.

On the client, the tombstone write and the DELETE calls are both awaited. They
were fire-and-forget with .catch(() => {}), so quitting soon after clearing the
bin lost the tombstone, and a delete that failed -- a 409 on a job stuck in
"queued", a 500 when files could not be removed -- was invisible. Failures now
surface through notifyFailure, and the button is disabled while it runs.

Verified: removing the deletion record makes the two resurrection tests fail.

Refs #521
Comment thread tests/test_registry_resilience.py Dismissed
@thcp
thcp marked this pull request as ready for review September 1, 2026 07:03
…mmend a shape (#545)

A trashed song came back on the next launch whenever a second job shared
its source URL. addTrackToLibrary evicted the trashed track to make room
for the sibling's import, which dropped the catalog entry but not the
job, so the directory and its registry record outlived their only
reference. syncWithServer then found a job with no track, no trash entry
and no tombstone, and re-adopted it. The trashed match is now left where
the user put it: the new track is in no folder yet, so it reaches the
library on its own, and evicting the old one was never what put it
there. A Playwright spec seeds two jobs on one source URL and holds
both halves, and seed.py grows a sibling job to make that possible.

Three of the eight logos listed in the We Recommend dialog had no file
behind them and 404'd for every user on every platform. They are
bundled now, along with avatars for Beltr and Seratone, which had none
at all.

The dialog itself was one flat list of twelve entries with no order a
reader could perceive. It is grouped into five categories, r/bass is
added so the app matches the README, and the descriptions move out of
hardcoded English into the i18n layer across all ten language tables.
The README table gains the same categories so the two lists stop
drifting apart. library.deleteFailed, English-only since #540, is
filled in at the same time because the i18n audit cannot pass without
it.

Co-authored-by: Thales <>
@thcp thcp changed the title 0.16.1: macOS song sections, settings data loss, deleted songs returning, and 11 more fixes 0.16.1: macOS song sections, settings data loss, songs coming back from the bin, and 14 more fixes Sep 1, 2026
thcp and others added 3 commits September 1, 2026 11:14
…pha badge (#547)

* feat(panels): clear the studio down to the mixer in one press

#480 gave each panel around the mixer a toggle, but collapsing all three
still left the library holding its column, which is the largest thing on
screen that is not the mixer. Getting to a bare mixer meant three presses
in the toggles row and a fourth on a control somewhere else, then four
more to undo it.

"All" sits at the end of the same row and takes the three panels and the
sidebar together. It keeps no state of its own: it drives the same
apply/persist the individual toggles already use, and reads its own
pressed state back off .app. A fourth flag would be a fourth thing to
disagree with the other three the moment the library was collapsed from
its own button instead. A MutationObserver on that one class attribute is
what keeps it honest, so no other handler has to remember it exists.

setSidebarCollapsed moves out of wireCatalogToggle's scope and is
exported, because the sidebar's state is one class plus one localStorage
flag and two writers reproducing that pair is how it drifts.
setCatalogView was already the second writer: it wrote the flag by hand
and left the collapse button's aria-expanded claiming the sidebar was
still shut. It routes through the helper now.

panels.all and panels.allTitle are in all ten language tables. ptPT needs
no override, the European wording is the same.

* feat(footer): scroll a loop bound, and stop the panel row shouting

Three things in the footer, all of them small and all of them in the same
three files.

A loop bound could only be changed by typing it. A loop is never right
first time: you drag a rough region, play it, and want the start forty
milliseconds earlier because it clips the transient. That meant clicking
in and retyping nine characters, which is slower and less precise than
dragging the region again, so the fields went unused for the one job they
are best at. The wheel now adjusts them, in seconds left of the decimal
point and milliseconds right of it, a tenth and a hundredth of a second
per notch. One millisecond per notch would need a hundred notches to
cover something audible. Character-level hit-testing inside an input is
not reliable across browsers, but only the decimal point matters, so the
text up to it is measured and compared. A nudge past either end of the
track, or one that would squeeze the loop under the minimum, is dropped
rather than clamped, so holding the wheel at the end does not drag the
other bound along.

The panel row read "Click to collapse" as a sentence it had no width for,
and All was fused to it. The label is one word now, the rule sits after
it, and All is the first of four buttons. Its pressed state greys and
strikes it through like the other three, but only once all four are away:
read as "is anything hidden" it struck itself through the moment Analysis
was hidden alone, which looks exactly like you pressed it.

Loop stands beside the two bounds rather than over them. As a row of its
own it stretched the footer to three lines for one button.

The Alpha badge is gone from Click track. The feature has been through
several releases and is covered by its own spec; a warning that has
stopped being true also teaches people to ignore the next one. Removed
properly: markup, the CSS rule that had no other user, and the dead
click.alpha key in all ten tables.

---------

Co-authored-by: Thales <>
… test does not use

a_free_port_is_granted_as_asked asked for 21000 and asserted against 53969
on a shared macOS runner. 53969 is an ephemeral port, so claim_port had
refused 21000 and reserve_port had correctly fallen back. The failure was
the probe, not the code.

The probe bound with std::net::TcpListener, which sets SO_REUSEADDR on
Unix. claim_port deliberately does not. A port sitting in TIME_WAIT
therefore accepts the probe and refuses the claim, and the test reads
"free" where reserve_port reads "taken". Probing through claim_port makes
the word mean one thing.

Matching options narrows the race but cannot close it: the probe has to
let go before reserve_port can take the port, and cargo runs this
binary's tests in parallel with several of them standing up throwaway
listeners. So the test walks the range instead of committing to the first
candidate. Losing one race costs a retry; a reserve_port that genuinely
ignored a free port would have to lose all two hundred, and the assertion
says exactly that when it fails.

cargo test 57 passed, cargo clippy clean, cargo fmt clean.
Five sections stacked in one 420px column is taller than the dialog on
any normal window, so the list scrolled and the people below the fold
were never seen. That is a poor way to point at them.

Two columns with a rule between, in a 780px card. The split is after the
third section, which is where the two sides come out closest in height.
Each column is still wide enough for three cards per row, so nothing got
smaller to make room.

The overflow stays as a floor rather than the normal case: a very short
window still has to reach the bottom somehow. Below 860px the card drops
back to one column and the rule goes with it, instead of squeezing the
cards under three per row.

playwright 85 passed, node tests/js 11/11.
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