fix(macos): stop shipping AppleDouble files that break song sections, and give warmup the bundled FFmpeg - #506
Merged
Merged
Conversation
The macOS runtime pack copies Python with `ditto`, which preserves extended attributes on every file, and then tarred the staging tree without disabling copyfile. macOS tar serialized those xattrs as AppleDouble "._name" members, and the Rust tar crate that unpacks the pack on the user's machine has no AppleDouble support, so it wrote 30,239 of them out as literal files. One lands in matplotlib's style directory. matplotlib globs "*.mplstyle" at import time, matches "._seaborn-v0_8-bright.mplstyle", and dies on byte 45 of the AppleDouble header: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa3 in position 45 That kills `import matplotlib.pyplot`, and with it allin1_infer and every importer of it. Automatic song sections were broken outright on macOS, not merely un-prewarmed: section_worker.py takes the same import path warmup does. Linux and Docker are unaffected. Fixed on both ends. The pack script strips xattrs and sidecars from the staging tree and sets COPYFILE_DISABLE, and extract_tar_archive skips "._" entries so packs already published are handled too. The pack script also verifies the finished archive, because whether macOS tar emits AppleDouble members at all varies by OS version, so none of the guards can be trusted blind. That check cannot use `tar -tf`: macOS tar folds "._name" members back into their sibling's metadata while listing exactly as it does while creating, and reports a clean archive either way. It streams members through Python's tarfile instead, which has no AppleDouble handling. Refs #505
`warmup_models` spawned its Python child without the PATH block `start_backend` has, so the bundled FFmpeg in the data directory was invisible to it. A Finder-launched .app inherits a bare /usr/bin:/bin:/usr/sbin:/sbin, and audio_separator probes for `ffmpeg` before it will load anything, so the karaoke vocal-split model failed every setup run with: WARMUP_FAILED vocal_split [Errno 2] No such file or directory: 'ffmpeg' Smaller blast radius than the sections failure: the real backend does get the right PATH, so the on-demand vocal split still worked, it just paid the model download mid-job instead of during setup. The two spawn sites had drifted because each carried its own copy of the PATH logic, so extract it into apply_ffmpeg_path and call it from both. Refs #505
The template tracks the latest published pre-release, which is now 0.16.0. Unraid users pull exactly this tag, so leaving it at 0.15.2 means their installs silently lag behind. Only the <Repository> tag changes; the Docker image itself is unaffected by the macOS fixes in this branch.
thcp
marked this pull request as ready for review
August 30, 2026 21:58
This was referenced Aug 30, 2026
Closed
thcp
added a commit
that referenced
this pull request
Aug 31, 2026
…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 ```
thcp
added a commit
that referenced
this pull request
Aug 31, 2026
…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
thcp
added a commit
that referenced
this pull request
Sep 1, 2026
…om the bin, and 14 more fixes (#541) 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** - **#509** `settings.json` was written with `write_text` (truncate, then write), and `_load` could not tell a torn file from a first run. A real user lost `port` and `allow_network` from both the file and its mirror. Now atomic, and a corrupt file is preserved as `settings.json.corrupt-<ts>` and recovered from the mirror. - **#521** Deleted songs came back. `reset_all` swallowed per-directory failures, `/api/reset` reported unconditional success, the frontend then wiped its own tombstone, and `restore()` re-adopted every surviving directory on the next start. A server-side deletion record closes it. - **#542** Trashed songs came back too, by a different route. `addTrackToLibrary` deduplicates by source URL, and when the match was in the Trash it deleted the catalog entry without deleting the job. The directory and its registry record outlived their only reference, and `syncWithServer` re-adopted the orphan on the next launch. Any second job sharing the URL was enough to trigger it. Found while testing this release on Windows. - **#515** The footer scrub bar did nothing, and "set loop in at playhead" always wrote 0, `main.js` drove the silent multitrack while `audioEngine` owned the clock. - **#520** A cancel landing between the queue worker's pop and claim stranded a job at `queued` forever: invisible, still counted against capacity, source file never freed, re-queued on every restart. Also, a malformed `registry.json` raised an uncaught `AttributeError` at import and the backend never started. **Security** - **#510** The in-app updater installed an executable from a WebView-supplied URL with no host allowlist, checked against a SHA from the same caller. - **#511** Fork PRs executed arbitrary code on the self-hosted runners, the same machine that builds and signs releases. - **#518** Linux FFmpeg was downloaded, chmod +x and executed with no integrity check at all. - **#517** Deno pulled from `releases/latest` unpinned and unverified into every image; releases could publish without updater assets and stay green; a failed CPU-torch install was silently ignored, shipping a non-CPU torch in the CPU zip. **Robustness** - **#508** A regression in #506 itself: the per-entry unpack loop lost `Archive::unpack`'s directory deferral, so a read-only directory member would fail extraction outright. - **#512** `end` had no upper bound, reaching a multi-GB `np.zeros` on the event loop; the body-size guard covered two paths and was bypassed by chunked encoding. - **#513** SSE slots leaked permanently when a client disconnected before the body started, 200 of those and every progress stream 503s with nothing connected. - **#514** Worker teardown sat outside the `finally`, so an exception left a poisoned CUDA worker warm; cancel was dropped before the CPU fallback, costing 10+ minutes. - **#516** `child_output_with_timeout` never drained child pipes until exit, deadlocking any chatty child. - **#519** Cancellation never reached several pipeline subprocesses, and two of three workers never armed the parent-PID watchdog, so a Force-Quit orphaned a GPU-holding process. **Presentation** - **#543** Three of the eight logos listed in the We Recommend dialog had no file behind them. Analog4Lyfe, Empress Effects and Thomann showed a broken card on every install, with a 404 in the backend log each time the dialog opened. - **#544** That dialog was one flat list of twelve entries with no order a reader could perceive. It is grouped into five categories now, `r/bass` is added so the app matches the README, and the twelve descriptions move out of hardcoded English into the i18n layer across all ten language tables. **Feature** - **#538** Loop regions can be adjusted rather than redrawn, drag either edge independently, or drag the region to slide it. From discussion #507. ## 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: - **#509** port and `allow_network` survived a quit and relaunch, in both the portable file and the AppData mirror. - **#521** a hard-deleted job stayed deleted across a restart. The deletion record self-pruned once the directory was gone, which is the designed behaviour. - **#542** a trashed job stayed in the Trash across a restart, and again across a fresh import of the same URL. Both cases fail on 0.16.0. - **#520** a cancel while queued removed the job and freed its capacity slot, with nothing stranded. - **#519 / #514** a cancel mid-separation wiped the partial output and the queued job started immediately, with no leftover worker process. A quit mid-separation left zero orphaned `python.exe`, and the interrupted job resumed once with `resume_attempts: 1`. ## 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.
prjoni99
pushed a commit
to prjoni99/stemdeck
that referenced
this pull request
Sep 1, 2026
stemdeckapp#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 stemdeckapp#508
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #505.
Two macOS desktop defects, both surfacing as warmup failures in
setup.log. The first one is not really a warmup problem.1. AppleDouble sidecars break automatic song sections
make-runtime-pack.shcopies Python withditto, which preserves extended attributes on every file, then tars the staging tree without disabling copyfile. macOS tar serializes those xattrs as AppleDouble._namemembers, and the Rust tar crate that unpacks the pack on the user's machine has no AppleDouble support, so it writes them out as literal files. A 0.16.0 install carries 30,239 of them.One lands in matplotlib's style directory. matplotlib globs
*.mplstyleat import time, matches._seaborn-v0_8-bright.mplstyle, and dies on byte 45 of the AppleDouble header:matplotlib logs
Cannot decode configuration file ... as utf-8.and re-raises, soimport matplotlib.pyplotfails, which failsallin1_infer/__init__.py -> .analyze -> .visualize, which fails every importer ofallin1_infer.That includes
app/pipeline/section_worker.py. Automatic song section detection was broken outright on macOS 0.16.0, not merely un-prewarmed. Linux and Docker are unaffected: this is a macOS archive artifact.Fixed on both ends:
COPYFILE_DISABLE=1before tarring.extract_tar_archiveskips._entries, so the packs already published are handled without waiting for a rebuild.The pack script also verifies the finished archive and fails the build if a sidecar survives. That check deliberately does not use
tar -tf: macOS tar folds._namemembers back into their sibling's metadata while listing exactly as it does while creating, so it reports a clean archive whether or not one is clean. It streams members through Python'starfileinstead, which has no AppleDouble handling.2. Warmup did not put the bundled FFmpeg on PATH
warmup_modelsbuilt its Python command without the PATH blockstart_backendhas, so the FFmpeg we downloaded into the data directory was invisible to it. A Finder-launched.appinherits a bare/usr/bin:/bin:/usr/sbin:/sbin, andaudio_separatorprobes forffmpegbefore loading anything:Smaller blast radius: the real backend does get the right PATH, so the on-demand karaoke split still worked, it just paid the model download mid-job. Recurring on every launch in
setup.logsince timestamp 1787336901.The two spawn sites had drifted because each carried its own copy of the PATH logic, so it is extracted into
apply_ffmpeg_pathand called from both.Verification
env -i PATH=/usr/bin:/bin:/usr/sbin:/sbinto mimic a Finder launch.allin1_inferand the fullsection_workerimport set succeed; adding the FFmpeg dir to PATH makesSeparatorconstruct.extract_tar_archive_drops_apple_double_sidecarsbuilds a.tar.zstcontaining a sidecar and asserts it is dropped while the real file is kept. Confirmed it fails without the fix.cargo test58 passed,cargo fmt --checkandcargo clippyclean.Note for the release
The
extract_tar_archiveguard only runs on a fresh runtime install. Anyone already on 0.16.0 keeps their 30k sidecars until the runtime is re-fetched, so this needs a runtime version bump to reach existing macOS installs.