diff --git a/crates/fbuild-build/src/compiler.rs b/crates/fbuild-build/src/compiler.rs index fe4d2682..1e1b8a25 100644 --- a/crates/fbuild-build/src/compiler.rs +++ b/crates/fbuild-build/src/compiler.rs @@ -284,7 +284,7 @@ impl CompilerBase { let hash = { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); - hasher.update(source.to_string_lossy().as_bytes()); + hasher.update(object_hash_key(source, build_dir).as_bytes()); let result = hasher.finalize(); format!("{:02x}{:02x}", result[0], result[1]) }; @@ -298,6 +298,37 @@ impl CompilerBase { } } +/// Key used to derive an object file's disambiguating hash suffix. +/// +/// FastLED/fbuild#966: this MUST be project-directory-independent. The object +/// filename becomes the compiler's `-o` argument, which zccache folds into its +/// per-TU context key — so if the hash is derived from the *absolute* source +/// path (`/a/src/x.cpp` vs `/b/src/x.cpp`), two checkouts of the same project +/// produce different object names → different keys → 0% cross-project cache +/// hits. Hashing the source path *relative to the project workspace* (the +/// parent of the `.fbuild/` component in `build_dir`) makes the object name — +/// and therefore the key — identical across checkouts. Sources that live +/// outside the workspace (the global framework/toolchain cache under +/// `~/.fbuild/…`) fall back to their absolute path, which is already +/// project-independent. Separators are normalized so Windows and POSIX agree. +fn object_hash_key(source: &Path, build_dir: &Path) -> String { + for ancestor in build_dir.ancestors() { + if ancestor + .file_name() + .map(|n| n == ".fbuild") + .unwrap_or(false) + { + if let Some(workspace) = ancestor.parent() { + if let Ok(rel) = source.strip_prefix(workspace) { + return rel.to_string_lossy().replace('\\', "/"); + } + } + break; + } + } + source.to_string_lossy().replace('\\', "/") +} + /// Filter both `flags` and `extra_flags` through `unflags` using the shared /// PlatformIO-compatible removal semantics in `pipeline::remove_unflagged_tokens`. /// Returns the filtered pair ready to pass to `compile_one`. Short-circuits @@ -807,8 +838,33 @@ pub async fn compile_source( .clone() .or_else(|| output.parent().map(Path::to_path_buf)) .unwrap_or_else(|| PathBuf::from(".")); - let compile_env = + let mut compile_env = fbuild_core::subprocess::compile_env_for_build(&build_scratch_root).unwrap_or_default(); + // FastLED/fbuild#966: pin zccache's worktree_root to the project workspace + // so per-TU cache keys are project-directory-independent — identical + // workspace-relative paths across `/proj-a` and `/proj-b` produce identical + // keys, so a second project (or a fresh checkout) hits the warm cache + // instead of recompiling. zccache's `resolve_worktree_root` honors this env + // ONLY when the value is an existing absolute directory (else it silently + // falls back to the git root / cwd, reintroducing project dependence), so + // guard on `is_dir()`. + if let Some(root) = compile_cwd.as_deref() { + if root.is_dir() { + compile_env.push(( + "ZCCACHE_WORKTREE_ROOT".to_string(), + root.to_string_lossy().to_string(), + )); + if verbose { + tracing::info!("zccache worktree_root pinned to {}", root.display()); + } + } else { + tracing::warn!( + "compile_cwd {} is not a directory; ZCCACHE_WORKTREE_ROOT unset \ + → cache keys stay project-specific (FastLED/fbuild#966)", + root.display() + ); + } + } let compile_fut = svc.compile(compiler, sanitized, cwd, compile_env); let outcome = tokio::time::timeout(std::time::Duration::from_secs(300), compile_fut) .await diff --git a/crates/fbuild-build/src/esp32/orchestrator/build.rs b/crates/fbuild-build/src/esp32/orchestrator/build.rs index 626aa72e..615261b4 100644 --- a/crates/fbuild-build/src/esp32/orchestrator/build.rs +++ b/crates/fbuild-build/src/esp32/orchestrator/build.rs @@ -393,8 +393,16 @@ impl BuildOrchestrator for Esp32Orchestrator { ) .await?; - // Add library include dirs to the main include list - include_dirs.extend(lib_result.include_dirs); + // Add library include dirs to the main include list. Sort them + // first (FastLED/fbuild#966): the library set is discovered via + // `read_dir`, whose filesystem order differs between two checkouts + // of the same project — leaving it unsorted reorders the resulting + // `-I` flags and changes each TU's zccache context key, defeating + // cross-project cache hits. Library includes are same-tier, so a + // stable sort is safe for include resolution. + let mut lib_include_dirs = lib_result.include_dirs; + lib_include_dirs.sort(); + include_dirs.extend(lib_include_dirs); library_archives = lib_result.archives; tracing::info!( diff --git a/crates/fbuild-build/src/pipeline/library.rs b/crates/fbuild-build/src/pipeline/library.rs index 3da10545..5ae30b7b 100644 --- a/crates/fbuild-build/src/pipeline/library.rs +++ b/crates/fbuild-build/src/pipeline/library.rs @@ -105,6 +105,12 @@ pub fn discover_extra_library_roots(project_dir: &Path, entries: &[String]) -> V candidates.push(child_path); } } + // FastLED/fbuild#966: `read_dir` yields entries in filesystem order, + // which differs between two checkouts of the same project. That + // non-determinism reorders the resulting `-I` flags, changing the + // per-TU zccache context key and defeating cross-project cache + // hits. Sort so the include order is stable across checkouts. + candidates.sort(); } for candidate in candidates { diff --git a/crates/fbuild-packages/src/library/library_compiler.rs b/crates/fbuild-packages/src/library/library_compiler.rs index 812acffa..76fe4edd 100644 --- a/crates/fbuild-packages/src/library/library_compiler.rs +++ b/crates/fbuild-packages/src/library/library_compiler.rs @@ -413,10 +413,19 @@ async fn compile_one_source( // (cold-cache, full templated FastLED translation unit) is the // worst-case path that still finishes inside REAL_BUILD_TIMEOUT; // anything beyond that is wedged toolchain rather than a slow build. + // FastLED/fbuild#966: fw-lib object compiles run via the `zccache wrap` + // CLI (not the embedded service), so — like the sketch/core embedded path — + // pin zccache's worktree_root to the project workspace so the per-TU cache + // key is project-directory-independent and hits cross-project. The env vec + // must outlive the borrow passed to run_command. + let worktree_root = compile_cwd.map(|cwd| cwd.to_string_lossy().to_string()); + let env_pairs: Option> = worktree_root + .as_deref() + .map(|root| vec![("ZCCACHE_WORKTREE_ROOT", root)]); let result = run_command( &args_ref, compile_cwd, - None, + env_pairs.as_deref(), Some(fbuild_core::time::REAL_BUILD_TIMEOUT), ) .await?; @@ -625,17 +634,42 @@ async fn archive_objects(ar_path: &Path, objects: &[PathBuf], output: &Path) -> /// Compute the object file path for a source file. fn object_path(source: &Path, obj_dir: &Path) -> PathBuf { let stem = source.file_stem().unwrap_or_default().to_string_lossy(); - // Use a hash of the full source path to avoid collisions + // FastLED/fbuild#966: the disambiguating hash must be + // project-directory-independent — the object filename becomes the `-o` + // arg that zccache folds into the per-TU context key, so hashing the + // absolute source path defeats cross-project cache hits. Hash the source + // relative to the project workspace (parent of `.fbuild/` in obj_dir); + // sources outside it (global framework/library cache) keep their absolute + // path, which is already project-independent. let hash = { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); - hasher.update(source.to_string_lossy().as_bytes()); + hasher.update(object_hash_key(source, obj_dir).as_bytes()); let result = hasher.finalize(); format!("{:02x}{:02x}", result[0], result[1]) }; obj_dir.join(format!("{}_{}.o", stem, hash)) } +/// Project-workspace-relative key for the object hash (FastLED/fbuild#966). +fn object_hash_key(source: &Path, obj_dir: &Path) -> String { + for ancestor in obj_dir.ancestors() { + if ancestor + .file_name() + .map(|n| n == ".fbuild") + .unwrap_or(false) + { + if let Some(workspace) = ancestor.parent() { + if let Ok(rel) = source.strip_prefix(workspace) { + return rel.to_string_lossy().replace('\\', "/"); + } + } + break; + } + } + source.to_string_lossy().replace('\\', "/") +} + #[cfg(test)] mod tests { use super::*;