Skip to content

Commit ada3b60

Browse files
authored
build: stabilize zccache compile cwd (#191)
1 parent 888ec98 commit ada3b60

3 files changed

Lines changed: 353 additions & 1 deletion

File tree

crates/fbuild-build/src/compiler.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,12 +596,13 @@ pub fn compile_source(
596596
};
597597

598598
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
599+
let compile_cwd = compiler_cache.and_then(|_| crate::zccache::compile_cwd_from_output(output));
599600

600601
if verbose {
601602
tracing::info!("compile: {}", args.join(" "));
602603
}
603604

604-
let result = run_command(&args_ref, None, None, None)?;
605+
let result = run_command(&args_ref, compile_cwd.as_deref(), None, None)?;
605606

606607
if result.success() {
607608
std::fs::write(command_hash_path(output), rebuild_signature)?;

crates/fbuild-build/src/zccache.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,26 @@ pub fn wrap_args(args: &[&str], cache_path: Option<&Path>) -> Vec<String> {
177177
}
178178
}
179179

180+
/// Return the workspace root to use as the CWD for zccache-wrapped compiles.
181+
///
182+
/// Upstream zccache normalizes cache-key paths relative to the wrapper
183+
/// process CWD. fbuild object files live under `<workspace>/.fbuild/...`, so
184+
/// running the wrapper from `<workspace>` lets identical renamed workspaces
185+
/// share per-TU cache keys even when compiler args contain absolute paths.
186+
pub fn compile_cwd_from_output(output: &Path) -> Option<PathBuf> {
187+
let mut dir = output.parent()?;
188+
loop {
189+
if dir
190+
.file_name()
191+
.and_then(|name| name.to_str())
192+
.is_some_and(|name| name.eq_ignore_ascii_case(".fbuild"))
193+
{
194+
return dir.parent().map(Path::to_path_buf);
195+
}
196+
dir = dir.parent()?;
197+
}
198+
}
199+
180200
/// Ask zccache whether the watched root changed since the last successful mark.
181201
///
182202
/// Exit code semantics come from `zccache fp check`:
@@ -246,3 +266,25 @@ pub fn mark_fingerprint_success(zccache: &Path, watch: &FingerprintWatch) -> Res
246266
)))
247267
}
248268
}
269+
270+
#[cfg(test)]
271+
mod tests {
272+
use super::*;
273+
274+
#[test]
275+
fn compile_cwd_from_output_uses_workspace_before_fbuild() {
276+
let output = Path::new("/work/project/.fbuild/build/env/release/src/main.o");
277+
278+
assert_eq!(
279+
compile_cwd_from_output(output).as_deref(),
280+
Some(Path::new("/work/project"))
281+
);
282+
}
283+
284+
#[test]
285+
fn compile_cwd_from_output_returns_none_without_fbuild_component() {
286+
let output = Path::new("/work/project/build/env/main.o");
287+
288+
assert!(compile_cwd_from_output(output).is_none());
289+
}
290+
}
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
use std::path::{Path, PathBuf};
2+
use std::process::Command;
3+
use std::{env, fs};
4+
5+
use fbuild_build::compiler::compile_source;
6+
7+
const FAKE_ZCCACHE: &str = r#"
8+
use std::env;
9+
use std::fs;
10+
use std::io::Write;
11+
use std::path::{Component, Path, PathBuf};
12+
13+
fn main() {
14+
let args: Vec<String> = env::args().skip(1).collect();
15+
if args.len() < 2 || args[0] != "wrap" {
16+
eprintln!("usage: fake-zccache wrap <compiler> <args...>");
17+
std::process::exit(2);
18+
}
19+
20+
let cwd = env::current_dir().unwrap();
21+
let expanded = expand_response_files(&args[2..]);
22+
let source = find_source(&expanded, &cwd).expect("source file");
23+
let output = find_output(&expanded, &cwd).expect("output file");
24+
let includes = find_includes(&expanded, &cwd);
25+
26+
let key = cache_key(&cwd, &source, &includes);
27+
let key_hash = stable_hash(key.as_bytes());
28+
let cache_dir = PathBuf::from(env::var("FBUILD_FAKE_ZCCACHE_CACHE").unwrap());
29+
let log_path = PathBuf::from(env::var("FBUILD_FAKE_ZCCACHE_LOG").unwrap());
30+
fs::create_dir_all(&cache_dir).unwrap();
31+
if let Some(parent) = output.parent() {
32+
fs::create_dir_all(parent).unwrap();
33+
}
34+
35+
let cache_path = cache_dir.join(format!("{key_hash:016x}.o"));
36+
let mut log = fs::OpenOptions::new()
37+
.create(true)
38+
.append(true)
39+
.open(log_path)
40+
.unwrap();
41+
42+
if cache_path.exists() {
43+
fs::copy(&cache_path, &output).unwrap();
44+
writeln!(log, "hit cwd={} key={key_hash:016x}", cwd.display()).unwrap();
45+
} else {
46+
let object = format!("object\n{}\n", key);
47+
fs::write(&output, object.as_bytes()).unwrap();
48+
fs::copy(&output, &cache_path).unwrap();
49+
writeln!(log, "miss cwd={} key={key_hash:016x}", cwd.display()).unwrap();
50+
}
51+
}
52+
53+
fn expand_response_files(args: &[String]) -> Vec<String> {
54+
let mut expanded = Vec::new();
55+
for arg in args {
56+
if let Some(path) = arg.strip_prefix('@') {
57+
let text = fs::read_to_string(path).unwrap();
58+
expanded.extend(text.split_whitespace().map(unquote));
59+
} else {
60+
expanded.push(arg.clone());
61+
}
62+
}
63+
expanded
64+
}
65+
66+
fn unquote(value: &str) -> String {
67+
value
68+
.trim_matches('"')
69+
.trim_matches('\'')
70+
.to_string()
71+
}
72+
73+
fn find_source(args: &[String], cwd: &Path) -> Option<PathBuf> {
74+
let mut after_c = false;
75+
for arg in args {
76+
if after_c {
77+
return Some(resolve(arg, cwd));
78+
}
79+
after_c = arg == "-c";
80+
}
81+
args.iter()
82+
.find(|arg| !arg.starts_with('-') && is_source(arg))
83+
.map(|arg| resolve(arg, cwd))
84+
}
85+
86+
fn find_output(args: &[String], cwd: &Path) -> Option<PathBuf> {
87+
let mut i = 0;
88+
while i < args.len() {
89+
let arg = &args[i];
90+
if arg == "-o" {
91+
return args.get(i + 1).map(|value| resolve(value, cwd));
92+
}
93+
if let Some(value) = arg.strip_prefix("-o") {
94+
return Some(resolve(value, cwd));
95+
}
96+
i += 1;
97+
}
98+
None
99+
}
100+
101+
fn find_includes(args: &[String], cwd: &Path) -> Vec<PathBuf> {
102+
let mut includes = Vec::new();
103+
let mut i = 0;
104+
while i < args.len() {
105+
let arg = &args[i];
106+
if arg == "-I" {
107+
if let Some(value) = args.get(i + 1) {
108+
includes.push(resolve(value, cwd));
109+
}
110+
i += 2;
111+
continue;
112+
}
113+
if let Some(value) = arg.strip_prefix("-I") {
114+
includes.push(resolve(value, cwd));
115+
}
116+
i += 1;
117+
}
118+
includes
119+
}
120+
121+
fn cache_key(cwd: &Path, source: &Path, includes: &[PathBuf]) -> String {
122+
let mut key = String::new();
123+
key.push_str("source=");
124+
key.push_str(&key_path(source, cwd));
125+
key.push(':');
126+
key.push_str(&fs::read_to_string(source).unwrap());
127+
key.push('\n');
128+
129+
for include in includes {
130+
key.push_str("include-dir=");
131+
key.push_str(&key_path(include, cwd));
132+
key.push('\n');
133+
let header = include.join("demo.h");
134+
key.push_str("header=");
135+
key.push_str(&key_path(&header, cwd));
136+
key.push(':');
137+
key.push_str(&fs::read_to_string(header).unwrap());
138+
key.push('\n');
139+
}
140+
key
141+
}
142+
143+
fn key_path(path: &Path, cwd: &Path) -> String {
144+
let absolute = if path.is_absolute() {
145+
path.to_path_buf()
146+
} else {
147+
cwd.join(path)
148+
};
149+
let comparable = absolute.strip_prefix(cwd).unwrap_or(&absolute);
150+
comparable
151+
.components()
152+
.filter_map(|component| match component {
153+
Component::Prefix(prefix) => Some(prefix.as_os_str().to_string_lossy().replace('\\', "/")),
154+
Component::RootDir | Component::CurDir => None,
155+
Component::ParentDir => Some("..".to_string()),
156+
Component::Normal(value) => Some(value.to_string_lossy().replace('\\', "/")),
157+
})
158+
.collect::<Vec<_>>()
159+
.join("/")
160+
}
161+
162+
fn resolve(value: &str, cwd: &Path) -> PathBuf {
163+
let path = Path::new(value);
164+
if path.is_absolute() {
165+
path.to_path_buf()
166+
} else {
167+
cwd.join(path)
168+
}
169+
}
170+
171+
fn is_source(value: &str) -> bool {
172+
Path::new(value)
173+
.extension()
174+
.and_then(|ext| ext.to_str())
175+
.is_some_and(|ext| matches!(ext, "c" | "cc" | "cpp" | "cxx"))
176+
}
177+
178+
fn stable_hash(bytes: &[u8]) -> u64 {
179+
let mut hash = 0xcbf29ce484222325u64;
180+
for byte in bytes {
181+
hash ^= u64::from(*byte);
182+
hash = hash.wrapping_mul(0x100000001b3);
183+
}
184+
hash
185+
}
186+
"#;
187+
188+
struct CurrentDirGuard {
189+
original: PathBuf,
190+
}
191+
192+
impl CurrentDirGuard {
193+
fn set_to(path: &Path) -> Self {
194+
let original = env::current_dir().unwrap();
195+
env::set_current_dir(path).unwrap();
196+
Self { original }
197+
}
198+
}
199+
200+
impl Drop for CurrentDirGuard {
201+
fn drop(&mut self) {
202+
let _ = env::set_current_dir(&self.original);
203+
}
204+
}
205+
206+
#[test]
207+
fn zccache_hit_across_workspace_rename() {
208+
let tmp = tempfile::TempDir::new().unwrap();
209+
let fake_zccache = compile_fake_zccache(tmp.path());
210+
let fake_compiler = tmp
211+
.path()
212+
.join(format!("fake-compiler{}", env::consts::EXE_SUFFIX));
213+
let cache_dir = tmp.path().join("fake-cache");
214+
let log_path = tmp.path().join("fake-zccache.log");
215+
let ws_a = tmp.path().join("workspace-a");
216+
let ws_b = tmp.path().join("workspace-b");
217+
218+
create_workspace(&ws_a);
219+
create_workspace(&ws_b);
220+
221+
let _cwd = CurrentDirGuard::set_to(tmp.path());
222+
env::set_var("FBUILD_FAKE_ZCCACHE_CACHE", &cache_dir);
223+
env::set_var("FBUILD_FAKE_ZCCACHE_LOG", &log_path);
224+
225+
compile_workspace(&ws_a, &fake_compiler, &fake_zccache);
226+
compile_workspace(&ws_b, &fake_compiler, &fake_zccache);
227+
228+
let log = fs::read_to_string(&log_path).unwrap();
229+
let lines: Vec<&str> = log.lines().collect();
230+
assert_eq!(lines.len(), 2, "unexpected fake zccache log:\n{log}");
231+
assert!(
232+
lines[0].starts_with("miss "),
233+
"first compile should populate the cache:\n{log}"
234+
);
235+
assert!(
236+
lines[1].starts_with("hit "),
237+
"renamed workspace should reuse the cache entry:\n{log}"
238+
);
239+
assert!(
240+
lines[0].contains(&format!("cwd={}", ws_a.display())),
241+
"first wrapper CWD should be workspace root:\n{log}"
242+
);
243+
assert!(
244+
lines[1].contains(&format!("cwd={}", ws_b.display())),
245+
"second wrapper CWD should be workspace root:\n{log}"
246+
);
247+
}
248+
249+
fn compile_fake_zccache(root: &Path) -> PathBuf {
250+
let source = root.join("fake_zccache.rs");
251+
let exe = root.join(format!("fake-zccache{}", env::consts::EXE_SUFFIX));
252+
fs::write(&source, FAKE_ZCCACHE).unwrap();
253+
254+
let rustc = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
255+
let status = Command::new(rustc)
256+
.arg(&source)
257+
.arg("-o")
258+
.arg(&exe)
259+
.status()
260+
.expect("failed to spawn rustc for fake zccache");
261+
assert!(status.success(), "failed to compile fake zccache helper");
262+
exe
263+
}
264+
265+
fn create_workspace(root: &Path) {
266+
fs::create_dir_all(root.join("src")).unwrap();
267+
fs::create_dir_all(root.join("include")).unwrap();
268+
fs::create_dir_all(root.join(".fbuild").join("build")).unwrap();
269+
fs::write(
270+
root.join("include").join("demo.h"),
271+
"#pragma once\ninline int demo() { return 7; }\n",
272+
)
273+
.unwrap();
274+
fs::write(
275+
root.join("src").join("main.cpp"),
276+
"#include \"demo.h\"\nint main() { return demo(); }\n",
277+
)
278+
.unwrap();
279+
}
280+
281+
fn compile_workspace(root: &Path, compiler: &Path, zccache: &Path) {
282+
let source = root.join("src").join("main.cpp");
283+
let output = root.join(".fbuild").join("build").join("main.o");
284+
let flags = vec![
285+
"-I".to_string(),
286+
root.join("include").to_string_lossy().to_string(),
287+
];
288+
289+
let result = compile_source(
290+
compiler,
291+
&source,
292+
&output,
293+
&flags,
294+
&[],
295+
&root.join(".fbuild").join("build").join("tmp"),
296+
"zccache-rename",
297+
false,
298+
Some(zccache),
299+
&[],
300+
)
301+
.unwrap();
302+
303+
assert!(
304+
result.success,
305+
"compile failed: stdout={} stderr={}",
306+
result.stdout, result.stderr
307+
);
308+
assert!(output.exists(), "expected object at {}", output.display());
309+
}

0 commit comments

Comments
 (0)