Skip to content

Commit c7a3944

Browse files
author
Thales
committed
Catch a wrong-architecture FFmpeg, and say what it is
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.
1 parent 7713d56 commit c7a3944

2 files changed

Lines changed: 186 additions & 29 deletions

File tree

‎desktop/src-tauri/src/main.rs‎

Lines changed: 149 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4113,13 +4113,34 @@ fn ffmpeg_path(data_dir: &Path) -> Option<PathBuf> {
41134113
Some(data_dir.join("ffmpeg").join(file))
41144114
}
41154115

4116-
fn ffprobe_path(data_dir: &Path) -> PathBuf {
4117-
let file = if cfg!(windows) {
4116+
fn ffprobe_file_name() -> &'static str {
4117+
if cfg!(windows) {
41184118
"ffprobe.exe"
41194119
} else {
41204120
"ffprobe"
4121-
};
4122-
data_dir.join("ffmpeg").join(file)
4121+
}
4122+
}
4123+
4124+
/// The ffprobe that goes with a resolved ffmpeg.
4125+
///
4126+
/// ffprobe sits beside ffmpeg in every layout we accept (flat, `bin/`, a
4127+
/// system install), so the answer is always "same directory, other name". The
4128+
/// case worth naming is the PATH short-circuit in `ensure_ffmpeg`, which
4129+
/// returns a bare `ffmpeg` with no directory at all: the matching answer is a
4130+
/// bare `ffprobe`, resolved through PATH the same way.
4131+
///
4132+
/// Written out rather than as `.parent().map(join).unwrap_or_else(fallback)`,
4133+
/// because `Path::parent()` on a bare file name returns `Some("")` and not
4134+
/// `None`. That idiom reads as though it handles the directory-less case and
4135+
/// never reaches its fallback. Here it happened to land on the right answer
4136+
/// anyway, since `Path::new("").join(x)` is `x`, but only by accident: the
4137+
/// fallback it named was the data directory, which is the wrong answer for a
4138+
/// binary found on PATH. Saying it directly removes the accident.
4139+
fn ffprobe_beside(ffmpeg: &Path) -> PathBuf {
4140+
match ffmpeg.parent() {
4141+
Some(dir) if !dir.as_os_str().is_empty() => dir.join(ffprobe_file_name()),
4142+
_ => PathBuf::from(ffprobe_file_name()),
4143+
}
41234144
}
41244145

