diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20e50b3..3d2fedc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - gcc g++ libc6-dev make cmake ninja-build meson golang-go binutils llvm \ + gcc g++ clang ccache libc6-dev make cmake ninja-build meson golang-go binutils llvm \ podman nodejs npm zstd - name: Install Bazel diff --git a/Containerfile b/Containerfile index f4cc1be..574b0da 100644 --- a/Containerfile +++ b/Containerfile @@ -13,7 +13,7 @@ ENV PATH=/opt/uv/bin:/opt/buildprof-venv/bin:/opt/cargo/bin:${PATH} # Install every build system covered by the conformance suite. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - ca-certificates curl gcc g++ libc6-dev python3 python3-venv git \ + ca-certificates curl gcc g++ clang ccache libc6-dev python3 python3-venv git \ make cmake ninja-build meson golang-go binutils llvm podman nodejs npm zstd \ && rm -rf /var/lib/apt/lists/* diff --git a/src/compiler.rs b/src/compiler.rs index 77b6fca..eb8ed70 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -565,6 +565,7 @@ pub fn run_wrapper() -> Option { )); command.arg(format!("-Wl,--time-trace={}", output.display())); } + hide_wrappers(&profile_dir, &mut command); command } _ => return None, @@ -574,6 +575,22 @@ pub fn run_wrapper() -> Option { Some(ExitCode::from(WRAPPER_FAILURE_EXIT_CODE)) } +/// Takes the wrapper directory back out of `PATH` before the real compiler +/// runs. The directory is there to intercept what the build launches, not what +/// the compiler launches, and a compiler launcher such as ccache resolves its +/// own name through `PATH`: leaving the directory in place lets it find this +/// wrapper again, which sends it straight back to the launcher, forever. +fn hide_wrappers(profile_dir: &OsStr, command: &mut Command) { + let Some(path) = env::var_os("PATH") else { + return; + }; + let wrappers = Path::new(profile_dir).join(WRAPPER_DIRECTORY_NAME); + let kept = env::split_paths(&path).filter(|directory| *directory != wrappers); + if let Ok(path) = env::join_paths(kept) { + command.env("PATH", path); + } +} + fn find_on_path(program: &str) -> Option { env::split_paths(&env::var_os("PATH")?) .map(|directory| directory.join(program)) diff --git a/tests/conformance/test_compiler_traces.py b/tests/conformance/test_compiler_traces.py index 5fd37e5..40bf16c 100644 --- a/tests/conformance/test_compiler_traces.py +++ b/tests/conformance/test_compiler_traces.py @@ -5,7 +5,9 @@ Rust self-profile data needs a nightly compiler. The build decides which toolchain runs, so the wrapper must find out from the compiler it launches -rather than from whatever `rustc` is the default at startup. +rather than from whatever `rustc` is the default at startup. The build also +decides what stands in for the compiler, which may be a launcher that goes +looking for the compiler itself. """ from __future__ import annotations @@ -19,6 +21,10 @@ from .model import count_compiler_events, load_perfetto, load_trace_attributes +# Long enough for a cold ccache to compile a one-line program, short enough +# that a wrapper that never stops is reported rather than waited on. +CCACHE_TIMEOUT_SECONDS = 60 + # How a build can pick the nightly toolchain without changing the default. NIGHTLY_SELECTIONS = { "cargo-plus-nightly": (["cargo", "+nightly", "build", "--offline"], {}, None), @@ -70,7 +76,13 @@ def _cargo_project(root: Path, toolchain_file: str | None) -> None: (root / "rust-toolchain.toml").write_text(toolchain_file) -def _record(buildprof: Path, project: Path, command: list[str], extra_env: dict) -> Path: +def _record( + buildprof: Path, + project: Path, + command: list[str], + extra_env: dict, + timeout: int = 300, +) -> Path: trace = project.parent / f"{project.name}.pftrace" env = dict(os.environ, LC_ALL="C", **extra_env) result = subprocess.run( @@ -78,7 +90,7 @@ def _record(buildprof: Path, project: Path, command: list[str], extra_env: dict) cwd=project, text=True, capture_output=True, - timeout=300, + timeout=timeout, env=env, ) assert result.returncode == 0, f"build failed:\n{result.stdout}{result.stderr}" @@ -131,3 +143,40 @@ def test_default_toolchain_builds_and_traces_only_if_nightly( assert events > 0, "the default nightly rustc produced no compiler events" else: assert events == 0, f"a stable rustc produced {events} compiler events" + + +def test_ccache_wrappers_on_path_do_not_loop(buildprof: Path, tmp_path: Path): + """A compiler launcher must not find the wrapper that launched it. + + ccache is normally used through a directory of symlinks named after the + compilers, early on `PATH`. It works out which compiler to run by looking + its own name up on `PATH` again, skipping only itself, so it finds the + wrapper directory and runs the wrapper, which hands straight back to + ccache. Left alone the two never stop. + """ + _require("ccache") + _require("clang") + project = tmp_path / "ccache" + project.mkdir() + (project / "hello.c").write_text("int main(void) { return 0; }\n") + launchers = tmp_path / "ccache-bin" + launchers.mkdir() + ccache = shutil.which("ccache") + for name in ("clang", "clang++"): + (launchers / name).symlink_to(ccache) + + trace = _record( + buildprof, + project, + ["clang", "hello.c", "-o", "hello"], + { + "PATH": f"{launchers}{os.pathsep}{os.environ['PATH']}", + "CCACHE_DIR": str(tmp_path / "cache"), + }, + timeout=CCACHE_TIMEOUT_SECONDS, + ) + + assert (project / "hello").is_file(), "the build produced nothing" + assert count_compiler_events(trace, "Clang") > 0, ( + "compiling through ccache recorded no Clang events" + )