From 79a9460f8c333ff7e2dd3f9309acbe1b3f6ab940 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 27 Aug 2026 22:31:42 +0200 Subject: [PATCH 1/5] fix(build): stop the Windows cargo env values from breaking Linux builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/.cargo/config.toml sets FFMPEG_DIR and LIBCLANG_PATH in a global [env] because cargo has no [target..env] — the macOS section in that file is inert, and cargo says so on every invocation. Both values are Windows ones, so they are also set on Linux, where they point at paths that do not exist. build.rs already compensated for macOS and never did for Linux, so a bare `cargo check -p openscreen-compositor` failed on a stock Ubuntu: clang-sys takes LIBCLANG_PATH at its word, finds nothing under C:\Program Files\LLVM\bin and gives up with "Unable to find libclang" even though a distro libclang.so is almost always installed. Past that, FFMPEG_DIR still named the win64 tree, so vendoring the Linux one at the conventional location did not help either. Give Linux the same treatment macOS already had. Drop a LIBCLANG_PATH that holds no libclang rather than letting it sabotage clang-sys's own search, and accept FFMPEG_DIR only when it points at a tree that really exists, falling back to thirdparty/ffmpeg-linux64-lgpl-shared — the same order, and the same location, that scripts/build-linux-compositor-addon.mjs already resolves, so a bare cargo check and an npm build see the same tree. The vendored-tree lookup is now shared with the macOS branch instead of being written twice, and the panic that fires when nothing resolves finally names Linux alongside the other two. Verified on Linux: cargo check succeeds with no manual environment at all, and the 146 lib tests pass. cargo test still needs LD_LIBRARY_PATH for the ffmpeg .so files, which is a separate matter from this env leak. Co-Authored-By: Claude Opus 5 --- crates/.cargo/config.toml | 5 +++ crates/compositor/build.rs | 90 ++++++++++++++++++++++++++++++-------- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml index 29197773e..cb1fd42fb 100644 --- a/crates/.cargo/config.toml +++ b/crates/.cargo/config.toml @@ -1,5 +1,10 @@ # FFMPEG_DIR relatif au dossier crates/ (portable dans le repo). Y déposer le build # ffmpeg LGPL-shared (voir README). LIBCLANG_PATH = install LLVM locale (bindgen). +# Ces deux valeurs sont celles de WINDOWS et cargo n'a pas de `[target..env]` : +# le `[env]` ci-dessous est global, donc elles sont posées sur les trois OS. C'est +# `crates/compositor/build.rs` qui les neutralise ailleurs — voir +# `point_libclang_at_the_xcode_toolchain()` (macOS) et `drop_unusable_libclang_path()` +# (Linux). Ne pas supposer qu'un `LIBCLANG_PATH` non vide désigne un vrai libclang. # Ces valeurs cèdent à une vraie variable d'environnement (force=false par défaut). # # Pinné sur le MÊME build release-branch (n8.1.2-34-g9b6c8969e0, tag BtbN diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs index f8c9e5f5b..4f22c47ae 100644 --- a/crates/compositor/build.rs +++ b/crates/compositor/build.rs @@ -10,6 +10,8 @@ fn main() { if target_is_macos { point_libclang_at_the_xcode_toolchain(); + } else if target_os == "linux" { + drop_unusable_libclang_path(); } // Le pin ffmpeg est porté par `.cargo/config.toml` ; sur Windows c'est le @@ -30,27 +32,24 @@ fn main() { env::var("MAC_FFMPEG_DIR") .ok() .filter(|v| Path::new(v).join("include").exists()) - .or_else(|| { - // `thirdparty/` est frère de `compositor/`, sous `crates/` — c'est aussi - // ce que le pin Windows désigne (`relative = true` dans - // `crates/.cargo/config.toml`, relatif au dossier de la config). - // build.rs s'exécute avec cwd = racine du crate, pas `crates/`, donc on - // remonte depuis CARGO_MANIFEST_DIR plutôt que d'écrire un chemin relatif - // qui viserait `crates/compositor/thirdparty/`. - let candidate = Path::new(&env::var("CARGO_MANIFEST_DIR").ok()?) - .parent()? - .join("thirdparty") - .join("ffmpeg-n8.1.2-macos64-lgpl-shared"); - candidate - .join("include") - .exists() - .then(|| candidate.to_string_lossy().to_string()) - }) + .or_else(|| vendored_ffmpeg_tree("ffmpeg-n8.1.2-macos64-lgpl-shared")) .or_else(|| { env::var("FFMPEG_DIR") .ok() .filter(|v| Path::new(v).join("include").exists()) }) + } else if target_os == "linux" { + // Même piège que LIBCLANG_PATH, même remède : le `FFMPEG_DIR` du `[env]` global + // désigne l'arbre win64, qui n'existe pas ici, donc on ne l'accepte que s'il + // pointe sur un arbre RÉEL — c'est-à-dire quand un dev ou + // scripts/build-linux-compositor-addon.mjs l'a posé à la main. Sinon on retombe + // sur l'emplacement vendorisé conventionnel, dans le même ordre que ce script + // (`resolveFfmpegDir`), pour qu'un `cargo check` nu et un build via npm voient + // le même arbre. + env::var("FFMPEG_DIR") + .ok() + .filter(|v| Path::new(v).join("include").exists()) + .or_else(|| vendored_ffmpeg_tree("ffmpeg-linux64-lgpl-shared")) } else { env::var("FFMPEG_DIR").ok() }; @@ -58,9 +57,12 @@ fn main() { let include_dir = match ff.as_ref() { Some(v) => Path::new(v).join("include").to_string_lossy().to_string(), None => panic!( - "crates/compositor build.rs: FFMPEG_DIR non défini (target={}). \ + "crates/compositor build.rs: aucun arbre ffmpeg utilisable (target={}). \ Sur Windows, voir crates/.cargo/config.toml. Sur macOS, poser \ - MAC_FFMPEG_DIR ou vendoriser thirdparty/ffmpeg-n8.1.2-macos64-lgpl-shared.", + MAC_FFMPEG_DIR ou vendoriser thirdparty/ffmpeg-n8.1.2-macos64-lgpl-shared. \ + Sur Linux, poser FFMPEG_DIR ou vendoriser \ + thirdparty/ffmpeg-linux64-lgpl-shared (arbre *shared*, avec include/ et \ + lib/ — celui de scripts/fetch-ffmpeg.mjs est statique et ne convient pas).", target_os ), }; @@ -316,3 +318,55 @@ fn point_libclang_at_the_xcode_toolchain() { None => env::remove_var("LIBCLANG_PATH"), } } + +/// L'arbre ffmpeg vendorisé sous `crates/thirdparty/`, s'il existe vraiment. +/// +/// `thirdparty/` est frère de `compositor/`, sous `crates/` — c'est aussi ce que le pin +/// Windows désigne (`relative = true` dans `crates/.cargo/config.toml`, relatif au +/// dossier de la config). build.rs s'exécute avec cwd = racine du crate, pas `crates/`, +/// donc on remonte depuis CARGO_MANIFEST_DIR plutôt que d'écrire un chemin relatif qui +/// viserait `crates/compositor/thirdparty/`. +fn vendored_ffmpeg_tree(name: &str) -> Option { + let candidate = Path::new(&env::var("CARGO_MANIFEST_DIR").ok()?) + .parent()? + .join("thirdparty") + .join(name); + candidate + .join("include") + .exists() + .then(|| candidate.to_string_lossy().to_string()) +} + +/// Même remède que le versant macOS, pour la même cause. +/// +/// `crates/.cargo/config.toml` pose `LIBCLANG_PATH` dans un `[env]` GLOBAL, faute de +/// `[target..env]` en cargo. La valeur est celle de Windows +/// (`C:\Program Files\LLVM\bin`) et elle est donc renseignée sous Linux aussi, où +/// clang-sys la prend au mot : il ne regarde nulle part ailleurs et abandonne sur +/// « Unable to find libclang », alors qu'un `libclang.so` de distribution est presque +/// toujours installé. Un `cargo check -p openscreen-compositor` nu échouait donc sur +/// une Ubuntu de série — exactement ce que `freestanding_header_args()` juste au-dessus +/// s'emploie à éviter par ailleurs. +/// +/// On ne devine pas le bon chemin : clang-sys sait chercher tout seul (LD_LIBRARY_PATH, +/// PATH, /usr/lib/llvm-*/lib …). Il suffit de ne pas lui mentir. Une valeur posée par le +/// dev et réellement utilisable est conservée telle quelle — `force = false` fait déjà +/// gagner l'environnement réel sur la config, et on ne casse pas un choix explicite. +fn drop_unusable_libclang_path() { + println!("cargo:rerun-if-env-changed=LIBCLANG_PATH"); + let Ok(dir) = env::var("LIBCLANG_PATH") else { + return; + }; + // `libclang.so`, `libclang.so.1`, `libclang-14.so` : les distributions ne + // s'accordent pas sur le suffixe, seul le préfixe est stable. + let holds_libclang = std::fs::read_dir(&dir).is_ok_and(|entries| { + entries.flatten().any(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.starts_with("libclang") && name.contains(".so") + }) + }); + if !holds_libclang { + env::remove_var("LIBCLANG_PATH"); + } +} From 09993e9a30d9ed827c81f6132529b1618c875cca Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 27 Aug 2026 22:44:53 +0200 Subject: [PATCH 2/5] fix(build): require lib/ as well as include/ when picking an ffmpeg tree Candidate selection filtered on include/ alone, but the linkage section below puts a rustc-link-search on /lib and asks for avformat/avcodec/avutil/ swscale/swresample. So a tree carrying only headers was accepted, which also stopped the fallback from reaching a complete vendored tree, and the build then failed much later on an error that does not name its cause. resolveFfmpegDir() in scripts/build-linux-compositor-addon.mjs already checks both, so this is the same rule on both sides rather than a new one. Reproduced with an FFMPEG_DIR holding include/ and no lib/: before, that tree won and the build failed; now it is skipped and the vendored tree is used. Co-Authored-By: Claude Opus 5 --- crates/compositor/build.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs index 4f22c47ae..bd4a46d8a 100644 --- a/crates/compositor/build.rs +++ b/crates/compositor/build.rs @@ -31,12 +31,12 @@ fn main() { // un dev l'a posé à la main pour macOS, jamais quand il vient du pin Windows. env::var("MAC_FFMPEG_DIR") .ok() - .filter(|v| Path::new(v).join("include").exists()) + .filter(|v| usable_ffmpeg_tree(Path::new(v))) .or_else(|| vendored_ffmpeg_tree("ffmpeg-n8.1.2-macos64-lgpl-shared")) .or_else(|| { env::var("FFMPEG_DIR") .ok() - .filter(|v| Path::new(v).join("include").exists()) + .filter(|v| usable_ffmpeg_tree(Path::new(v))) }) } else if target_os == "linux" { // Même piège que LIBCLANG_PATH, même remède : le `FFMPEG_DIR` du `[env]` global @@ -48,7 +48,7 @@ fn main() { // le même arbre. env::var("FFMPEG_DIR") .ok() - .filter(|v| Path::new(v).join("include").exists()) + .filter(|v| usable_ffmpeg_tree(Path::new(v))) .or_else(|| vendored_ffmpeg_tree("ffmpeg-linux64-lgpl-shared")) } else { env::var("FFMPEG_DIR").ok() @@ -319,6 +319,19 @@ fn point_libclang_at_the_xcode_toolchain() { } } +/// Un arbre ffmpeg exploitable : `include/` pour bindgen ET `lib/` pour le linkage. +/// +/// Les deux, pas seulement le premier : la section « linkage » plus bas pose un +/// `rustc-link-search` sur `/lib` et réclame avformat/avcodec/avutil/swscale/ +/// swresample. Un arbre n'ayant que les en-têtes passait le filtre, écartait le repli +/// vers un arbre vendorisé complet, et échouait bien plus tard sur un `cannot find +/// -lavformat` qui ne désigne pas sa cause. `resolveFfmpegDir()` dans +/// scripts/build-linux-compositor-addon.mjs vérifie déjà les deux — c'est la même règle +/// des deux côtés. +fn usable_ffmpeg_tree(dir: &Path) -> bool { + dir.join("include").is_dir() && dir.join("lib").is_dir() +} + /// L'arbre ffmpeg vendorisé sous `crates/thirdparty/`, s'il existe vraiment. /// /// `thirdparty/` est frère de `compositor/`, sous `crates/` — c'est aussi ce que le pin @@ -331,10 +344,7 @@ fn vendored_ffmpeg_tree(name: &str) -> Option { .parent()? .join("thirdparty") .join(name); - candidate - .join("include") - .exists() - .then(|| candidate.to_string_lossy().to_string()) + usable_ffmpeg_tree(&candidate).then(|| candidate.to_string_lossy().to_string()) } /// Même remède que le versant macOS, pour la même cause. From 003561d32f28de614e789835a943feed07abefff Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 27 Aug 2026 22:59:27 +0200 Subject: [PATCH 3/5] fix(build): accept a LIBCLANG_PATH that names a library file, and skip libclang-cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drop_unusable_libclang_path only ever called read_dir on the value, so a LIBCLANG_PATH pointing straight at /usr/lib/llvm-N/lib/libclang.so.1 was treated as unusable and removed. clang-sys accepts both forms — search_libclang_directories checks "if the path is a matching file" before checking for a directory containing one — so that was a working setup being thrown away. The filename test was also too loose: it matched libclang-cpp.so.10, which clang-sys explicitly refuses (filename.contains("-cpp.")) because libclang_shared was renamed libclang-cpp in Clang 10. Keeping such a path preserved something clang-sys would reject straight after. Both rules now mirror clang-sys rather than approximating it. Verified against clang-sys 1.9.1 sources, and by exercising the selection on this machine: a Windows path, a real file, a real directory, a directory holding only libclang-cpp, and a missing path all resolve as intended. Co-Authored-By: Claude Opus 5 --- crates/compositor/build.rs | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs index bd4a46d8a..d55243254 100644 --- a/crates/compositor/build.rs +++ b/crates/compositor/build.rs @@ -364,19 +364,37 @@ fn vendored_ffmpeg_tree(name: &str) -> Option { /// gagner l'environnement réel sur la config, et on ne casse pas un choix explicite. fn drop_unusable_libclang_path() { println!("cargo:rerun-if-env-changed=LIBCLANG_PATH"); - let Ok(dir) = env::var("LIBCLANG_PATH") else { + let Ok(value) = env::var("LIBCLANG_PATH") else { return; }; - // `libclang.so`, `libclang.so.1`, `libclang-14.so` : les distributions ne - // s'accordent pas sur le suffixe, seul le préfixe est stable. - let holds_libclang = std::fs::read_dir(&dir).is_ok_and(|entries| { - entries.flatten().any(|e| { - let name = e.file_name(); - let name = name.to_string_lossy(); - name.starts_with("libclang") && name.contains(".so") + // clang-sys accepte DEUX formes : un fichier bibliothèque, ou un répertoire qui en + // contient un (`search_libclang_directories` : « Check if the path is a matching + // file », puis « … a directory containing a matching file »). Ne traiter que le + // répertoire retirerait un `LIBCLANG_PATH` parfaitement valide pointant sur + // `/usr/lib/llvm-N/lib/libclang.so.1`. + let path = Path::new(&value); + let usable = if path.is_file() { + path.file_name() + .is_some_and(|n| is_libclang_filename(&n.to_string_lossy())) + } else { + std::fs::read_dir(path).is_ok_and(|entries| { + entries + .flatten() + .any(|e| is_libclang_filename(&e.file_name().to_string_lossy())) }) - }); - if !holds_libclang { + }; + if !usable { env::remove_var("LIBCLANG_PATH"); } } + +/// Les noms de fichier que clang-sys reconnaît sous Linux, sa règle et pas la nôtre : +/// `libclang.so`, `libclang-.so`, `libclang.so.`, `libclang-.so.`. +/// +/// `libclang-cpp.*` en est exclu, parce que clang-sys l'exclut lui-même +/// (`filename.contains("-cpp.")`) : `libclang_shared` a été renommé `libclang-cpp` à +/// partir de Clang 10 et se fait sinon happer par les motifs cherchant `libclang`. Le +/// garder ici reviendrait à conserver un chemin que clang-sys refusera ensuite. +fn is_libclang_filename(name: &str) -> bool { + name.starts_with("libclang") && name.contains(".so") && !name.contains("-cpp.") +} From a46a6934bb95fb099579d14fd408b634146226cb Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 27 Aug 2026 23:10:48 +0200 Subject: [PATCH 4/5] fix(build): match clang-sys's exact libclang filename patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predicate tested a prefix and a substring, so it also accepted names clang-sys never matches — libclang_extra.so (underscore, not the dash the libclang-*.so pattern wants) and libclang.software (".so" happens to be a substring of ".software"). That is not cosmetic, because search_libclang_directories stops at LIBCLANG_PATH once the variable is set — "Search only the path indicated by the relevant environment variable" — and never falls back to llvm-config, PATH or the known directories. Keeping a directory that holds only a near-miss name would therefore doom the build exactly the way the Windows value did, which is the whole failure this function exists to prevent. Now matches the four real patterns: libclang.so, libclang-.so, libclang.so., libclang-.so., with an empty rejected and libclang-cpp.* still excluded. Verified on the predicate itself, near-misses included: the five accepted forms plus libclang-cpp.so.10, libclang_extra.so, libclang.software, libclang-.so, libclangfoo and clang.so all resolve as intended. cargo check passes with LIBCLANG_PATH unset, set to the Windows value, to a library file and to a directory; 146 lib tests pass. Co-Authored-By: Claude Opus 5 --- crates/compositor/build.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs index d55243254..93097fd90 100644 --- a/crates/compositor/build.rs +++ b/crates/compositor/build.rs @@ -388,13 +388,33 @@ fn drop_unusable_libclang_path() { } } -/// Les noms de fichier que clang-sys reconnaît sous Linux, sa règle et pas la nôtre : -/// `libclang.so`, `libclang-.so`, `libclang.so.`, `libclang-.so.`. +/// Les motifs EXACTS que clang-sys cherche sous Linux : `libclang.so`, +/// `libclang-.so`, `libclang.so.`, `libclang-.so.`. /// -/// `libclang-cpp.*` en est exclu, parce que clang-sys l'exclut lui-même -/// (`filename.contains("-cpp.")`) : `libclang_shared` a été renommé `libclang-cpp` à -/// partir de Clang 10 et se fait sinon happer par les motifs cherchant `libclang`. Le -/// garder ici reviendrait à conserver un chemin que clang-sys refusera ensuite. +/// Coller aux motifs, et pas seulement au préfixe, parce que `search_libclang_directories` +/// s'arrête net sur `LIBCLANG_PATH` quand la variable est posée — « Search only the path +/// indicated by the relevant environment variable » — sans jamais retomber sur +/// `llvm-config`, le PATH ou les répertoires connus. Conserver un chemin qui ne contient +/// qu'un `libclang_extra.so` ou un `libclang.software` reviendrait donc à condamner le +/// build, exactement comme le faisait la valeur Windows. +/// +/// `libclang-cpp.*` est écarté d'entrée : clang-sys l'écarte lui-même +/// (`filename.contains("-cpp.")`), `libclang_shared` ayant été renommé `libclang-cpp` à +/// partir de Clang 10. fn is_libclang_filename(name: &str) -> bool { - name.starts_with("libclang") && name.contains(".so") && !name.contains("-cpp.") + if name.contains("-cpp.") { + return false; + } + let Some(rest) = name.strip_prefix("libclang") else { + return false; + }; + // Soit `libclang.so…`, soit `libclang-.so…` avec un `` non vide. + let rest = match rest.strip_prefix('-') { + Some(versioned) => match versioned.find(".so") { + None | Some(0) => return false, + Some(i) => &versioned[i..], + }, + None => rest, + }; + rest == ".so" || rest.starts_with(".so.") } From b1f7575aab0f4cfd9f57e0196ad82c23f5bd6e14 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 27 Aug 2026 23:24:06 +0200 Subject: [PATCH 5/5] fix(build): only accept a LIBCLANG_PATH entry that is a regular file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_dir also yields subdirectories and special files, so the name-only test would accept a directory called libclang.so. clang-sys globs by name too, so it would select that entry, fail to load it, and — since it never falls back once LIBCLANG_PATH is set — take the build down with it. Removing the variable instead lets its normal search find a real libclang. Checked both ways: a directory holding a subdirectory named libclang.so is now dropped and the build succeeds through the fallback, while a real /usr/lib/llvm-14/lib is still kept. 146 lib tests pass. Co-Authored-By: Claude Opus 5 --- crates/compositor/build.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/compositor/build.rs b/crates/compositor/build.rs index 93097fd90..2dc9ec53b 100644 --- a/crates/compositor/build.rs +++ b/crates/compositor/build.rs @@ -380,7 +380,13 @@ fn drop_unusable_libclang_path() { std::fs::read_dir(path).is_ok_and(|entries| { entries .flatten() - .any(|e| is_libclang_filename(&e.file_name().to_string_lossy())) + // `is_file()` autant que le nom : `read_dir` rend aussi les + // sous-répertoires et les fichiers spéciaux, et un répertoire qui + // s'appellerait `libclang.so` passerait le seul test de nom — clang-sys + // le retiendrait puis échouerait à le charger, sans repli possible. + .any(|e| { + e.path().is_file() && is_libclang_filename(&e.file_name().to_string_lossy()) + }) }) }; if !usable {