1
//! The coverage phase: an instrumented test run rendered into
2
//! `web/static/doc/coverage`.
3
//!
4
//! grcov can read the raw profiles itself, but it then holds every binary's
5
//! results at once and runs one `llvm-cov` per CPU — about 13 GiB for this
6
//! workspace, enough to OOM-kill the job on an 8 GiB CI node. So the profiles
7
//! are merged here single-threaded, each instrumented object is exported to lcov
8
//! on its own, and grcov only renders. Exporting per object keeps grcov's
9
//! numbers: one export over every object discards the counters of functions
10
//! compiled differently in different binaries.
11
//!
12
//! Unlike grcov, which skips an object llvm-cov cannot export, a failed export
13
//! fails the phase: every exported object carries a real coverage map, so a
14
//! failure means a broken build, and a quietly lower percentage would hide it.
15

            
16
use std::ffi::OsStr;
17
use std::fs::File;
18
use std::io::{self, BufRead, BufReader, BufWriter, ErrorKind, Read, Seek, SeekFrom, Write};
19
use std::os::unix::fs::PermissionsExt;
20
use std::path::{Path, PathBuf};
21
use std::process::{Command, Stdio};
22

            
23
use anyhow::{Context, Result, bail};
24

            
25
use crate::clean::remove_dir_if_present;
26
use crate::heartbeat::Heartbeat;
27
use crate::release::{DOC_DIR, run};
28

            
29
const PROFRAW_DIR: &str = "target/coverage";
30
const PROFDATA: &str = "target/coverage.profdata";
31
const LCOV_DIR: &str = "target/coverage-lcov";
32
const TARGET_DIR: &str = "target/debug";
33
/// The section every instrumented object carries.
34
const COVMAP_SECTION: &[u8] = b"__llvm_covmap";
35

            
36
/// ELF magic, 64-bit class, little-endian data: the only objects CI builds.
37
const ELF_IDENT: &[u8] = b"\x7fELF\x02\x01";
38
const ELF_HEADER_LEN: usize = 64;
39
const E_SHOFF: usize = 0x28;
40
const E_SHENTSIZE: usize = 0x3a;
41
const E_SHNUM: usize = 0x3c;
42
const E_SHSTRNDX: usize = 0x3e;
43
const SH_NAME: usize = 0x00;
44
const SH_OFFSET: usize = 0x18;
45
const SH_SIZE: usize = 0x20;
46
const SH_LINK: usize = 0x28;
47
/// The name-table index an object with extended numbering stores in section 0.
48
const SHN_XINDEX: usize = 0xffff;
49
/// Real section and name tables are kilobytes; anything past this is corrupt.
50
const MAX_TABLE_LEN: u64 = 16 << 20;
51

            
52
/// Instrumented test run, then the HTML report.
53
pub fn run_coverage(heartbeat: &Heartbeat, database_url: &str) -> Result<()> {
54
    let cwd = std::env::current_dir()?;
55
    let profraw_dir = cwd.join(PROFRAW_DIR);
56
    instrumented_test_run(heartbeat, &profraw_dir, database_url)?;
57

            
58
    let llvm_bin = llvm_bin()?;
59
    let profdata = Path::new(PROFDATA);
60
    heartbeat.phase("merge profiles");
61
    merge_profiles(&llvm_bin.join("llvm-profdata"), &profraw_dir, profdata)?;
62

            
63
    heartbeat.phase("llvm-cov export");
64
    let exported = export_objects(
65
        &llvm_bin.join("llvm-cov"),
66
        profdata,
67
        Path::new(TARGET_DIR),
68
        Path::new(LCOV_DIR),
69
        &format!("{}/", cwd.display()),
70
    )?;
71
    println!(">> exported {exported} instrumented objects");
72

            
73
    heartbeat.phase("grcov");
74
    render_html(
75
        "grcov",
76
        Path::new(LCOV_DIR),
77
        &PathBuf::from(DOC_DIR).join("coverage"),
78
    )
79
}
80

            
81
fn instrumented_test_run(
82
    heartbeat: &Heartbeat,
83
    profraw_dir: &Path,
84
    database_url: &str,
85
) -> Result<()> {
86
    // LLVM_PROFILE_FILE must be unique per process: %p (pid) and %m (binary id)
87
    // keep the 85 test binaries from overwriting each other's profiles.
88
    remove_dir_if_present(profraw_dir)?;
89
    std::fs::create_dir_all(profraw_dir).context("cannot make the profraw dir")?;
90
    let profile_pattern = profraw_dir.join("nomisync-%p-%m.profraw");
91

            
92
    heartbeat.phase("instrumented test run");
93
    // Watched, unlike every other phase here: this one prints a line per test,
94
    // so silence means something is stuck rather than merely slow. A hang here
95
    // once cost an hour and told us nothing about which test caused it.
96
    crate::watchdog::run_watched(
97
        "cargo",
98
        &["test", "--verbose", "--all-features", "--workspace"],
99
        &[
100
            // No -Ccodegen-units=1: LLVM source-based coverage instruments per
101
            // function, so the CGU count never changed what grcov reports — it
102
            // only serialised codegen.
103
            ("RUSTFLAGS", "-Cinstrument-coverage"),
104
            ("CARGO_INCREMENTAL", "0"),
105
            ("LLVM_PROFILE_FILE", &profile_pattern.to_string_lossy()),
106
            ("DATABASE_URL", database_url),
107
        ],
108
    )?;
109

            
110
    // A coverage run that produced no profiles yields an empty-but-valid grcov
111
    // report: green CI, blank coverage page. Assert instead.
112
    let profraw_count = std::fs::read_dir(profraw_dir)
113
        .context("cannot read the profraw dir")?
114
        .filter_map(Result::ok)
115
        .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "profraw"))
116
        .count();
117
    if profraw_count == 0 {
118
        bail!("no .profraw produced — coverage instrumentation did not run");
119
    }
120
    println!(">> {profraw_count} profraw files");
121
    Ok(())
122
}
123

            
124
/// `llvm-tools` installs its binaries beside the host target's libdir.
125
1
fn llvm_bin() -> Result<PathBuf> {
126
1
    let output = Command::new("rustc")
127
1
        .args(["--print", "target-libdir"])
128
1
        .output()
129
1
        .context("failed to run rustc")?;
130
1
    if !output.status.success() {
131
        bail!("rustc --print target-libdir exited with {}", output.status);
132
1
    }
133
1
    let libdir = String::from_utf8(output.stdout).context("rustc printed a non-UTF-8 libdir")?;
134
1
    let bin =
135
1
        llvm_bin_for(Path::new(libdir.trim())).context("rustc's target libdir has no parent")?;
136
1
    require_llvm_tools(bin)
137
1
}
138

            
139
3
fn llvm_bin_for(libdir: &Path) -> Option<PathBuf> {
140
3
    libdir.parent().map(|host| host.join("bin"))
141
3
}
142

            
143
3
fn require_llvm_tools(bin: PathBuf) -> Result<PathBuf> {
144
3
    if !bin.join("llvm-profdata").is_file() {
145
1
        bail!(
146
            "no llvm-profdata in {} — install it with `rustup component add llvm-tools`",
147
1
            bin.display()
148
        );
149
2
    }
150
2
    Ok(bin)
151
3
}
152

            
153
/// One thread: the default of one per CPU took 3 GiB for this merge, one
154
/// thread takes 250 MiB.
155
2
fn merge_profiles(tool: &Path, profraw_dir: &Path, profdata: &Path) -> Result<()> {
156
2
    run(
157
2
        &tool.to_string_lossy(),
158
2
        &[
159
2
            "merge",
160
2
            "-sparse",
161
2
            "--num-threads=1",
162
2
            "-o",
163
2
            &profdata.to_string_lossy(),
164
2
            &profraw_dir.to_string_lossy(),
165
2
        ],
166
2
        &[],
167
    )
168
2
}
169

            
170
3
fn export_objects(
171
3
    tool: &Path,
172
3
    profdata: &Path,
173
3
    target_dir: &Path,
174
3
    lcov_dir: &Path,
175
3
    root: &str,
176
3
) -> Result<usize> {
177
3
    let objects = instrumented_objects(target_dir)?;
178
3
    if objects.is_empty() {
179
1
        bail!("no instrumented objects under {}", target_dir.display());
180
2
    }
181
2
    remove_dir_if_present(lcov_dir)?;
182
2
    std::fs::create_dir_all(lcov_dir).context("cannot make the lcov dir")?;
183

            
184
2
    let mut workspace_sources = 0usize;
185
3
    for (index, object) in objects.iter().enumerate() {
186
3
        let info = lcov_dir.join(format!("{index}.info"));
187
3
        let kept = export_object(tool, profdata, object, &info, root)?;
188
3
        workspace_sources = workspace_sources.saturating_add(kept);
189
    }
190
    // grcov still writes an index and badges when every source was ignored, so
191
    // a checkout path llvm-cov does not report would otherwise publish a green,
192
    // empty report.
193
2
    if workspace_sources == 0 {
194
1
        bail!("no lcov source path starts with {root}; the report would have no workspace files");
195
1
    }
196
1
    Ok(objects.len())
197
3
}
198

            
199
/// Returns how many workspace source records were kept.
200
6
fn export_object(
201
6
    tool: &Path,
202
6
    profdata: &Path,
203
6
    object: &Path,
204
6
    info: &Path,
205
6
    root: &str,
206
6
) -> Result<usize> {
207
6
    let mut child = Command::new(tool)
208
6
        .args(["export", "-format=lcov", "-instr-profile"])
209
6
        .arg(profdata)
210
6
        .arg(object)
211
6
        .stdout(Stdio::piped())
212
6
        .spawn()
213
6
        .with_context(|| format!("failed to run {}", tool.display()))?;
214
6
    let stdout = child
215
6
        .stdout
216
6
        .take()
217
6
        .context("llvm-cov stdout was not captured")?;
218
    // The reader is dropped before the wait, so a failed copy closes the pipe
219
    // and llvm-cov exits instead of blocking on a full pipe.
220
6
    let copied = copy_workspace_records(stdout, info, root);
221
6
    let status = child
222
6
        .wait()
223
6
        .with_context(|| format!("llvm-cov export {} did not finish", object.display()))?;
224
6
    let kept = copied?;
225
5
    if !status.success() {
226
1
        bail!("llvm-cov export {} exited with {status}", object.display());
227
4
    }
228
4
    Ok(kept)
229
6
}
230

            
231
/// Copies only the records of sources under `root`, with their paths made
232
/// relative. The report ignores every absolute path anyway, and dropping the
233
/// registry and toolchain records here keeps gigabytes of lcov off the disk.
234
/// Returns how many records were kept.
235
10
fn copy_workspace_records(lcov: impl Read, info: &Path, root: &str) -> Result<usize> {
236
10
    let file = File::create(info).with_context(|| format!("cannot create {}", info.display()))?;
237
8
    let mut writer = BufWriter::new(file);
238
8
    let mut kept = 0usize;
239
8
    let mut keep = false;
240
33
    for line in BufReader::new(lcov).lines() {
241
33
        let line = line?;
242
33
        match line
243
33
            .strip_prefix("SF:")
244
33
            .map(|source| source.strip_prefix(root))
245
        {
246
6
            Some(Some(relative)) => {
247
6
                keep = true;
248
6
                kept = kept.saturating_add(1);
249
6
                writeln!(writer, "SF:{relative}")?;
250
            }
251
5
            Some(None) => keep = false,
252
12
            None if keep => writeln!(writer, "{line}")?,
253
10
            None => {}
254
        }
255
    }
256
8
    writer
257
8
        .flush()
258
8
        .with_context(|| format!("cannot write {}", info.display()))?;
259
8
    Ok(kept)
260
10
}
261

            
262
5
fn instrumented_objects(dir: &Path) -> Result<Vec<PathBuf>> {
263
5
    let mut found = Vec::new();
264
5
    collect_objects(dir, &mut found)?;
265
4
    found.sort();
266
4
    Ok(found)
267
5
}
268

            
269
10
fn collect_objects(dir: &Path, found: &mut Vec<PathBuf>) -> Result<()> {
270
9
    let entries =
271
10
        std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))?;
272
15
    for entry in entries {
273
15
        let entry = entry?;
274
15
        let path = entry.path();
275
15
        let file_type = entry.file_type()?;
276
15
        if file_type.is_dir() {
277
5
            collect_objects(&path, found)?;
278
10
        } else if file_type.is_file()
279
10
            && is_candidate(&path, entry.metadata()?.permissions().mode())
280
8
            && has_coverage_map(&path)?
281
6
        {
282
6
            found.push(path);
283
6
        }
284
    }
285
9
    Ok(())
286
10
}
287

            
288
/// Executables (tests, binaries, build scripts) and shared objects (proc
289
/// macros, which run inside rustc) are what carry coverage maps.
290
14
fn is_candidate(path: &Path, mode: u32) -> bool {
291
14
    match path.extension().and_then(OsStr::to_str) {
292
6
        Some("so") => true,
293
4
        Some("d") => false,
294
10
        _ => mode & 0o111 != 0,
295
    }
296
14
}
297

            
298
8
fn has_coverage_map(path: &Path) -> Result<bool> {
299
8
    let mut file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
300
8
    elf_has_section(&mut file, COVMAP_SECTION)
301
8
        .with_context(|| format!("cannot read {}", path.display()))
302
8
}
303

            
304
/// Looks the name up in the ELF section table. Matching it anywhere in the
305
/// file also accepts binaries that merely contain the string — xtask itself
306
/// among them — and llvm-cov refuses to export those.
307
21
fn elf_has_section(file: &mut (impl Read + Seek), name: &[u8]) -> io::Result<bool> {
308
21
    let mut header = [0; ELF_HEADER_LEN];
309
21
    if !fill(file, &mut header)? {
310
3
        return Ok(false);
311
17
    }
312
17
    let Some(sections) = SectionTable::parse(&header) else {
313
1
        return Ok(false);
314
    };
315
16
    let Some(sections) = resolve_extended(file, sections)? else {
316
1
        return Ok(false);
317
    };
318
15
    let Some(table) = read_region(file, sections.offset, sections.len())? else {
319
2
        return Ok(false);
320
    };
321
13
    let Some(names_entry) = sections.entry(&table, sections.names_index) else {
322
1
        return Ok(false);
323
    };
324
12
    let (Some(names_offset), Some(names_len)) = (
325
12
        le_bytes(names_entry, SH_OFFSET).map(u64::from_le_bytes),
326
12
        le_bytes(names_entry, SH_SIZE).map(u64::from_le_bytes),
327
    ) else {
328
        return Ok(false);
329
    };
330
12
    let Some(names) = read_region(file, names_offset, usize::try_from(names_len).ok())? else {
331
        return Ok(false);
332
    };
333
12
    Ok((0..sections.count)
334
35
        .filter_map(|index| sections.entry(&table, index))
335
35
        .filter_map(|entry| le_bytes(entry, SH_NAME).map(u32::from_le_bytes))
336
35
        .any(|name_offset| section_name(&names, name_offset) == Some(name)))
337
21
}
338

            
339
/// With 0xff00 or more sections the header's count is 0 and its name index is
340
/// SHN_XINDEX; the real values live in section 0's `sh_size` and `sh_link`.
341
16
fn resolve_extended(
342
16
    file: &mut (impl Read + Seek),
343
16
    sections: SectionTable,
344
16
) -> io::Result<Option<SectionTable>> {
345
16
    if sections.count != 0 && sections.names_index != SHN_XINDEX {
346
12
        return Ok(Some(sections));
347
4
    }
348
4
    if sections.offset == 0 {
349
1
        return Ok(None);
350
3
    }
351
3
    let Some(first) = read_region(file, sections.offset, Some(sections.entry_len))? else {
352
        return Ok(None);
353
    };
354
3
    let count = match sections.count {
355
3
        0 => le_bytes(&first, SH_SIZE)
356
3
            .map(u64::from_le_bytes)
357
3
            .and_then(|size| usize::try_from(size).ok()),
358
        count => Some(count),
359
    };
360
3
    let names_index = match sections.names_index {
361
3
        SHN_XINDEX => le_bytes(&first, SH_LINK)
362
3
            .map(u32::from_le_bytes)
363
3
            .and_then(|link| usize::try_from(link).ok()),
364
        index => Some(index),
365
    };
366
3
    Ok(count
367
3
        .zip(names_index)
368
3
        .map(|(count, names_index)| SectionTable {
369
3
            count,
370
3
            names_index,
371
            ..sections
372
3
        }))
373
16
}
374

            
375
/// Where an ELF64 little-endian object keeps its section headers.
376
struct SectionTable {
377
    offset: u64,
378
    entry_len: usize,
379
    count: usize,
380
    names_index: usize,
381
}
382

            
383
impl SectionTable {
384
    /// Anything but ELF64 little-endian is not an object this pipeline builds.
385
17
    fn parse(header: &[u8]) -> Option<Self> {
386
17
        if header.get(..ELF_IDENT.len())? != ELF_IDENT {
387
1
            return None;
388
16
        }
389
        Some(Self {
390
16
            offset: le_bytes(header, E_SHOFF).map(u64::from_le_bytes)?,
391
16
            entry_len: le_bytes(header, E_SHENTSIZE)
392
16
                .map(u16::from_le_bytes)?
393
16
                .into(),
394
16
            count: le_bytes(header, E_SHNUM).map(u16::from_le_bytes)?.into(),
395
16
            names_index: le_bytes(header, E_SHSTRNDX).map(u16::from_le_bytes)?.into(),
396
        })
397
17
    }
398

            
399
15
    fn len(&self) -> Option<usize> {
400
15
        self.entry_len.checked_mul(self.count)
401
15
    }
402

            
403
48
    fn entry<'a>(&self, table: &'a [u8], index: usize) -> Option<&'a [u8]> {
