Skip to content

Catch a wrong-architecture FFmpeg, and say what it is - #643

Merged
thcp merged 4 commits into
mainfrom
fix/macos-ffmpeg-arch-637
Sep 20, 2026
Merged

thcp merged 4 commits into
mainfrom
fix/macos-ffmpeg-arch-637

Conversation

@thcp

@thcp thcp commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Closes #637
Follow-up: #642

Thanks to @tsgo606 for the report, and for including the exact error text. That is what made this findable, because the message the app showed pointed at the wrong thing entirely.

What was happening

macOS FFmpeg comes from two sources. The primary publishes one build per architecture and is picked with std::env::consts::ARCH. The fallback, evermeet.cx, is a single URL with a single hash used on both architectures, and it publishes Intel builds.

When the primary is unreachable, an Apple Silicon Mac gets an Intel binary. Rosetta 2 normally hides that, but a bare exec() never triggers Rosetta's install-on-demand prompt the way launching a GUI app does. On a Mac without it, the binary simply cannot run: Bad CPU type in executable (os error 86).

That source gap is not fixed here. It needs an arm64 build to point at, and pinning one means downloading and verifying its hashes the way the existing four were. That is #642. Everything that turned the gap into a dead end is fixed here.

Why the issue title says ffprobe

Verification ran ffmpeg and only checked that ffprobe existed. So a pair that disagreed about architecture passed setup and failed later, inside the Python pipeline, as Could not read file duration: [Errno 86].

It gets worse than a missed check: the data directory is prepended to every child process's PATH, so a stale wrong-arch ffprobe sitting there shadows a working system one. ffprobe is now executed like ffmpeg is, and ffprobeReady reports whether it runs rather than whether the file exists. That flag had both cases backwards: a wrong-arch binary counted as ready, and a working ffprobe found on PATH did not.

Why retrying never helped

resolve_existing_ffmpeg only asks whether the file exists, and a failed verification propagated straight out. A bad download therefore failed identically on every launch afterwards, forever, with nothing to do but delete the folder by hand. It now falls through to the system PATH and the download paths, which can replace it.

An explicit STEMDECK_FFMPEG override is exempt. That file is the user's own choice, usually outside the data directory, and replacing it is not the app's call.

The message

The network/firewall hint was attached to every failure of the FFmpeg step, so an architecture mismatch was reported as a firewall block. It now recognises EBADARCH and offers the two things that actually work: install Rosetta, or point STEMDECK_FFMPEG_URL at a matching build.

Testing on an Apple Silicon Mac

Rosetta decides whether you can see the original bug at all, so check first:

/usr/bin/pgrep -q oahd && echo "Rosetta present" || echo "no Rosetta"

Confirm the architecture, which works either way:

cd ~/Library/Application\ Support/StemDeck/ffmpeg
file ffmpeg ffprobe            # arm64 is correct, x86_64 is the bug
arch -arm64 ./ffprobe -version # forces arm64, so it fails even with Rosetta

Reproduce the reported path:

  1. rm -rf ~/Library/Application\ Support/StemDeck/ffmpeg (required on the old build, which is the point: nothing re-downloads on its own)
  2. Add 127.0.0.1 github.com objects.githubusercontent.com to /etc/hosts to force the fallback
  3. Run setup
  4. file the result to confirm it is x86_64
  5. Remove the hosts entry afterwards

What should now be different:

  • Setup fails at the FFmpeg step rather than succeeding and dying later in a job
  • The message names the architecture problem and offers Rosetta or STEMDECK_FFMPEG_URL, instead of blaming your firewall
  • Retrying after fixing the cause works, with no manual folder deletion
  • With a working Homebrew FFmpeg on PATH and a stale wrong-arch pair in the data directory, setup no longer reports ready

Testing done here

cargo fmt, clippy --tests -D warnings and cargo test pass on Windows (96 tests) and on the Linux target through WSL (105). Two new unit tests cover ffprobe path resolution, including the Path::parent() trap where a bare file name yields Some("") rather than None.

The hint classifier was checked against both of the reporter's verbatim error strings plus three controls. desktop/ui has no test harness, so that check was a one-off rather than a committed test.

The macOS cfg blocks compile on neither host here, so PR CI's macOS job is the first thing to build them. I have no Mac, so the end-to-end behaviour above is unverified by me and needs a real Apple Silicon machine.

