From 9176c14ee13a3d80f71bd1a40f5c36938f4610a8 Mon Sep 17 00:00:00 2001 From: Lalit Maganti Date: Wed, 16 Sep 2026 14:46:03 +0100 Subject: [PATCH 1/2] compiler: keep the wrappers out of the compiler's own PATH The wrapper directory goes on PATH so the build's `clang` is this recorder. ccache is normally used the same way, through a directory of symlinks named after the compilers, and works out which compiler to run by looking its own name up on PATH again, skipping only itself. It therefore found the wrapper, which handed straight back to ccache, which found the wrapper: neither ever stopped, and each pass appended another `-ftime-trace` to a command line that grew without bound. The directory is there to intercept what the build launches, not what the compiler launches, so take it back out of PATH before handing over. Fixes #26 --- src/compiler.rs | 17 +++++++ tests/conformance/test_compiler_traces.py | 55 +++++++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) 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" + ) From e6ad75f27b27723960a6a547043fddd0d04160f9 Mon Sep 17 00:00:00 2001 From: Lalit Maganti Date: Wed, 16 Sep 2026 14:54:01 +0100 Subject: [PATCH 2/2] ci: install clang and ccache for the conformance suite The suite requires every tool it names, so the wrapper loop test needs both present rather than skipped. --- .github/workflows/ci.yml | 2 +- Containerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/*