404
48
        let start = self.entry_len.checked_mul(index)?;
405
48
        table.get(start..start.checked_add(self.entry_len)?)
406
48
    }
407
}
408

            
409
/// Reads `len` bytes at `offset`; `None` when the region is implausibly large
410
/// or runs past the end of the file.
411
30
fn read_region(
412
30
    file: &mut (impl Read + Seek),
413
30
    offset: u64,
414
30
    len: Option<usize>,
415
30
) -> io::Result<Option<Vec<u8>>> {
416
30
    let Some(len) = len.filter(|len| u64::try_from(*len).is_ok_and(|len| len <= MAX_TABLE_LEN))
417
    else {
418
1
        return Ok(None);
419
    };
420
29
    file.seek(SeekFrom::Start(offset))?;
421
29
    let mut region = vec![0; len];
422
29
    Ok(fill(file, &mut region)?.then_some(region))
423
30
}
424

            
425
/// `false` when the file ends first: a truncated file is not an object.
426
50
fn fill(file: &mut impl Read, buf: &mut [u8]) -> io::Result<bool> {
427
50
    match file.read_exact(buf) {
428
45
        Ok(()) => Ok(true),
429
5
        Err(err) if err.kind() == ErrorKind::UnexpectedEof => Ok(false),
430
1
        Err(err) => Err(err),
431
    }
432
50
}
433

            
434
35
fn section_name(names: &[u8], offset: u32) -> Option<&[u8]> {
435
35
    names
436
35
        .get(usize::try_from(offset).ok()?..)?
437
248
        .split(|byte| *byte == 0)
438
35
        .next()
439
35
}
440

            
441
129
fn le_bytes<const N: usize>(bytes: &[u8], at: usize) -> Option<[u8; N]> {
442
129
    bytes.get(at..at.checked_add(N)?)?.try_into().ok()
443
129
}
444

            
445
3
fn render_html(grcov: &str, lcov_dir: &Path, coverage_dir: &Path) -> Result<()> {
446
    // Stale trees must go first: `mv target/doc <existing>` nests the new tree
447
    // inside the old one, silently pinning published pages to a snapshot.
448
3
    remove_dir_if_present(coverage_dir)?;
449
3
    run(
450
3
        grcov,
451
3
        &[
452
3
            &lcov_dir.to_string_lossy(),
453
3
            "-s",
454
3
            ".",
455
3
            "-t",
456
3
            "html",
457
3
            "--branch",
458
3
            "--ignore-not-existing",
459
3
            "--ignore",
460
3
            "/*",
461
3
            "--ignore",
462
3
            "*cranelift*",
463
3
            "--ignore",
464
3
            "*lexicon*",
465
3
            "--ignore",
466
3
            "*incremental*",
467
3
            "-o",
468
3
            &coverage_dir.to_string_lossy(),
469
3
        ],
470
3
        &[],
471
1
    )?;
472

            
473
    // grcov exits zero on an empty profile set, so the tree existing is not the
474
    // same as the tree having content. This job is the only place that can tell,
475
    // and it is the artifact deploy will overlay.
476
2
    let pages = std::fs::read_dir(coverage_dir)
477
2
        .with_context(|| format!("grcov produced no {}", coverage_dir.display()))?
478
2
        .count();
479
2
    if pages == 0 {
480
1
        bail!(
481
            "{} is empty — the coverage report has no pages",
482
1
            coverage_dir.display()
483
        );
484
1
    }
485
1
    println!(">> coverage report: {pages} entries");
486
1
    Ok(())
487
3
}
488

            
489
#[cfg(test)]
490
mod test_fixtures;
491
#[cfg(test)]
492
mod tests;
493
#[cfg(test)]
494
mod tool_tests;