Thales and others added 3 commits September 20, 2026 08:49
An Apple Silicon Mac reported "Bad CPU type in executable (os error 86)" on
install, and the same machine later reported it again from inside a job, this
time naming ffprobe (#637).

The trigger is that the macOS FFmpeg fallback is architecture-blind. The primary
source publishes per-architecture builds and is picked by std::env::consts::ARCH.
The fallback, evermeet.cx, is one URL and one hash for both architectures, and
publishes Intel builds. So when the primary is unreachable, which is the
reporter's situation, an Apple Silicon Mac is handed a binary it cannot execute
unless Rosetta happens to be installed. A bare exec() never triggers Rosetta's
install-on-demand prompt, so it just fails.

That source gap is not fixed here; it needs an arm64 build to point at, and
pinning one means verifying its hashes first. What is fixed is everything that
turned it into a dead end.

ffprobe is now executed, not stat'd. Verification ran ffmpeg and only checked
that ffprobe existed, so a pair that disagreed about architecture passed setup
and failed later inside Python as "Could not read file duration: [Errno 86]".
That is why the issue title names ffprobe. The data directory is prepended to
every child's PATH, so a stale wrong-arch ffprobe there also shadows a working
system one. ffprobeReady now reports whether it runs rather than whether the
file is present, which had both cases backwards: a wrong-arch binary counted as
ready, and a working ffprobe found on PATH did not.

A present-but-unusable binary no longer ends the search. resolve_existing_ffmpeg
only asks whether the file exists, so a bad download failed identically on every
launch afterwards with nothing to do but delete the folder by hand. It now falls
through to the system PATH and the download paths, which can replace it. An
explicit STEMDECK_FFMPEG override is exempt: that file is the user's choice and
replacing it is not ours to make.

The setup wizard stops calling it a network problem. The hint was attached to
every failure of the step, so an architecture mismatch was reported as a
firewall block. It now recognises EBADARCH and gives the two things that
actually work: install Rosetta, or point STEMDECK_FFMPEG_URL at a matching
build.

ffprobe_beside replaces a .parent().unwrap_or_else() whose fallback was
unreachable, since parent() on a bare file name is Some("") rather than None. It
reached the right answer by accident and the fallback it named was wrong; the
test pins the behaviour so a cleanup does not "fix" it into a bug.

Verified: cargo fmt, clippy --tests -D warnings and test on Windows (96) and on
the Linux target via WSL (105). The macOS cfg blocks do not compile on either
host and are left to PR CI.
Not part of #637. It is here because it fails the macOS Rust Check on every
branch, including this one, so nothing else can go green until it is fixed.

torchvision_version_for_tag was gated `any(not(macos), test)` while the constant
it returns, CPU_TORCHVISION_VERSION, is gated `not(macos)`. A macOS test build
therefore compiled a function whose only value did not exist:

  error[E0425]: cannot find value `CPU_TORCHVISION_VERSION` in this scope

The `, test` was copied from torch_version_for_tag above it, where it is
correct: that one is called by a test which runs on every platform, and it
returns literals. This one is called by two tests that are both `not(macos)`,
and by one non-test caller that is `not(macos)` as well, so nothing wanted it in
a macOS test build in the first place. Narrowing the gate to match its constant
and its callers is the whole fix.

Untouched by this branch otherwise: the same mismatch is on main. It went unseen
because every recent run of that workflow was cancelled rather than failing, so
the job has not reported a real result in months.

Cannot be verified on this host. Windows and the Linux target compile the
constant either way, so the two gates are indistinguishable here; the macOS job
on this PR is the check that matters.
The verification added here is correct and was never reached on the one
machine that needed it. Dragging an MP3 onto a build of this branch, on
an Apple Silicon Mac with a wrong-architecture pair already installed,
still produced the reported error verbatim:

    Could not read file duration: [Errno 86] Bad CPU type in executable:
    '.../StemDeck/ffmpeg/ffprobe'

Two things kept it alive.

probe_runtime reported readiness from resolve_existing_ffmpeg().is_some()
-- is_file(), on ffmpeg alone, never ffprobe, never running anything. A
wrong-architecture pair is a pair that exists, so setup.js short-circuited
straight to the studio and ensure_ffmpeg, which does verify and recover,
was never called. It now runs the pair.

The backend was then never told which FFmpeg had been settled on. It
re-derives one from the data directory and asks only whether a file is
there (ffprobe_executable, app/core/config.py), so the rejected binary is
what it execs -- by absolute path, where there is no PATH search to fall
past it. apply_ffmpeg_path now names both halves outright via
STEMDECK_FFMPEG and STEMDECK_FFPROBE, read back from what setup recorded.

A bare "ffmpeg" is resolved to an absolute path first, since that is what
ensure_ffmpeg returns for a system install and it means nothing to a child
with a different PATH. Windows records the same bare name, where it is the
one spelling that never exists on disk, hence the .exe arm.

Nothing under app/ changes, and nothing is deleted.

Verified on an Apple Silicon Mac against a profile holding a working
ffmpeg beside an unrunnable ffprobe: setup now rejects the pair, recovers,
records both halves, and the import that failed before succeeds. cargo
fmt, the workflow's clippy and cargo test all pass there, 99 tests.
@thcp

thcp commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Tested this on an Apple Silicon Mac. The machine turned out to already be in the reported state: x86_64 ffmpeg and ffprobe in the data directory, both from evermeet.cx, on an arm64 host.

Rosetta is installed here, so those binaries run and the reporter's failure cannot happen naturally. I forced a real one with a PowerPC Mach-O, which the kernel rejects with Bad CPU type in executable and Rosetta will not rescue, giving a working ffmpeg beside an unrunnable ffprobe. That is the reported condition exactly.

What held up

The three new ffprobe tests pass, and so do the other 94 once cargo test compiles. The hint classifier is right: I ran it against the reporter's verbatim string plus five others, and all six classify correctly, architecture hint for the three EBADARCH forms and network hint for timeout, 404 and hash mismatch.

ensure_ffmpeg is correct too. Verify the pair, fall through on failure, try system PATH, then re-download.

What did not

Dragging an MP3 onto a build of this branch still produced the reported error verbatim:

Could not read file duration: [Errno 86] Bad CPU type in executable:
'.../ffmpeg/ffprobe'

ensure_ffmpeg was never reached. probe_runtime reports readiness from resolve_existing_ffmpeg().is_some(), which is is_file() on ffmpeg alone, never ffprobe and never running anything. A wrong-architecture pair is a pair that exists, so setup.js short-circuited to the studio. The setup log for that launch has one line, probe-runtime, and then the backend started.

Past that, the backend is never told which FFmpeg was settled on. It re-derives one from the data directory and asks only whether a file is there (ffprobe_executable, app/core/config.py), so the rejected binary is what it execs, by absolute path.

That last part also corrects something in the PR description. A wrong-architecture binary earlier on PATH does not shadow a working one: both zsh and Python's execvp walk past it and find the good one further down. I checked. The failure is only ever the absolute path, which is why the reporter's message contains one.

Pushed to the branch

434561e makes probe_runtime run the pair, and has apply_ffmpeg_path name both halves to every child via STEMDECK_FFMPEG and STEMDECK_FFPROBE, read back from what setup recorded. A bare ffmpeg, which is what ensure_ffmpeg returns for a system install, is resolved to an absolute path first.

Nothing under app/ changes and nothing is deleted. Two tests added, 99 pass.

After it, the same launch rejects the pair, recovers, records both halves, and the import that failed before succeeds.

Two notes

7c5f353 is what unblocks this PR's macOS CI, and it is a fix for main rather than for #637. I can confirm it works: cargo fmt, the workflow's clippy line and cargo test all pass here. Worth checking whether the macOS job should run cargo clippy --tests the way Linux and Windows do, since running it without --tests is why the break went unseen.

The recovery re-downloads from evermeet.cx, so on Apple Silicon it lands another Intel build. It verifies here only because Rosetta is present. Without Rosetta the launch now stops at the FFmpeg step with the architecture hint, which is the right outcome, but #642 is still what actually ends this for those users.

The Linux Rust Check failed to compile the test target:

    error[E0433]: cannot find module or crate `env` in this scope

`mod tests` imports `std::env` under `#[cfg(target_os = "macos")]`, so a
bare `env::` in a test compiles on exactly the one target I built and
nowhere else.

Fully qualifying it would have been enough to compile, but the test was
wrong in a second way worth fixing instead: it read and wrote the
process-wide PATH to steer resolve_on_path, and `cargo test` runs tests on
threads, so it raced every other test that spawns a child.

resolve_on_path is now a thin wrapper over resolve_in_paths, which takes
the paths to search. The test passes its own, asserts that an earlier
entry without the file is skipped rather than given up on, and that an
absent name comes back None instead of being handed back bare. No global
state, and it compiles wherever the module does.

The Linux target cannot be checked from a macOS host -- it needs the GTK
system libraries -- so this is verified by fmt, the workflow's clippy and
cargo test on macOS, 99 passing, plus a read of every env:: use in the
test module: one fully qualified, four inside a macOS-gated test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant