diff --git a/crates/fbuild-build-engine/src/build_fingerprint/mod.rs b/crates/fbuild-build-engine/src/build_fingerprint/mod.rs index f18716a5a..b24fe35e1 100644 --- a/crates/fbuild-build-engine/src/build_fingerprint/mod.rs +++ b/crates/fbuild-build-engine/src/build_fingerprint/mod.rs @@ -59,6 +59,15 @@ pub struct BinArtifactCache { pub flash_mode: String, pub flash_freq: String, pub flash_size: String, + /// Which esptool produced the cached bin (FastLED/fbuild#1238): empty + /// for a provisioned absolute-path esptool or when no caller PATH was + /// forwarded (pre-#1236 behavior, so old records stay valid); otherwise + /// a short hash of the caller PATH the bare-name spawn resolved + /// against. `serde(default)` deserializes old records as `""`, which + /// mismatches any caller-PATH fingerprint — invalidation in the + /// correct direction only. + #[serde(default)] + pub esptool_fingerprint: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/fbuild-build-esp/src/esp32/esp32_linker.rs b/crates/fbuild-build-esp/src/esp32/esp32_linker.rs index 537be2650..5e10f8361 100644 --- a/crates/fbuild-build-esp/src/esp32/esp32_linker.rs +++ b/crates/fbuild-build-esp/src/esp32/esp32_linker.rs @@ -259,9 +259,34 @@ impl Esp32Linker { flash_mode: self.flash_mode.clone(), flash_freq: self.flash_freq.clone(), flash_size: flash_size.to_string(), + esptool_fingerprint: self.esptool_fingerprint(), }) } + /// Key the bin cache on which esptool a bare-name spawn would resolve + /// (FastLED/fbuild#1238). A provisioned absolute-path esptool (or no + /// forwarded caller PATH) fingerprints as `""` — exactly the pre-#1236 + /// behavior, so existing caches stay valid. A bare-name spawn under a + /// caller PATH fingerprints that PATH, so two requests whose PATHs + /// resolve different esptool binaries never share a `firmware.bin`. + fn esptool_fingerprint(&self) -> String { + if self.esptool_bin.is_some() { + return String::new(); + } + match self.caller_path.as_deref() { + None => String::new(), + Some(path) => { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(path.as_bytes()); + let hex = digest + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + hex[..16].to_string() + } + } + } + fn can_reuse_bin(&self, elf_path: &Path, output_dir: &Path, flash_size: &str) -> bool { let bin_out = output_dir.join("firmware.bin"); if !bin_out.exists() { @@ -594,6 +619,51 @@ mod tests { assert_eq!(linker.max_ram, Some(327680)); } + /// FastLED/fbuild#1238: seed a reusable bin cache written by `linker`, + /// returning the elf path whose stamp the cache records. + fn seed_bin_cache(linker: &Esp32Linker, dir: &Path) -> PathBuf { + let elf = dir.join("firmware.elf"); + std::fs::write(&elf, b"elf").unwrap(); + std::fs::write(dir.join("firmware.bin"), b"bin").unwrap(); + let cache = linker.current_bin_cache(&elf, "4MB").unwrap(); + save_json(&linker.bin_cache_path(dir), &cache).unwrap(); + elf + } + + #[test] + fn bare_name_esptool_bin_reuse_is_keyed_by_caller_path() { + let dir = tempfile::tempdir().unwrap(); + // Bare-name esptool (no provisioned path) resolved under PATH_A. + let writer = test_linker("esp32c6").with_caller_path(Some("PATH_A".into())); + assert!(writer.esptool_bin.is_none(), "test premise: bare-name spawn"); + let elf = seed_bin_cache(&writer, dir.path()); + + // Same caller PATH → same esptool → reuse. + assert!(writer.can_reuse_bin(&elf, dir.path(), "4MB")); + // Different caller PATH may resolve a different esptool → no reuse. + let other = test_linker("esp32c6").with_caller_path(Some("PATH_B".into())); + assert!(!other.can_reuse_bin(&elf, dir.path(), "4MB")); + // No caller PATH (daemon env) must not reuse a caller-PATH bin. + let legacy = test_linker("esp32c6"); + assert!(!legacy.can_reuse_bin(&elf, dir.path(), "4MB")); + } + + #[test] + fn absolute_esptool_bin_reuse_ignores_caller_path() { + let dir = tempfile::tempdir().unwrap(); + // Provisioned absolute esptool: caller PATH is irrelevant to which + // tool runs, so the fingerprint stays empty and caching keeps + // working across requests with different PATHs. + let mut writer = test_linker("esp32c6").with_caller_path(Some("PATH_A".into())); + writer.esptool_bin = Some(PathBuf::from("/tools/esptool")); + assert_eq!(writer.esptool_fingerprint(), ""); + let elf = seed_bin_cache(&writer, dir.path()); + + let mut reader = test_linker("esp32c6").with_caller_path(Some("PATH_B".into())); + reader.esptool_bin = Some(PathBuf::from("/tools/esptool")); + assert!(reader.can_reuse_bin(&elf, dir.path(), "4MB")); + } + #[test] fn test_flash_size_uses_board_max_flash_for_elf2image_and_cache() { let config = get_mcu_config("esp32c6").unwrap();