Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/fbuild-build/src/esp32/orchestrator/framework_libs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ pub(super) async fn compile_framework_builtin_libs(
let fw_libs_build_dir = build_dir.join("fw_libs");
std::fs::create_dir_all(&fw_libs_build_dir)?;

// Compile framework libs workspace-relative so their zccache keys are
// stable across project directories and hit the warm cache instead of
// recompiling ~150s on every fresh project (FastLED/fbuild#952). The
// object dir lives under `<project>/.fbuild/...`, so this resolves to
// the project workspace root (canonicalized).
let fw_compile_cwd =
fbuild_core::path::compile_cwd_from_output(&fw_libs_build_dir.join("obj").join("_probe.o"));

// Build set of already-compiled library names
let already_compiled: std::collections::HashSet<String> = library_archives
.iter()
Expand Down Expand Up @@ -174,6 +182,7 @@ pub(super) async fn compile_framework_builtin_libs(
params.verbose,
fw_jobs,
compiler_cache,
fw_compile_cwd.clone(),
)
.await
{
Expand Down
1 change: 1 addition & 0 deletions crates/fbuild-build/src/esp32/orchestrator/local_libs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pub(super) async fn compile_local_libraries(
verbose,
jobs,
compiler_cache,
None,
)
.await
{
Expand Down
2 changes: 2 additions & 0 deletions crates/fbuild-build/src/pipeline/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ pub async fn compile_extra_libraries(
env.verbose,
env.jobs,
env.compiler_cache,
None,
)
.await
{
Expand Down Expand Up @@ -278,6 +279,7 @@ pub async fn compile_project_as_library(
env.verbose,
env.jobs,
env.compiler_cache,
None,
)
.await
{
Expand Down
334 changes: 9 additions & 325 deletions crates/fbuild-build/src/zccache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@
//! — still used by `compile_source` to keep cache keys
//! workspace-relative.

use std::path::{Path, PathBuf};
use std::path::PathBuf;

use fbuild_core::path::NormalizedPath;
use fbuild_core::{FbuildError, Result};

// Workspace-relativization helpers were moved to `fbuild_core::path` so
// `fbuild-packages` can share them for library compiles (FastLED/fbuild#952).
// Re-exported here so existing `crate::zccache::…` call sites keep working
// unchanged.
pub use fbuild_core::path::{
compile_cwd_from_output, normalize_flags_for_compile_cwd, path_arg_for_compile_cwd,
};

/// A persistent zccache fingerprint watch.
#[derive(Debug, Clone)]
pub struct FingerprintWatch {
Expand Down Expand Up @@ -79,326 +86,3 @@ pub fn mark_fingerprint_success(watch: &FingerprintWatch) -> Result<()> {
))
})
}

/// Return the workspace root to use as the CWD for zccache compiles.
///
/// Upstream zccache normalizes cache-key paths relative to the
/// compile CWD. fbuild object files live under
/// `<workspace>/.fbuild/...`, so running the compile from
/// `<workspace>` lets identical renamed workspaces share per-TU
/// cache keys even when compiler args contain absolute paths.
pub fn compile_cwd_from_output(output: &Path) -> Option<PathBuf> {
let mut dir = output.parent()?;
loop {
if dir
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(".fbuild"))
{
return dir.parent().map(|workspace| {
canonicalize_existing_path(workspace).unwrap_or_else(|| workspace.to_path_buf())
});
}
dir = dir.parent()?;
}
}

/// Return a path argument that is stable relative to the zccache compile CWD.
///
/// macOS can canonicalize `/var/...` working directories to `/private/var/...`
/// inside the child process. Canonicalizing absolute compiler arguments before
/// stripping the compile CWD keeps zccache keys workspace-relative across both
/// path spellings.
///
/// The final `NormalizedPath::display_slash()` call is the load-bearing
/// step: `NormalizedPath` owns the Windows `\` → `/` rewrite that GCC's
/// internal spec-file pass requires (see FastLED/fbuild#875, #885, #890,
/// #912). Do NOT hand-roll the rewrite here — the `ban_manual_slash_normalize`
/// dylint flags that anti-pattern. Route every path arg through
/// `NormalizedPath::display_slash()` at exactly one place, which is here.
pub fn path_arg_for_compile_cwd(path: &Path, cwd: &Path) -> String {
let relative: PathBuf = if !path.is_absolute() {
path.to_path_buf()
} else {
// Both ends must be in the same normal form (stripped of any `\\?\`
// Windows extended-length prefix) for `strip_prefix` to match, since
// `canonicalize_existing_path` now strips that prefix from `path`.
let stable_path = canonicalize_existing_path(path).unwrap_or_else(|| path.to_path_buf());
let stable_cwd = strip_unc_prefix(cwd.to_path_buf());
stable_path
.strip_prefix(&stable_cwd)
.map(|tail| tail.to_path_buf())
.unwrap_or(stable_path)
};
// GCC's internal spec-file pass treats `\` as an escape character, so
// `src\main.cpp` reaches cc1plus as `srcmain.cpp` and fails to open.
// `NormalizedPath::display_slash()` owns the `\` → `/` rewrite (plus
// the Windows UNC prefix strip) for the whole workspace — see
// fbuild_core::path::NormalizedPath. POSIX hosts pass through unchanged.
let arg = NormalizedPath::from(relative).display_slash();
// Preserve the "empty relative → '.'" invariant: when the source file
// *is* the compile CWD (or the caller passed a bare empty path), GCC
// requires a positional argument that resolves to the current directory.
if arg.is_empty() {
".".to_string()
} else {
arg
}
}

/// Normalize common path-bearing compiler flags for a zccache CWD.
pub fn normalize_flags_for_compile_cwd(flags: &[String], cwd: &Path) -> Vec<String> {
let mut normalized = Vec::with_capacity(flags.len());
let mut next_is_path = false;

for flag in flags {
if next_is_path {
normalized.push(path_arg_for_compile_cwd(Path::new(flag), cwd));
next_is_path = false;
continue;
}

if flag_takes_path_argument(flag) {
normalized.push(flag.clone());
next_is_path = true;
continue;
}

if let Some(value) = flag.strip_prefix("--sysroot=") {
normalized.push(format!(
"--sysroot={}",
path_arg_for_compile_cwd(Path::new(value), cwd)
));
continue;
}

if let Some((prefix, value)) = split_joined_path_flag(flag) {
normalized.push(format!(
"{}{}",
prefix,
path_arg_for_compile_cwd(Path::new(value), cwd)
));
continue;
}

normalized.push(flag.clone());
}

normalized
}

fn canonicalize_existing_path(path: &Path) -> Option<PathBuf> {
if let Ok(canonical) = path.canonicalize() {
return Some(strip_unc_prefix(canonical));
}

let parent = path.parent()?.canonicalize().ok()?;
let joined = match path.file_name() {
Some(name) => parent.join(name),
None => parent,
};
Some(strip_unc_prefix(joined))
}

/// On Windows, `Path::canonicalize` returns paths prefixed with the
/// extended-length namespace marker `\\?\`. That prefix only works with
/// backslash path separators, but the response-file writer rewrites all
/// backslashes to forward slashes (so GCC doesn't interpret them as escape
/// sequences). The result is `//?/C:/…` which neither GCC nor mingw's path
/// search code understands as a valid drive-letter path, so `#include`
/// lookups against canonicalized include dirs silently fail (see FastLED
/// issue #2507 — `soc/soc_caps.h` not found on esp32p4).
///
/// Stripping the prefix here turns `\\?\C:\…` into `C:\…`, which survives
/// the backslash→slash rewrite as the standard `C:/…` form GCC accepts.
/// The trade-off is that long-path support (>260 chars) is lost for the
/// stripped paths, but the cache root is `<home>/.fbuild` which is well
/// under the limit in practice.
fn strip_unc_prefix(path: PathBuf) -> PathBuf {
if !cfg!(windows) {
return path;
}
let s = path.to_string_lossy();
// Handle both `\\?\C:\…` and (defensively) the already-rewritten `//?/C:/…`.
if let Some(rest) = s.strip_prefix(r"\\?\").or_else(|| s.strip_prefix("//?/")) {
return PathBuf::from(rest);
}
path
}

fn flag_takes_path_argument(flag: &str) -> bool {
matches!(
flag,
"-I" | "-isystem"
| "-iquote"
| "-idirafter"
| "-include"
| "-imacros"
| "-isysroot"
| "--sysroot"
)
}

fn split_joined_path_flag(flag: &str) -> Option<(&'static str, &str)> {
for prefix in [
"-I",
"-isystem",
"-iquote",
"-idirafter",
"-include",
"-imacros",
"-isysroot",
] {
if let Some(value) = flag.strip_prefix(prefix).filter(|value| !value.is_empty()) {
return Some((prefix, value));
}
}
None
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn compile_cwd_from_output_uses_workspace_before_fbuild() {
let output = Path::new("/work/project/.fbuild/build/env/release/src/main.o");

assert_eq!(
compile_cwd_from_output(output).as_deref(),
Some(Path::new("/work/project"))
);
}

#[test]
fn compile_cwd_from_output_returns_none_without_fbuild_component() {
let output = Path::new("/work/project/build/env/main.o");

assert!(compile_cwd_from_output(output).is_none());
}

#[test]
fn compile_cwd_from_output_canonicalizes_existing_workspace() {
let tmp = tempfile::TempDir::new().unwrap();
let workspace = tmp.path().join("project");
let output = workspace.join(".fbuild/build/main.o");
std::fs::create_dir_all(output.parent().unwrap()).unwrap();
// On Windows, canonicalize() yields a `\\?\` extended-length prefix
// that the response-file writer cannot use (forward-slash rewrite
// produces `//?/` which gcc rejects). The helper now strips that
// prefix on Windows, so the expected value must match.
let expected = strip_unc_prefix(workspace.canonicalize().unwrap());

assert_eq!(
compile_cwd_from_output(&output).as_deref(),
Some(expected.as_path())
);
}

#[test]
#[cfg(windows)]
fn strip_unc_prefix_removes_extended_length_marker() {
let raw = std::path::PathBuf::from(r"\\?\C:\Users\test\.fbuild\cache");
let stripped = strip_unc_prefix(raw);
assert_eq!(
stripped,
std::path::PathBuf::from(r"C:\Users\test\.fbuild\cache")
);
}

#[test]
#[cfg(windows)]
fn strip_unc_prefix_is_idempotent_for_already_normal_paths() {
let raw = std::path::PathBuf::from(r"C:\Users\test\include");
let stripped = strip_unc_prefix(raw.clone());
assert_eq!(stripped, raw);
}

#[test]
fn path_arg_for_compile_cwd_returns_workspace_relative_path() {
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path().join("project");
let source = cwd.join("src/main.cpp");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, "int main() { return 0; }\n").unwrap();
let cwd = cwd.canonicalize().unwrap();

// Forward-slash spelling is the invariant on every platform — GCC's
// spec-file pass treats `\` as an escape character on Windows.
// `NormalizedPath::display_slash()` owns that rewrite.
assert_eq!(path_arg_for_compile_cwd(&source, &cwd), "src/main.cpp");
}

#[test]
#[cfg(windows)]
fn path_arg_for_compile_cwd_forces_forward_slashes_on_windows() {
// FastLED/fbuild#911 regression test — the #890 / #912 bug class:
// any hand-rolled path formatting on Windows that leaves backslashes
// intact reaches cc1plus with `\` interpreted as an escape, so
// `src\main.cpp` becomes `srcmain.cpp` and fails to open. This test
// walks the absolute-path arm end-to-end on Windows and asserts no
// `\` character survives — locking the invariant that
// `NormalizedPath::display_slash()` is doing its job for the
// compile pipeline.
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path().join("project");
let nested = cwd.join("src").join("sketch");
let source = nested.join("main.cpp");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(&source, "int main() { return 0; }\n").unwrap();
let cwd = cwd.canonicalize().unwrap();

let arg = path_arg_for_compile_cwd(&source, &cwd);

assert!(
!arg.contains('\\'),
"compile arg must not contain backslashes: {arg}"
);
assert_eq!(arg, "src/sketch/main.cpp");
}

#[test]
fn path_arg_for_compile_cwd_returns_dot_for_workspace_root() {
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path().join("project");
std::fs::create_dir_all(&cwd).unwrap();
let cwd = cwd.canonicalize().unwrap();

assert_eq!(path_arg_for_compile_cwd(&cwd, &cwd), ".");
}

#[test]
fn normalize_flags_for_compile_cwd_rewrites_include_paths() {
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path().join("project");
let include = cwd.join("include");
let vendor = cwd.join("vendor");
let sysroot = cwd.join("sysroot");
std::fs::create_dir_all(&include).unwrap();
std::fs::create_dir_all(&vendor).unwrap();
std::fs::create_dir_all(&sysroot).unwrap();
let cwd = cwd.canonicalize().unwrap();
let flags = vec![
"-I".to_string(),
include.to_string_lossy().to_string(),
"-I".to_string(),
cwd.to_string_lossy().to_string(),
format!("-I{}", vendor.display()),
format!("-I{}", cwd.display()),
format!("--sysroot={}", sysroot.display()),
];

assert_eq!(
normalize_flags_for_compile_cwd(&flags, &cwd),
vec![
"-I".to_string(),
"include".to_string(),
"-I".to_string(),
".".to_string(),
"-Ivendor".to_string(),
"-I.".to_string(),
"--sysroot=sysroot".to_string(),
]
);
}
}
Loading
Loading