From 477a89859a2b0032213cb27ec177374b3a5f0ed6 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 21 Sep 2026 14:51:01 -0700 Subject: [PATCH 1/3] fix(esp32): resolve guarded framework libraries --- .../fbuild-build-engine/src/framework_libs.rs | 20 +++- .../src/framework_libs_tests.rs | 100 +++++++++++++++++ .../src/esp32/orchestrator/build.rs | 6 +- crates/fbuild-build/tests/esp32_build.rs | 103 +++++++++++++++++- crates/fbuild-header-scan/src/lib.rs | 4 +- crates/fbuild-header-scan/src/scanner.rs | 12 +- .../fbuild-header-scan/src/scanner_tests.rs | 30 +++++ crates/fbuild-library-select/src/cache.rs | 2 +- crates/fbuild-library-select/src/lib.rs | 16 ++- 9 files changed, 276 insertions(+), 17 deletions(-) diff --git a/crates/fbuild-build-engine/src/framework_libs.rs b/crates/fbuild-build-engine/src/framework_libs.rs index c707501df..d80a4e35b 100644 --- a/crates/fbuild-build-engine/src/framework_libs.rs +++ b/crates/fbuild-build-engine/src/framework_libs.rs @@ -95,12 +95,15 @@ pub fn resolve_framework_library_selection_active_declared( ) } -/// Active framework selection with additional translation-unit seeds and -/// include roots supplied by externally declared libraries. +/// Active framework selection with additional translation-unit seeds and the +/// compiler's complete include path. /// /// An external library can include a framework header from one of its own /// `.cpp` files. The compiler sees that dependency, so the LDF must see it as /// well or the selected framework archive is omitted from the final link. +/// The complete include path also lets the scanner resolve SDK headers that +/// define capability macros guarding later framework-library includes (for +/// example `soc/soc_caps.h` guarding ESP32's `LittleFS.h`). pub fn resolve_framework_library_selection_active_declared_with_extra( libraries: &[FrameworkLibrary], project_dir: &Path, @@ -108,16 +111,23 @@ pub fn resolve_framework_library_selection_active_declared_with_extra( defines: &HashMap, declared: &[String], extra_source_files: &[PathBuf], - extra_include_dirs: &[PathBuf], + compiler_include_dirs: &[PathBuf], ) -> fbuild_library_select::Selection { let roots = framework_include_scan_roots(project_dir, src_dir); let filtered = filter_framework_libs_shadowed_by_project(libraries, &roots); let mut seeds = collect_project_seeds(&roots); seeds.extend_from_slice(extra_source_files); - let mut search_paths = project_search_paths(&roots); - for include_dir in extra_include_dirs { + // Preserve the compiler's observable include order. In particular, ESP32 + // searches core/variant/SDK headers before project headers; reversing that + // order can make the LDF inspect a shadowing header the compiler never + // sees and derive the wrong capability set. + let mut search_paths = Vec::new(); + for include_dir in compiler_include_dirs { push_existing_unique(&mut search_paths, include_dir.clone()); } + for project_path in project_search_paths(&roots) { + push_existing_unique(&mut search_paths, project_path); + } fbuild_library_select::resolve_with_stats_active_declared( &seeds, &search_paths, diff --git a/crates/fbuild-build-engine/src/framework_libs_tests.rs b/crates/fbuild-build-engine/src/framework_libs_tests.rs index 2b90b815c..272930780 100644 --- a/crates/fbuild-build-engine/src/framework_libs_tests.rs +++ b/crates/fbuild-build-engine/src/framework_libs_tests.rs @@ -777,3 +777,103 @@ fn external_library_source_selects_framework_dependency() { assert_eq!(selection.required_libraries, vec!["WiFi"]); } + +/// An SDK header on the compiler include path can define a capability macro +/// that guards a later framework-library include. The selection scan must see +/// the same include path as the compiler or it incorrectly prunes that branch. +#[test] +fn sdk_capability_header_keeps_framework_library_include_reachable() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("project"); + let src_dir = project_dir.join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::write( + src_dir.join("main.cpp"), + "#include \n\ + #if defined(SOC_WIFI_SUPPORTED) && SOC_WIFI_SUPPORTED\n\ + #include \n\ + #endif\n", + ) + .unwrap(); + + let sdk_dir = tmp.path().join("sdk"); + std::fs::create_dir_all(sdk_dir.join("soc")).unwrap(); + std::fs::write( + sdk_dir.join("soc").join("soc_caps.h"), + "#define SOC_WIFI_SUPPORTED 1\n", + ) + .unwrap(); + + let littlefs_dir = tmp + .path() + .join("framework") + .join("libraries") + .join("LittleFS"); + std::fs::create_dir_all(&littlefs_dir).unwrap(); + std::fs::write(littlefs_dir.join("LittleFS.h"), "").unwrap(); + std::fs::write(littlefs_dir.join("LittleFS.cpp"), "int littlefs;\n").unwrap(); + + let selection = resolve_framework_library_selection_active_declared_with_extra( + &[FrameworkLibrary { + name: "LittleFS".to_string(), + dir: littlefs_dir.clone(), + include_dirs: vec![littlefs_dir.clone()], + source_files: vec![littlefs_dir.join("LittleFS.cpp")], + }], + &project_dir, + &src_dir, + &HashMap::new(), + &[], + &[], + &[sdk_dir], + ); + + assert_eq!(selection.required_libraries, vec!["LittleFS"]); +} + +#[test] +fn compiler_include_order_wins_over_shadowing_project_header() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("project"); + let src_dir = project_dir.join("src"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::write(src_dir.join("main.cpp"), "#include \n").unwrap(); + std::fs::create_dir_all(src_dir.join("soc")).unwrap(); + std::fs::write( + src_dir.join("soc").join("capability.h"), + "#define PROJECT_SHADOW 1\n", + ) + .unwrap(); + + let sdk_dir = tmp.path().join("sdk"); + std::fs::create_dir_all(sdk_dir.join("soc")).unwrap(); + let sdk_header = sdk_dir.join("soc").join("capability.h"); + std::fs::write(&sdk_header, "#define SDK_HEADER 1\n").unwrap(); + + let selection = resolve_framework_library_selection_active_declared_with_extra( + &[], + &project_dir, + &src_dir, + &HashMap::new(), + &[], + &[], + &[sdk_dir], + ); + + assert!( + selection + .included_files + .contains(&sdk_header.canonicalize().unwrap()), + "selection must resolve headers using compiler include order" + ); + assert!( + !selection.included_files.contains( + &src_dir + .join("soc") + .join("capability.h") + .canonicalize() + .unwrap() + ), + "a project shadow searched after the SDK must not replace the compiler-selected header" + ); +} diff --git a/crates/fbuild-build-esp/src/esp32/orchestrator/build.rs b/crates/fbuild-build-esp/src/esp32/orchestrator/build.rs index 11e2b56bc..f4aed5eab 100644 --- a/crates/fbuild-build-esp/src/esp32/orchestrator/build.rs +++ b/crates/fbuild-build-esp/src/esp32/orchestrator/build.rs @@ -334,7 +334,6 @@ impl BuildOrchestrator for Esp32Orchestrator { .flat_map(|library| library.include_dirs.iter().cloned()), ); let mut external_library_sources = Vec::new(); - let mut external_library_include_dirs = Vec::new(); if !lib_deps.is_empty() { let libs_dir = build_dir.join("libs"); @@ -397,8 +396,7 @@ impl BuildOrchestrator for Esp32Orchestrator { // cross-project cache hits. Library includes are same-tier, so a // stable sort is safe for include resolution. external_library_sources = lib_result.source_files; - external_library_include_dirs = lib_result.include_dirs; - let mut lib_include_dirs = external_library_include_dirs.clone(); + let mut lib_include_dirs = lib_result.include_dirs; lib_include_dirs.sort(); include_dirs.extend(lib_include_dirs); library_archives = lib_result.archives; @@ -429,7 +427,7 @@ impl BuildOrchestrator for Esp32Orchestrator { &library_selection_defines, &declared_lib_deps, &external_library_sources, - &external_library_include_dirs, + &include_dirs, ); let selected_framework_libraries: Vec<_> = framework_libraries .iter() diff --git a/crates/fbuild-build/tests/esp32_build.rs b/crates/fbuild-build/tests/esp32_build.rs index d89b1ba9a..1f19e3a98 100644 --- a/crates/fbuild-build/tests/esp32_build.rs +++ b/crates/fbuild-build/tests/esp32_build.rs @@ -166,7 +166,7 @@ async fn build_esp32c6_blink() { fs::write( project_dir.join("platformio.ini"), - "[env:esp32c6]\nplatform = espressif32\nboard = esp32-c6\nframework = arduino\n", + "[env:esp32c6]\nplatform = espressif32\nboard = esp32-c6-devkitc-1\nframework = arduino\n", ) .unwrap(); @@ -242,6 +242,107 @@ void loop() { ); } +/// Regression gate for #1452: SDK capability headers participate in library +/// discovery, so a guarded LittleFS include selects both LittleFS and its FS +/// dependency without a manual `lib_deps` declaration. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "downloads ESP32 toolchain (~hundreds of MB)"] +async fn build_esp32c6_discovers_guarded_littlefs() { + install_test_compile_backend().await; + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path(); + + fs::write( + project_dir.join("platformio.ini"), + "[env:esp32c6]\nplatform = espressif32\nboard = esp32-c6-devkitc-1\nframework = arduino\n", + ) + .unwrap(); + + let src_dir = project_dir.join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write( + src_dir.join("platform_flags.h"), + "#define FL_IS_ESP32 1\n#define FL_PLATFORM_HAS_LARGE_MEMORY 1\n", + ) + .unwrap(); + fs::write( + src_dir.join("main.cpp"), + "\ +#include +#include \"platform_flags.h\" +#include + +#if !defined(FASTLED_AUTORESEARCH_LOW_MEMORY) && !FL_PLATFORM_HAS_LARGE_MEMORY +#define FASTLED_AUTORESEARCH_LOW_MEMORY 1 +#endif + +#if !(defined(FASTLED_AUTORESEARCH_LOW_MEMORY) && FASTLED_AUTORESEARCH_LOW_MEMORY) +#if defined(FL_IS_ESP32) && defined(SOC_WIFI_SUPPORTED) && SOC_WIFI_SUPPORTED +#include +#endif +#endif + +void setup() { +#if !(defined(FASTLED_AUTORESEARCH_LOW_MEMORY) && FASTLED_AUTORESEARCH_LOW_MEMORY) +#if defined(FL_IS_ESP32) && defined(SOC_WIFI_SUPPORTED) && SOC_WIFI_SUPPORTED + LittleFS.begin(true); + File file = LittleFS.open(\"/probe\", FILE_WRITE); + file.print(\"ok\"); + file.close(); +#endif +#endif +} + +void loop() {} +", + ) + .unwrap(); + + let build_dir = project_dir.join(format!( + "{}/{}/esp32c6/release", + fbuild_paths::FBUILD_DIR_NAME, + fbuild_paths::BUILD_DIR_NAME + )); + let params = BuildParams { + project_dir: project_dir.to_path_buf(), + env_name: "esp32c6".to_string(), + clean_all: false, + clean: true, + clean_only: false, + profile: BuildProfile::Release, + build_dir: build_dir.clone(), + verbose: true, + jobs: None, + generate_compiledb: false, + compiledb_only: false, + log_sender: None, + symbol_analysis: false, + symbol_analysis_path: None, + no_timestamp: false, + src_dir: None, + pio_env: Default::default(), + extra_build_flags: Vec::new(), + watch_set_cache: None, + bloat_analysis: false, + caller_path: None, + }; + + let orchestrator = fbuild_build::esp32::orchestrator::Esp32Orchestrator; + let result = under_test_timeout(orchestrator.build(¶ms)) + .await + .expect("ESP32-C6 LittleFS build should succeed without lib_deps"); + + assert!(result.success); + assert!( + build_dir.join("fw_libs/liblittlefs.a").is_file(), + "LittleFS must be auto-discovered from the guarded sketch include" + ); + assert!( + build_dir.join("fw_libs/libfs.a").is_file(), + "FS must be discovered transitively from LittleFS.h" + ); +} + /// Build a self-contained ESP32-C3 blink sketch (RISC-V). /// /// ESP32-C3 uses the rv32imc RISC-V ISA. This test validates the full build diff --git a/crates/fbuild-header-scan/src/lib.rs b/crates/fbuild-header-scan/src/lib.rs index 0ad0c8883..a9ace6d05 100644 --- a/crates/fbuild-header-scan/src/lib.rs +++ b/crates/fbuild-header-scan/src/lib.rs @@ -10,8 +10,8 @@ mod scanner; mod walker; pub use scanner::{ - IncludeKind, IncludeRef, Span, active_defines, defined_macro_names, scan, scan_active, - scan_active_with_known, + IncludeKind, IncludeRef, Span, active_defines, active_defines_with_known, defined_macro_names, + scan, scan_active, scan_active_with_known, }; pub use walker::{ WalkResult, WalkState, collect_defined_macro_names, walk, walk_active, walk_with_state, diff --git a/crates/fbuild-header-scan/src/scanner.rs b/crates/fbuild-header-scan/src/scanner.rs index 2c8234e75..3c9fbd2b7 100644 --- a/crates/fbuild-header-scan/src/scanner.rs +++ b/crates/fbuild-header-scan/src/scanner.rs @@ -240,8 +240,18 @@ pub fn defined_macro_names(src: &str) -> Vec { /// The LDF uses this for sketch translation units so a sketch-local feature /// define remains visible when its included headers are scanned. pub fn active_defines(src: &str, defines: &HashMap) -> HashMap { + active_defines_with_known(src, defines, &HashSet::new()) +} + +/// Return active source-local defines while treating macros defined elsewhere +/// in the reachable corpus as undecidable. +pub fn active_defines_with_known( + src: &str, + defines: &HashMap, + defined_somewhere: &HashSet, +) -> HashMap { let mut macros = defines.clone(); - let _ = active_source(src, &mut macros, &HashSet::new()); + let _ = active_source(src, &mut macros, defined_somewhere); macros } diff --git a/crates/fbuild-header-scan/src/scanner_tests.rs b/crates/fbuild-header-scan/src/scanner_tests.rs index 65de48e94..7578937df 100644 --- a/crates/fbuild-header-scan/src/scanner_tests.rs +++ b/crates/fbuild-header-scan/src/scanner_tests.rs @@ -553,3 +553,33 @@ fn a_true_operand_settles_or_despite_an_undecidable_one() { let paths: Vec<&str> = refs.iter().map(|r| r.path.as_str()).collect(); assert_eq!(paths, vec!["taken.h"], "{paths:?}"); } + +#[test] +fn nested_unknown_fastled_guards_remain_visible() { + let known: HashSet = [ + "FASTLED_AUTORESEARCH_LOW_MEMORY", + "FL_PLATFORM_HAS_LARGE_MEMORY", + "FL_IS_ESP32", + "SOC_WIFI_SUPPORTED", + ] + .into_iter() + .map(str::to_string) + .collect(); + let refs = scan_active_with_known( + "#if !defined(FASTLED_AUTORESEARCH_LOW_MEMORY) && !FL_PLATFORM_HAS_LARGE_MEMORY\n\ + #define FASTLED_AUTORESEARCH_LOW_MEMORY 1\n\ + #endif\n\ + #if !(defined(FASTLED_AUTORESEARCH_LOW_MEMORY) && FASTLED_AUTORESEARCH_LOW_MEMORY)\n\ + #if defined(FL_IS_ESP32)\n\ + #include \n\ + #endif\n\ + #if defined(FL_IS_ESP32) && defined(SOC_WIFI_SUPPORTED) && SOC_WIFI_SUPPORTED\n\ + #include \n\ + #endif\n\ + #endif\n", + &HashMap::new(), + &known, + ); + let paths: Vec<&str> = refs.iter().map(|r| r.path.as_str()).collect(); + assert!(paths.contains(&"LittleFS.h"), "{paths:?}"); +} diff --git a/crates/fbuild-library-select/src/cache.rs b/crates/fbuild-library-select/src/cache.rs index 8bb50b99c..6f13c849b 100644 --- a/crates/fbuild-library-select/src/cache.rs +++ b/crates/fbuild-library-select/src/cache.rs @@ -39,7 +39,7 @@ pub const SCANNER_VERSION: u32 = 3; /// Bump when the resolver's 2-pass LDF semantics change (seed expansion, /// attribution, convergence rule, etc.). -pub const LDF_MODE_VERSION: u32 = 6; +pub const LDF_MODE_VERSION: u32 = 7; /// Namespace for the library-selection file cache. pub const NAMESPACE: &str = "library-selection"; diff --git a/crates/fbuild-library-select/src/lib.rs b/crates/fbuild-library-select/src/lib.rs index b2eb1269a..b1204f2b2 100644 --- a/crates/fbuild-library-select/src/lib.rs +++ b/crates/fbuild-library-select/src/lib.rs @@ -21,7 +21,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use fbuild_header_scan::{ - WalkState, active_defines, collect_defined_macro_names, walk_with_state, + WalkState, active_defines_with_known, collect_defined_macro_names, walk_with_state, walk_with_state_active_known, }; use fbuild_packages::library::FrameworkLibrary; @@ -136,7 +136,16 @@ pub fn resolve_with_stats_active_declared( defines: &HashMap, declared: &[String], ) -> (Selection, ResolveStats) { - let effective_defines = seed_defines(seeds, defines); + let mut full_search_paths = project_search_paths.to_vec(); + for library in libraries { + for dir in &library.include_dirs { + if !full_search_paths.contains(dir) { + full_search_paths.push(dir.clone()); + } + } + } + let defined_somewhere = collect_defined_macro_names(seeds, &full_search_paths); + let effective_defines = seed_defines(seeds, defines, &defined_somewhere); resolve_with_stats_impl_declared( seeds, project_search_paths, @@ -204,13 +213,14 @@ pub fn external_declared_deps(declared: &[String], libraries: &[FrameworkLibrary fn seed_defines( seeds: &[PathBuf], compiler_defines: &HashMap, + defined_somewhere: &std::collections::HashSet, ) -> HashMap { let mut defines = compiler_defines.clone(); let mut ordered_seeds = seeds.to_vec(); ordered_seeds.sort(); for seed in ordered_seeds { if let Ok(source) = std::fs::read_to_string(seed) { - defines = active_defines(&source, &defines); + defines = active_defines_with_known(&source, &defines, defined_somewhere); } } defines From 7f76d481c6c6f1ace6700d4de184fcffe670487b Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 21 Sep 2026 14:51:01 -0700 Subject: [PATCH 2/3] test(qemu): isolate runtime cache lookup --- .../src/toolchain/esp_qemu_runtime.rs | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs index 846fbb51c..70ff2cd09 100644 --- a/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs +++ b/crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs @@ -99,6 +99,31 @@ impl QemuLinuxRuntime { }) } + #[cfg(test)] + fn for_host_with_cache_root( + host: HostPlatform, + project_dir: &Path, + cache_root: &Path, + ) -> Result { + let arch = runtime_arch(host)?; + let url = format!( + "https://github.com/FastLED/fbuild/releases/download/{}/qemu-esp-linux-runtime-{}-{}.tar.zst", + RUNTIME_RELEASE_TAG, arch, RUNTIME_VERSION + ); + Ok(Self { + base: PackageBase::with_cache_root( + "esp-qemu-linux-runtime", + RUNTIME_VERSION, + &url, + &format!("qemu-linux-runtime-{arch}"), + Some(runtime_sha256(arch)?), + CacheSubdir::Toolchains, + project_dir, + cache_root, + ), + }) + } + /// Directory to place on `LD_LIBRARY_PATH`. pub fn lib_dir(&self) -> NormalizedPath { NormalizedPath::from(self.base.install_path()).join(LIB_SUBDIR) @@ -203,6 +228,10 @@ pub fn ld_library_path_with(lib_dir: &Path, current: Option<&str>) -> String { /// here and gets `None`. pub fn installed_lib_dir(project_dir: &Path) -> Option { let runtime = QemuLinuxRuntime::new(project_dir).ok()?; + installed_lib_dir_for(&runtime) +} + +fn installed_lib_dir_for(runtime: &QemuLinuxRuntime) -> Option { if runtime.is_installed() { Some(runtime.lib_dir()) } else { @@ -495,9 +524,16 @@ mod tests { #[test] fn uninstalled_bundle_yields_no_ld_library_path() { let tmp = tempfile::TempDir::new().unwrap(); - // Nothing is cached under a fresh project dir, so the spawn-time - // lookup must stay quiet rather than inventing a path. - assert!(build_linux_qemu_ld_library_path(tmp.path(), Some("/usr/lib")).is_none()); + let cache = tempfile::TempDir::new().unwrap(); + let runtime = QemuLinuxRuntime::for_host_with_cache_root( + linux(HostArch::X86_64), + tmp.path(), + cache.path(), + ) + .unwrap(); + // An isolated empty cache must stay quiet rather than inheriting a + // bundle installed in the developer's global fbuild cache. + assert!(installed_lib_dir_for(&runtime).is_none()); } // ── probe_qemu_binary ─────────────────────────────────────────── From f2689bfb7287aced47d14632267659a2d8b04bf1 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 21 Sep 2026 14:53:34 -0700 Subject: [PATCH 3/3] chore(ci): refresh platform boundary inventory --- ci/platform_boundary_research.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index f53f19465..27e4924f5 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -77,7 +77,7 @@ crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 565 attr_cfg #[cfg(not(windows crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 570 attr_cfg #[cfg(not(windows))] host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 728 attr_cfg #[cfg(windows)] host_executable host_mechanic crates/fbuild-toolchain/src/toolchain/esp_qemu.rs 755 attr_cfg #[cfg(windows)] host_executable host_mechanic -crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 224 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy -crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 266 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy -crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 272 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 253 attr_cfg #[cfg_attr(not(target_os=),allow(dead_code))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 295 attr_cfg #[cfg(not(target_os=))] host_executable host_artifact_policy +crates/fbuild-toolchain/src/toolchain/esp_qemu_runtime.rs 301 attr_cfg #[cfg(target_os=)] host_executable host_artifact_policy crates/fbuild-toolchain/tests/qemu_linux_runtime.rs 18 attr_cfg #![cfg(target_os=)] host_executable host_artifact_policy