41254146
// Locate an FFmpeg binary that already exists on disk. Honors the STEMDECK_FFMPEG
@@ -4440,9 +4461,27 @@ fn parse_health_identity(response: &str) -> Option<HealthIdentity> {
44404461
fn ensure_ffmpeg(data_dir: &Path) -> Result<PathBuf, String> {
44414462
// Use an already-present binary (flat, bin/, or STEMDECK_FFMPEG override) before
44424463
// downloading, so a manually-placed FFmpeg is honored (#248).
4464+
//
4465+
// A present-but-unusable binary used to end the search here, because the
4466+
// failure propagated instead of being recovered from. resolve_existing_ffmpeg
4467+
// only asks whether the file exists, so a download that produced the wrong
4468+
// CPU architecture failed identically on every launch afterwards, forever,
4469+
// with nothing to do but delete the folder by hand (#637). Fall through
4470+
// instead: the paths below can find a working system FFmpeg or fetch a fresh
4471+
// copy over this one.
4472+
//
4473+
// An explicit STEMDECK_FFMPEG override is exempt. It names a file the user
4474+
// chose, usually outside the data directory, and replacing that is not ours
4475+
// to do -- say what is wrong with it and stop.
44434476
if let Some(existing) = resolve_existing_ffmpeg(data_dir) {
4444-
verify_ffmpeg(&existing)?;
4445-
return Ok(existing);
4477+
match verify_ffmpeg_pair(&existing) {
4478+
Ok(()) => return Ok(existing),
4479+
Err(err) if env_path_override("STEMDECK_FFMPEG").is_some() => return Err(err),
4480+
Err(err) => eprintln!(
4481+
"FFmpeg at {} is present but unusable, looking for a replacement: {err}",
4482+
existing.display()
4483+
),
4484+
}
44464485
}
44474486

44484487
// Prefer a system FFmpeg on PATH -- a Homebrew/apt/choco install, or a dev
@@ -4454,7 +4493,7 @@ fn ensure_ffmpeg(data_dir: &Path) -> Result<PathBuf, String> {
44544493
// users on an older OS than our downloaded build assumes (#414): if they
44554494
// already have a working system FFmpeg, we no longer force a potentially
44564495
// incompatible download on top of it.
4457-
if verify_ffmpeg(Path::new("ffmpeg")).is_ok() {
4496+
if verify_ffmpeg_pair(Path::new("ffmpeg")).is_ok() {
44584497
return Ok(PathBuf::from("ffmpeg"));
44594498
}
44604499

@@ -4463,7 +4502,7 @@ fn ensure_ffmpeg(data_dir: &Path) -> Result<PathBuf, String> {
44634502
download_windows_ffmpeg(data_dir)?;
44644503
let portable =
44654504
ffmpeg_path(data_dir).ok_or_else(|| "failed to resolve FFmpeg path".to_string())?;
4466-
verify_ffmpeg(&portable)?;
4505+
verify_ffmpeg_pair(&portable)?;
44674506
Ok(portable)
44684507
}
44694508

@@ -4472,7 +4511,7 @@ fn ensure_ffmpeg(data_dir: &Path) -> Result<PathBuf, String> {
44724511
download_macos_ffmpeg(data_dir)?;
44734512
let portable =
44744513
ffmpeg_path(data_dir).ok_or_else(|| "failed to resolve FFmpeg path".to_string())?;
4475-
verify_ffmpeg(&portable)?;
4514+
verify_ffmpeg_pair(&portable)?;
44764515
Ok(portable)
44774516
}
44784517

@@ -4484,7 +4523,7 @@ fn ensure_ffmpeg(data_dir: &Path) -> Result<PathBuf, String> {
44844523
download_linux_ffmpeg(data_dir)?;
44854524
let portable =
44864525
ffmpeg_path(data_dir).ok_or_else(|| "failed to resolve FFmpeg path".to_string())?;
4487-
verify_ffmpeg(&portable)?;
4526+
verify_ffmpeg_pair(&portable)?;
44884527
Ok(portable)
44894528
}
44904529
}
@@ -4707,8 +4746,13 @@ fn download_macos_ffmpeg(data_dir: &Path) -> Result<(), String> {
47074746
// what we expect, not that the binary actually launches on this machine's
47084747
// macOS version or has every encoder StemDeck needs -- verify both before
47094748
// accepting it over the fallback (#414).
4749+
//
4750+
// Both halves, not just ffmpeg. The primary publishes them as two separate
4751+
// downloads, so "ffmpeg arrived and runs" says nothing about ffprobe, and
4752+
// accepting the pair on half the evidence is how a working ffmpeg came to
4753+
// sit beside an ffprobe that could not run at all (#637).
47104754
let primary_result = download_macos_ffmpeg_primary(&ffmpeg_dir)
4711-
.and_then(|()| verify_ffmpeg(&ffmpeg_dir.join("ffmpeg")));
4755+
.and_then(|()| verify_ffmpeg_pair(&ffmpeg_dir.join("ffmpeg")));
47124756
if let Err(primary_err) = primary_result {
47134757
eprintln!("primary FFmpeg source failed, trying the evermeet.cx fallback: {primary_err}");
47144758
return download_macos_ffmpeg_zip_source(
@@ -5015,18 +5059,45 @@ fn verify_ffmpeg(path: &Path) -> Result<(), String> {
50155059
verify_ffmpeg_encoders(path)
50165060
}
50175061

5062+
// ffprobe gets the same run check ffmpeg does.
5063+
//
5064+
// It did not, and that is the whole of #637: verification executed ffmpeg and
5065+
// only stat()'d ffprobe, so a pair that disagreed about CPU architecture passed
5066+
// setup and failed later inside the Python pipeline, as "Could not read file
5067+
// duration: [Errno 86] Bad CPU type in executable". Worse, the data directory is
5068+
// prepended to every child's PATH (see apply_ffmpeg_path), so a stale wrong-arch
5069+
// ffprobe there shadows a working system one.
5070+
//
5071+
// No encoder check: ffprobe does not encode. Running it is the whole test.
5072+
fn verify_ffprobe(path: &Path) -> Result<(), String> {
5073+
let mut command = Command::new(path);
5074+
command
5075+
.arg("-version")
5076+
.stdout(Stdio::null())
5077+
.stderr(Stdio::piped());
5078+
hide_console_window(&mut command);
5079+
let output = command_output_with_timeout(command, Duration::from_secs(15), "ffprobe check")
5080+
.map_err(|e| format!("failed to run ffprobe at {}: {e}", path.display()))?;
5081+
if !output.status.success() {
5082+
let stderr = String::from_utf8_lossy(&output.stderr);
5083+
return Err(format!(
5084+
"ffprobe at {} failed verification: {}",
5085+
path.display(),
5086+
stderr.trim()
5087+
));
5088+
}
5089+
Ok(())
5090+
}
5091+
5092+
// Both halves, which is the only useful question: StemDeck needs ffmpeg *and*
5093+
// ffprobe, and nothing downloads or ships one without the other.
5094+
fn verify_ffmpeg_pair(ffmpeg: &Path) -> Result<(), String> {
5095+
verify_ffmpeg(ffmpeg)?;
5096+
verify_ffprobe(&ffprobe_beside(ffmpeg))
5097+
}
5098+
50185099
fn write_setup_config(data_dir: &Path, ffmpeg: &Path) -> Result<(), String> {
5019-
// ffprobe always sits next to the resolved ffmpeg (flat or bin/); fall back to
5020-
// the canonical flat location if ffmpeg has no parent.
5021-
let ffprobe_file = if cfg!(windows) {
5022-
"ffprobe.exe"
5023-
} else {
5024-
"ffprobe"
5025-
};
5026-
let ffprobe = ffmpeg
5027-
.parent()
5028-
.map(|dir| dir.join(ffprobe_file))
5029-
.unwrap_or_else(|| ffprobe_path(data_dir));
5100+
let ffprobe = ffprobe_beside(ffmpeg);
50305101
let updated_at = SystemTime::now()
50315102
.duration_since(UNIX_EPOCH)
50325103
.map(|duration| duration.as_secs())
@@ -5040,7 +5111,13 @@ fn write_setup_config(data_dir: &Path, ffmpeg: &Path) -> Result<(), String> {
50405111
"ffmpegPath",
50415112
serde_json::json!(ffmpeg.display().to_string()),
50425113
),
5043-
("ffprobeReady", serde_json::json!(ffprobe.is_file())),
5114+
// Whether it runs, not whether it exists. is_file() called a
5115+
// wrong-architecture binary ready, and a bare "ffprobe" from the
5116+
// PATH short-circuit not ready, getting both cases backwards (#637).
5117+
(
5118+
"ffprobeReady",
5119+
serde_json::json!(verify_ffprobe(&ffprobe).is_ok()),
5120+
),
50445121
(
50455122
"ffprobePath",
50465123
serde_json::json!(ffprobe.display().to_string()),
@@ -6187,6 +6264,55 @@ mod tests {
61876264
}
61886265
}
61896266

6267+
// --- ffprobe path resolution (#637) ---
6268+
6269+
#[test]
6270+
fn ffprobe_is_found_beside_ffmpeg_in_every_layout() {
6271+
let probe = if cfg!(windows) {
6272+
"ffprobe.exe"
6273+
} else {
6274+
"ffprobe"
6275+
};
6276+
for dir in ["/data/ffmpeg", "/data/ffmpeg/bin", "/opt/homebrew/bin"] {
6277+
let ffmpeg = Path::new(dir).join(if cfg!(windows) {
6278+
"ffmpeg.exe"
6279+
} else {
6280+
"ffmpeg"
6281+
});
6282+
assert_eq!(
6283+
super::ffprobe_beside(&ffmpeg),
6284+
Path::new(dir).join(probe),
6285+
"layout: {dir}"
6286+
);
6287+
}
6288+
}
6289+
6290+
#[test]
6291+
fn a_bare_ffmpeg_resolves_to_a_bare_ffprobe() {
6292+
// The PATH short-circuit in ensure_ffmpeg returns a bare "ffmpeg" with
6293+
// no directory, and the matching answer is a bare "ffprobe" that PATH
6294+
// resolves the same way.
6295+
//
6296+
// This pins behaviour rather than fixing a past bug: the previous
6297+
// `.parent().map(join).unwrap_or_else(fallback)` reached the same
6298+
// answer, because parent() is Some("") here and joining onto "" is a
6299+
// no-op. It is worth a test because the obvious "cleanup" -- making the
6300+
// unreachable fallback reachable, pointing at the data directory --
6301+
// would break exactly this case and nothing would notice.
6302+
let probe = if cfg!(windows) {
6303+
"ffprobe.exe"
6304+
} else {
6305+
"ffprobe"
6306+
};
6307+
let bare = PathBuf::from(if cfg!(windows) {
6308+
"ffmpeg.exe"
6309+
} else {
6310+
"ffmpeg"
6311+
});
6312+
assert_eq!(bare.parent(), Some(Path::new("")), "the trap itself");
6313+
assert_eq!(super::ffprobe_beside(&bare), PathBuf::from(probe));
6314+
}
6315+
61906316
// --- FFmpeg checksum verification (#172), macOS and Linux ---
61916317

61926318
#[cfg(unix)]

‎desktop/ui/setup.js‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,40 @@ async function installRuntimePack(appRoot) {
255255
}
256256
}
257257

258+
// What to tell someone whose FFmpeg step just failed.
259+
//
260+
// This used to be one sentence about networks and firewalls, attached to every
261+
// failure of the step. That is right for a download that did not arrive, and
262+
// actively misleading for a download that arrived and then would not run: a
263+
// reporter on an Apple Silicon Mac was told to check their firewall when the
264+
// binary they had was built for Intel (#637).
265+
//
266+
// macOS reports that case as EBADARCH, "Bad CPU type in executable (os error
267+
// 86)". It happens when the per-architecture primary source is unreachable and
268+
// the fallback, which publishes Intel builds only, is used instead. Rosetta
269+
// normally papers over it, but a bare exec() never triggers Rosetta's
270+
// install-on-demand prompt, so a Mac without it just fails.
271+
function ffmpegFailureHint(message) {
272+
const wrongArchitecture = /bad cpu type|os error 86|EBADARCH/i.test(message);
273+
if (wrongArchitecture) {
274+
return (
275+
"The FFmpeg that was downloaded is built for a different kind of Mac than yours, " +
276+
"so it cannot run. This happens when the Apple Silicon download source is " +
277+
"unreachable and StemDeck falls back to an Intel-only build. Two ways out: " +
278+
'install Rosetta with "softwareupdate --install-rosetta" in Terminal, which lets ' +
279+
"Intel builds run, or point StemDeck at a matching build by setting the " +
280+
"STEMDECK_FFMPEG_URL environment variable before launching. Deleting the ffmpeg " +
281+
"folder in StemDeck's data directory before retrying lets it fetch a fresh copy."
282+
);
283+
}
284+
return (
285+
"If this keeps failing, your network or firewall may be blocking the FFmpeg " +
286+
"download server. You can point StemDeck at a different FFmpeg build by " +
287+
"setting the STEMDECK_FFMPEG_URL environment variable before launching, then " +
288+
"retrying."
289+
);
290+
}
291+
258292
async function runSetup() {
259293
detailsEl.classList.add("hidden");
260294
retryBtn.classList.add("hidden");
@@ -361,12 +395,9 @@ async function runSetup() {
361395
// here rather than only in the generic path -- a network/firewall
362396
// block on the FFmpeg host is common enough (and retrying alone
363397
// won't fix it) to deserve a specific next step, not just "retry".
364-
const wrapped = new Error(String(err?.message ?? err));
365-
wrapped.hint =
366-
"If this keeps failing, your network or firewall may be blocking the FFmpeg " +
367-
"download server. You can point StemDeck at a different FFmpeg build by " +
368-
"setting the STEMDECK_FFMPEG_URL environment variable before launching, then " +
369-
"retrying.";
398+
const message = String(err?.message ?? err);
399+
const wrapped = new Error(message);
400+
wrapped.hint = ffmpegFailureHint(message);
370401
throw wrapped;
371402
} finally {
372403
stopProgress();

0 commit comments

Comments
 (0)