Lines
81.27 %
Functions
65.38 %
Branches
100 %
//! The coverage phase: an instrumented test run rendered into
//! `web/static/doc/coverage`.
//!
//! grcov can read the raw profiles itself, but it then holds every binary's
//! results at once and runs one `llvm-cov` per CPU — about 13 GiB for this
//! workspace, enough to OOM-kill the job on an 8 GiB CI node. So the profiles
//! are merged here single-threaded, each instrumented object is exported to lcov
//! on its own, and grcov only renders. Exporting per object keeps grcov's
//! numbers: one export over every object discards the counters of functions
//! compiled differently in different binaries.
//! Unlike grcov, which skips an object llvm-cov cannot export, a failed export
//! fails the phase: every exported object carries a real coverage map, so a
//! failure means a broken build, and a quietly lower percentage would hide it.
use std::ffi::OsStr;
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, ErrorKind, Read, Seek, SeekFrom, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use crate::clean::remove_dir_if_present;
use crate::heartbeat::Heartbeat;
use crate::release::{DOC_DIR, run};
const PROFRAW_DIR: &str = "target/coverage";
const PROFDATA: &str = "target/coverage.profdata";
const LCOV_DIR: &str = "target/coverage-lcov";
const TARGET_DIR: &str = "target/debug";
/// The section every instrumented object carries.
const COVMAP_SECTION: &[u8] = b"__llvm_covmap";
/// ELF magic, 64-bit class, little-endian data: the only objects CI builds.
const ELF_IDENT: &[u8] = b"\x7fELF\x02\x01";
const ELF_HEADER_LEN: usize = 64;
const E_SHOFF: usize = 0x28;
const E_SHENTSIZE: usize = 0x3a;
const E_SHNUM: usize = 0x3c;
const E_SHSTRNDX: usize = 0x3e;
const SH_NAME: usize = 0x00;
const SH_OFFSET: usize = 0x18;
const SH_SIZE: usize = 0x20;
const SH_LINK: usize = 0x28;
/// The name-table index an object with extended numbering stores in section 0.
const SHN_XINDEX: usize = 0xffff;
/// Real section and name tables are kilobytes; anything past this is corrupt.
const MAX_TABLE_LEN: u64 = 16 << 20;
/// Instrumented test run, then the HTML report.
pub fn run_coverage(heartbeat: &Heartbeat, database_url: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let profraw_dir = cwd.join(PROFRAW_DIR);
instrumented_test_run(heartbeat, &profraw_dir, database_url)?;
let llvm_bin = llvm_bin()?;
let profdata = Path::new(PROFDATA);
heartbeat.phase("merge profiles");
merge_profiles(&llvm_bin.join("llvm-profdata"), &profraw_dir, profdata)?;
heartbeat.phase("llvm-cov export");
let exported = export_objects(
&llvm_bin.join("llvm-cov"),
profdata,
Path::new(TARGET_DIR),
Path::new(LCOV_DIR),
&format!("{}/", cwd.display()),
)?;
println!(">> exported {exported} instrumented objects");
heartbeat.phase("grcov");
render_html(
"grcov",
&PathBuf::from(DOC_DIR).join("coverage"),
)
}
fn instrumented_test_run(
heartbeat: &Heartbeat,
profraw_dir: &Path,
database_url: &str,
) -> Result<()> {
// LLVM_PROFILE_FILE must be unique per process: %p (pid) and %m (binary id)
// keep the 85 test binaries from overwriting each other's profiles.
remove_dir_if_present(profraw_dir)?;
std::fs::create_dir_all(profraw_dir).context("cannot make the profraw dir")?;
let profile_pattern = profraw_dir.join("nomisync-%p-%m.profraw");
heartbeat.phase("instrumented test run");
// Watched, unlike every other phase here: this one prints a line per test,
// so silence means something is stuck rather than merely slow. A hang here
// once cost an hour and told us nothing about which test caused it.
crate::watchdog::run_watched(
"cargo",
&["test", "--verbose", "--all-features", "--workspace"],
&[
// No -Ccodegen-units=1: LLVM source-based coverage instruments per
// function, so the CGU count never changed what grcov reports — it
// only serialised codegen.
("RUSTFLAGS", "-Cinstrument-coverage"),
("CARGO_INCREMENTAL", "0"),
("LLVM_PROFILE_FILE", &profile_pattern.to_string_lossy()),
("DATABASE_URL", database_url),
],
// A coverage run that produced no profiles yields an empty-but-valid grcov
// report: green CI, blank coverage page. Assert instead.
let profraw_count = std::fs::read_dir(profraw_dir)
.context("cannot read the profraw dir")?
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "profraw"))
.count();
if profraw_count == 0 {
bail!("no .profraw produced — coverage instrumentation did not run");
println!(">> {profraw_count} profraw files");
Ok(())
/// `llvm-tools` installs its binaries beside the host target's libdir.
fn llvm_bin() -> Result<PathBuf> {
let output = Command::new("rustc")
.args(["--print", "target-libdir"])
.output()
.context("failed to run rustc")?;
if !output.status.success() {
bail!("rustc --print target-libdir exited with {}", output.status);
let libdir = String::from_utf8(output.stdout).context("rustc printed a non-UTF-8 libdir")?;
let bin =
llvm_bin_for(Path::new(libdir.trim())).context("rustc's target libdir has no parent")?;
require_llvm_tools(bin)
fn llvm_bin_for(libdir: &Path) -> Option<PathBuf> {
libdir.parent().map(|host| host.join("bin"))
fn require_llvm_tools(bin: PathBuf) -> Result<PathBuf> {
if !bin.join("llvm-profdata").is_file() {
bail!(
"no llvm-profdata in {} — install it with `rustup component add llvm-tools`",
bin.display()
);
Ok(bin)
/// One thread: the default of one per CPU took 3 GiB for this merge, one
/// thread takes 250 MiB.
fn merge_profiles(tool: &Path, profraw_dir: &Path, profdata: &Path) -> Result<()> {
run(
&tool.to_string_lossy(),
"merge",
"-sparse",
"--num-threads=1",
"-o",
&profdata.to_string_lossy(),
&profraw_dir.to_string_lossy(),
&[],
fn export_objects(
tool: &Path,
profdata: &Path,
target_dir: &Path,
lcov_dir: &Path,
root: &str,
) -> Result<usize> {
let objects = instrumented_objects(target_dir)?;
if objects.is_empty() {
bail!("no instrumented objects under {}", target_dir.display());
remove_dir_if_present(lcov_dir)?;
std::fs::create_dir_all(lcov_dir).context("cannot make the lcov dir")?;
let mut workspace_sources = 0usize;
for (index, object) in objects.iter().enumerate() {
let info = lcov_dir.join(format!("{index}.info"));
let kept = export_object(tool, profdata, object, &info, root)?;
workspace_sources = workspace_sources.saturating_add(kept);
// grcov still writes an index and badges when every source was ignored, so
// a checkout path llvm-cov does not report would otherwise publish a green,
// empty report.
if workspace_sources == 0 {
bail!("no lcov source path starts with {root}; the report would have no workspace files");
Ok(objects.len())
/// Returns how many workspace source records were kept.
fn export_object(
object: &Path,
info: &Path,
let mut child = Command::new(tool)
.args(["export", "-format=lcov", "-instr-profile"])
.arg(profdata)
.arg(object)
.stdout(Stdio::piped())
.spawn()
.with_context(|| format!("failed to run {}", tool.display()))?;
let stdout = child
.stdout
.take()
.context("llvm-cov stdout was not captured")?;
// The reader is dropped before the wait, so a failed copy closes the pipe
// and llvm-cov exits instead of blocking on a full pipe.
let copied = copy_workspace_records(stdout, info, root);
let status = child
.wait()
.with_context(|| format!("llvm-cov export {} did not finish", object.display()))?;
let kept = copied?;
if !status.success() {
bail!("llvm-cov export {} exited with {status}", object.display());
Ok(kept)
/// Copies only the records of sources under `root`, with their paths made
/// relative. The report ignores every absolute path anyway, and dropping the
/// registry and toolchain records here keeps gigabytes of lcov off the disk.
/// Returns how many records were kept.
fn copy_workspace_records(lcov: impl Read, info: &Path, root: &str) -> Result<usize> {
let file = File::create(info).with_context(|| format!("cannot create {}", info.display()))?;
let mut writer = BufWriter::new(file);
let mut kept = 0usize;
let mut keep = false;
for line in BufReader::new(lcov).lines() {
let line = line?;
match line
.strip_prefix("SF:")
.map(|source| source.strip_prefix(root))
{
Some(Some(relative)) => {
keep = true;
kept = kept.saturating_add(1);
writeln!(writer, "SF:{relative}")?;
Some(None) => keep = false,
None if keep => writeln!(writer, "{line}")?,
None => {}
writer
.flush()
.with_context(|| format!("cannot write {}", info.display()))?;
fn instrumented_objects(dir: &Path) -> Result<Vec<PathBuf>> {
let mut found = Vec::new();
collect_objects(dir, &mut found)?;
found.sort();
Ok(found)
fn collect_objects(dir: &Path, found: &mut Vec<PathBuf>) -> Result<()> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_objects(&path, found)?;
} else if file_type.is_file()
&& is_candidate(&path, entry.metadata()?.permissions().mode())
&& has_coverage_map(&path)?
found.push(path);
/// Executables (tests, binaries, build scripts) and shared objects (proc
/// macros, which run inside rustc) are what carry coverage maps.
fn is_candidate(path: &Path, mode: u32) -> bool {
match path.extension().and_then(OsStr::to_str) {
Some("so") => true,
Some("d") => false,
_ => mode & 0o111 != 0,
fn has_coverage_map(path: &Path) -> Result<bool> {
let mut file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
elf_has_section(&mut file, COVMAP_SECTION)
.with_context(|| format!("cannot read {}", path.display()))
/// Looks the name up in the ELF section table. Matching it anywhere in the
/// file also accepts binaries that merely contain the string — xtask itself
/// among them — and llvm-cov refuses to export those.
fn elf_has_section(file: &mut (impl Read + Seek), name: &[u8]) -> io::Result<bool> {
let mut header = [0; ELF_HEADER_LEN];
if !fill(file, &mut header)? {
return Ok(false);
let Some(sections) = SectionTable::parse(&header) else {
};
let Some(sections) = resolve_extended(file, sections)? else {
let Some(table) = read_region(file, sections.offset, sections.len())? else {
let Some(names_entry) = sections.entry(&table, sections.names_index) else {
let (Some(names_offset), Some(names_len)) = (
le_bytes(names_entry, SH_OFFSET).map(u64::from_le_bytes),
le_bytes(names_entry, SH_SIZE).map(u64::from_le_bytes),
) else {
let Some(names) = read_region(file, names_offset, usize::try_from(names_len).ok())? else {
Ok((0..sections.count)
.filter_map(|index| sections.entry(&table, index))
.filter_map(|entry| le_bytes(entry, SH_NAME).map(u32::from_le_bytes))
.any(|name_offset| section_name(&names, name_offset) == Some(name)))
/// With 0xff00 or more sections the header's count is 0 and its name index is
/// SHN_XINDEX; the real values live in section 0's `sh_size` and `sh_link`.
fn resolve_extended(
file: &mut (impl Read + Seek),
sections: SectionTable,
) -> io::Result<Option<SectionTable>> {
if sections.count != 0 && sections.names_index != SHN_XINDEX {
return Ok(Some(sections));
if sections.offset == 0 {
return Ok(None);
let Some(first) = read_region(file, sections.offset, Some(sections.entry_len))? else {
let count = match sections.count {
0 => le_bytes(&first, SH_SIZE)
.map(u64::from_le_bytes)
.and_then(|size| usize::try_from(size).ok()),
count => Some(count),
let names_index = match sections.names_index {
SHN_XINDEX => le_bytes(&first, SH_LINK)
.map(u32::from_le_bytes)
.and_then(|link| usize::try_from(link).ok()),
index => Some(index),
Ok(count
.zip(names_index)
.map(|(count, names_index)| SectionTable {
count,
names_index,
..sections
}))
/// Where an ELF64 little-endian object keeps its section headers.
struct SectionTable {
offset: u64,
entry_len: usize,
count: usize,
names_index: usize,
impl SectionTable {
/// Anything but ELF64 little-endian is not an object this pipeline builds.
fn parse(header: &[u8]) -> Option<Self> {
if header.get(..ELF_IDENT.len())? != ELF_IDENT {
return None;
Some(Self {
offset: le_bytes(header, E_SHOFF).map(u64::from_le_bytes)?,
entry_len: le_bytes(header, E_SHENTSIZE)
.map(u16::from_le_bytes)?
.into(),
count: le_bytes(header, E_SHNUM).map(u16::from_le_bytes)?.into(),
names_index: le_bytes(header, E_SHSTRNDX).map(u16::from_le_bytes)?.into(),
})
fn len(&self) -> Option<usize> {
self.entry_len.checked_mul(self.count)
fn entry<'a>(&self, table: &'a [u8], index: usize) -> Option<&'a [u8]> {
let start = self.entry_len.checked_mul(index)?;
table.get(start..start.checked_add(self.entry_len)?)
/// Reads `len` bytes at `offset`; `None` when the region is implausibly large
/// or runs past the end of the file.
fn read_region(
len: Option<usize>,
) -> io::Result<Option<Vec<u8>>> {
let Some(len) = len.filter(|len| u64::try_from(*len).is_ok_and(|len| len <= MAX_TABLE_LEN))
else {
file.seek(SeekFrom::Start(offset))?;
let mut region = vec![0; len];
Ok(fill(file, &mut region)?.then_some(region))
/// `false` when the file ends first: a truncated file is not an object.
fn fill(file: &mut impl Read, buf: &mut [u8]) -> io::Result<bool> {
match file.read_exact(buf) {
Ok(()) => Ok(true),
Err(err) if err.kind() == ErrorKind::UnexpectedEof => Ok(false),
Err(err) => Err(err),
fn section_name(names: &[u8], offset: u32) -> Option<&[u8]> {
names
.get(usize::try_from(offset).ok()?..)?
.split(|byte| *byte == 0)
.next()
fn le_bytes<const N: usize>(bytes: &[u8], at: usize) -> Option<[u8; N]> {
bytes.get(at..at.checked_add(N)?)?.try_into().ok()
fn render_html(grcov: &str, lcov_dir: &Path, coverage_dir: &Path) -> Result<()> {
// Stale trees must go first: `mv target/doc <existing>` nests the new tree
// inside the old one, silently pinning published pages to a snapshot.
remove_dir_if_present(coverage_dir)?;
grcov,
&lcov_dir.to_string_lossy(),
"-s",
".",
"-t",
"html",
"--branch",
"--ignore-not-existing",
"--ignore",
"/*",
"*cranelift*",
"*lexicon*",
"*incremental*",
&coverage_dir.to_string_lossy(),
// grcov exits zero on an empty profile set, so the tree existing is not the
// same as the tree having content. This job is the only place that can tell,
// and it is the artifact deploy will overlay.
let pages = std::fs::read_dir(coverage_dir)
.with_context(|| format!("grcov produced no {}", coverage_dir.display()))?
if pages == 0 {
"{} is empty — the coverage report has no pages",
coverage_dir.display()
println!(">> coverage report: {pages} entries");
#[cfg(test)]
mod test_fixtures;
mod tests;
mod tool_tests;