From b8a8c05dafb85aaaf8e513d9ebfa0727755933f4 Mon Sep 17 00:00:00 2001 From: Matthew Stone Date: Sat, 26 Sep 2026 09:33:50 +0000 Subject: [PATCH] Report coverage for binaries a rust_test runs as subprocesses A `rust_test` can list a `rust_binary` in `data` and spawn it through `CARGO_BIN_EXE_`. Under `bazel coverage` that binary is instrumented, inherits `LLVM_PROFILE_FILE`, and writes its `.profraw` into the test's `COVERAGE_DIR`, where the collector merges it with the test's own. But `llvm-cov export` is given only the test binary, and it reads counters only for the objects it is given, so the subprocess's counters were merged and then discarded. Lines that only the subprocess reaches were reported as though no test ran them. - `rust_test` names each instrumented bin crate in `data` in `RUST_COVERAGE_OBJECTS`, and the collector passes each one to `llvm-cov export` as `-object`. - `data` joins `deps` and `crate` as a coverage dependency attribute, as it already is for `cc_*`, `sh_*` and `py_*` rules. That puts the binary's sources in the instrumented file set, and puts the binary in the coverage metadata that split coverage postprocessing stages. `//test/coverage_subprocess` is a binary reached only through a subprocess. Presubmit's coverage validation now requires its lines to be hit. Co-Authored-By: Claude Opus 5.5 --- .bazelci/presubmit.yml | 12 ++++++++++++ rust/coverage/collect_rust_coverage.rs | 21 +++++++++++++++++++++ rust/private/rust.bzl | 19 +++++++++++++++++++ rust/private/rustc.bzl | 2 +- test/coverage_subprocess/BUILD.bazel | 16 ++++++++++++++++ test/coverage_subprocess/greeter.rs | 12 ++++++++++++ test/coverage_subprocess/greeter_test.rs | 13 +++++++++++++ 7 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 test/coverage_subprocess/BUILD.bazel create mode 100644 test/coverage_subprocess/greeter.rs create mode 100644 test/coverage_subprocess/greeter_test.rs diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml index 60c7448389..2718cc6683 100644 --- a/.bazelci/presubmit.yml +++ b/.bazelci/presubmit.yml @@ -83,6 +83,18 @@ coverage_validation_post_shell_commands: &coverage_validation_post_shell_command bazel-out/_coverage/_coverage_report.dat \ ; exit 1 \ ; } + # Regression check: lines reached only by a binary the test runs as a + # subprocess must be reported. + - | + awk '/^SF:test\/coverage_subprocess\/greeter\.rs$/,/^end_of_record$/' \ + bazel-out/_coverage/_coverage_report.dat \ + | grep -qE '^LH:[1-9]' \ + || { 1>&2 echo "No coverage for a subprocess binary (test/coverage_subprocess/greeter.rs)" \ + ; 1>&2 echo "Counters from binaries in a test's data were dropped." \ + ; 1>&2 awk '/^SF:test\/coverage_subprocess\/greeter\.rs$/,/^end_of_record$/' \ + bazel-out/_coverage/_coverage_report.dat \ + ; exit 1 \ + ; } split_coverage_postprocessing_shell_commands: &split_coverage_postprocessing_shell_commands - echo "coverage --experimental_fetch_all_coverage_outputs" >> user.bazelrc - echo "coverage --experimental_split_coverage_postprocessing" >> user.bazelrc diff --git a/rust/coverage/collect_rust_coverage.rs b/rust/coverage/collect_rust_coverage.rs index 7fbe08a35b..b41ef2ca82 100644 --- a/rust/coverage/collect_rust_coverage.rs +++ b/rust/coverage/collect_rust_coverage.rs @@ -15,6 +15,9 @@ //! - `RUNFILES_DIR` (optional): Location of the test's runfiles. Not set in split //! coverage postprocessing mode (`--experimental_split_coverage_postprocessing`). //! - `TEST_BINARY`: Runfiles-relative path to the test binary (used when `RUNFILES_DIR` is absent). +//! - `RUST_COVERAGE_OBJECTS` (optional): Instrumented binaries the test runs as +//! subprocesses, separated by the host path separator. Each is resolved like +//! `RUST_LLVM_COV`. //! - `VERBOSE_COVERAGE`: Print debug info from the coverage scripts //! //! The script looks in $COVERAGE_DIR for the Rust metadata coverage files @@ -22,6 +25,7 @@ //! is placed in $COVERAGE_DIR as a `coverage.dat` file. use std::env; +use std::ffi::OsStr; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -152,6 +156,18 @@ fn main() { test_binary } }; + let coverage_objects: Vec = env::var("RUST_COVERAGE_OBJECTS") + .map(|objects| { + env::split_paths(&objects) + .map(|object| match runfiles_dir { + Some(ref rd) => find_metadata_file(&execroot, rd, &object.to_string_lossy()), + None => execroot.join(object), + }) + .collect() + }) + .unwrap_or_default(); + debug_log!("Additional coverage objects: {:#?}", coverage_objects); + let profraw_files: Vec = fs::read_dir(coverage_dir) .unwrap() .flatten() @@ -203,6 +219,11 @@ fn main() { .arg("-ignore-filename-regex=/tmp/.+") .arg(format!("-path-equivalence=.,{}", execroot.display())) .arg(test_binary) + .args( + coverage_objects + .iter() + .flat_map(|object| [OsStr::new("-object"), object.as_os_str()]), + ) .stdout(process::Stdio::piped()) .stderr(process::Stdio::piped()); diff --git a/rust/private/rust.bzl b/rust/private/rust.bzl index d150068d9e..16a673bc85 100644 --- a/rust/private/rust.bzl +++ b/rust/private/rust.bzl @@ -617,6 +617,25 @@ def _rust_test_impl(ctx): # must set them explicitly. env["GENERATE_LLVM_LCOV"] = "1" env["CC_CODE_COVERAGE_SCRIPT"] = ctx.executable._collect_cc_coverage.path + + # Binaries the test runs as subprocesses write their `.profraw` beside + # the test's own, but `llvm-cov export` reads counters only for the + # objects it is given. Name them so the collector can pass them along. + coverage_objects = [] + for dep in data: + if rust_common.crate_info not in dep or not ctx.coverage_instrumented(dep): + continue + dep_crate_info = dep[rust_common.crate_info] + if dep_crate_info.type != "bin": + continue + if toolchain._experimental_use_coverage_metadata_files: + coverage_objects.append(dep_crate_info.output.path) + elif dep_crate_info.output.short_path.startswith("../"): + coverage_objects.append(dep_crate_info.output.short_path[len("../"):]) + else: + coverage_objects.append("{}/{}".format(ctx.workspace_name, dep_crate_info.output.short_path)) + if coverage_objects: + env["RUST_COVERAGE_OBJECTS"] = ctx.configuration.host_path_separator.join(coverage_objects) components = "{}/{}".format(ctx.label.workspace_root, ctx.label.package).split("/") env["CARGO_MANIFEST_DIR"] = "/".join([c for c in components if c]) providers.append(RunEnvironmentInfo( diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl index 4b24462e0b..37fe04dbc2 100644 --- a/rust/private/rustc.bzl +++ b/rust/private/rustc.bzl @@ -2243,7 +2243,7 @@ def rustc_compile_action( executable = crate_info.output if crate_info.type == "bin" or crate_info.is_test else None instrumented_files_kwargs = { - "dependency_attributes": ["deps", "crate"], + "dependency_attributes": ["deps", "crate", "data"], "extensions": ["rs"], "source_attributes": ["srcs"], } diff --git a/test/coverage_subprocess/BUILD.bazel b/test/coverage_subprocess/BUILD.bazel new file mode 100644 index 0000000000..57c68169ef --- /dev/null +++ b/test/coverage_subprocess/BUILD.bazel @@ -0,0 +1,16 @@ +load("//rust:defs.bzl", "rust_binary", "rust_test") + +rust_binary( + name = "greeter", + srcs = ["greeter.rs"], + edition = "2021", +) + +# Runs `:greeter` as a subprocess. Under `bazel coverage` the lines of +# `greeter.rs` are reached only through that subprocess. +rust_test( + name = "greeter_test", + srcs = ["greeter_test.rs"], + data = [":greeter"], + edition = "2021", +) diff --git a/test/coverage_subprocess/greeter.rs b/test/coverage_subprocess/greeter.rs new file mode 100644 index 0000000000..15a1c5ac86 --- /dev/null +++ b/test/coverage_subprocess/greeter.rs @@ -0,0 +1,12 @@ +//! A binary whose coverage is reached only by a test that spawns it. + +fn greeting(name: &str) -> String { + format!("hello, {name}") +} + +fn main() { + let name = std::env::args() + .nth(1) + .unwrap_or_else(|| "world".to_owned()); + println!("{}", greeting(&name)); +} diff --git a/test/coverage_subprocess/greeter_test.rs b/test/coverage_subprocess/greeter_test.rs new file mode 100644 index 0000000000..433933b1e5 --- /dev/null +++ b/test/coverage_subprocess/greeter_test.rs @@ -0,0 +1,13 @@ +//! Runs `greeter` through `CARGO_BIN_EXE_greeter`. + +use std::process::Command; + +#[test] +fn greeter_greets() { + let output = Command::new(env!("CARGO_BIN_EXE_greeter")) + .arg("bazel") + .output() + .expect("failed to run greeter"); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "hello, bazel\n"); +}