Repository navigation
MergeFunctions + -Cpasses=sancov-module leaves coverage counters without a PC table (breaks cargo fuzz --build-std) #163737
Description
With -Cpasses=sancov-module plus the inline-8bit-counters and pc-table options (what cargo-fuzz uses), identical weak functions end up with coverage counters but no PC table after linking with rust-lld. libFuzzer refuses to start such binaries:
ERROR: The size of coverage PC tables does not match the
number of instrumented PCs. This might be a compiler bug,
please contact the libFuzzer developers.
Reproducer
The two hook functions stand in for libFuzzer and perform its startup check.
#![feature(linkage)]
use std::sync::atomic::{AtomicUsize, Ordering};
// Two identical weak functions: MergeFunctions folds them into one private
// function and turns both names into aliases of it.
#[linkage = "weak"]
#[unsafe(no_mangle)]
pub extern "C" fn first(x: i32) -> i32 {
if x > 3 { x * 7 } else { x - 1 }
}
#[linkage = "weak"]
#[unsafe(no_mangle)]
pub extern "C" fn second(x: i32) -> i32 {
if x > 3 { x * 7 } else { x - 1 }
}
// Stand-ins for the libFuzzer runtime hooks, which receive the linked
// __sancov_cntrs and __sancov_pcs sections.
static COUNTERS: AtomicUsize = AtomicUsize::new(0);
static PCS: AtomicUsize = AtomicUsize::new(0);
#[unsafe(no_mangle)]
pub unsafe extern "C" fn __sanitizer_cov_8bit_counters_init(start: *const u8, end: *const u8) {
COUNTERS.store(end as usize - start as usize, Ordering::Relaxed);
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn __sanitizer_cov_pcs_init(start: *const usize, end: *const usize) {
// Each PC table entry is a (pc, flags) pair.
PCS.store((end as usize - start as usize) / (2 * size_of::<usize>()), Ordering::Relaxed);
}
fn main() {
println!("first(argc) = {}", first(std::env::args().count() as i32));
let (counters, pcs) = (COUNTERS.load(Ordering::Relaxed), PCS.load(Ordering::Relaxed));
println!("counters = {counters}, pc table entries = {pcs}");
if counters != pcs {
println!("MISMATCH: libFuzzer would abort here");
std::process::exit(1);
}
}rustc +nightly -O -Ccodegen-units=1 -Cpasses=sancov-module \
-Cllvm-args=-sanitizer-coverage-level=3 \
-Cllvm-args=-sanitizer-coverage-inline-8bit-counters \
-Cllvm-args=-sanitizer-coverage-pc-table \
repro.rs && ./reproI expected the counter and PC table counts to match. Instead:
first(argc) = 0
counters = 26, pc table entries = 25
MISMATCH: libFuzzer would abort here
With -Zmerge-functions=disabled added, both counts are 26. -Ccodegen-units=1, which cargo-fuzz also passes, is needed so both functions are in the same LLVM module.
A rustc built with LLVM assertions (the alt CI build of the same commit) aborts while compiling the reproducer instead, and also while compiling compiler_builtins with -Zbuild-std and these flags:
Instrumentation.cpp:99: Comdat *llvm::getOrCreateFunctionComdat(Function &, Triple &): Assertion `F.hasName()' failed.
Cause
MergeFunctions, enabled by default at opt-level 2 and above, folds the two weak functions into an unnamed private function. rustc runs -Cpasses after its optimization pipeline, so SanitizerCoverage then instruments that unnamed function and puts its arrays in an empty-named comdat, which is never emitted as a section group. rust-lld's --gc-sections keeps the counters, which the code references, and discards the PC table. Details and an LLVM-only reproducer: llvm/llvm-project#228820. Clang isn't affected because it instruments before MergeFunctions runs.
Real-world impact
cargo fuzz --build-std (also implied by --careful, and used for the memory and thread sanitizers) instruments compiler_builtins. Its identical weak intrinsics, for example rint/roundeven and __eqdf2/__ledf2, are merged this way, so any fuzz target that links one of them fails at startup. In my project, a ThreadSanitizer target failed with 68,741 counters against 68,642 PC entries. The 99 missing entries are exactly the merged functions' arrays from compiler_builtins.
Workaround: add -Zmerge-functions=disabled to RUSTFLAGS for these builds. rustc could also avoid this by disabling MergeFunctions when -Cpasses contains sancov-module.
rust-fuzz/cargo-fuzz#197 tracks the same libFuzzer error, but it predates this and had a different cause (old GNU ld).
Meta
rustc --version --verbose:
rustc 1.101.0-nightly (c36f14571 2026-10-01)
binary: rustc
commit-hash: c36f1457196e315bc204b9564a6a5a7fe7f5a51f
commit-date: 2026-10-01
host: x86_64-unknown-linux-gnu
release: 1.101.0-nightly
LLVM version: 23.1.1
Activity
maxdexh commentedon Oct 4, 2026
IIUC, this is an LLVM bug?
nikic commentedon Oct 4, 2026
LLVM should handle this more gracefully, but I believe the issue wouldn't occur if the sancov pass were scheduled at the pipeline position where it is supposed to run (i.e. the same as all the other sanitizers). See also #161855 (comment).
lbrooney commentedon Oct 6, 2026
The underlying issue should be fixed in llvm/llvm-project#228959
Had the pipelining been different then this issue might not have been encountered, but that's a broader discussion I'm not equipped to have.
Auto merge of #164254 - nikic:llvm-update-4, r=cuviper
Auto merge of #164254 - nikic:llvm-update-4, r=cuviper
Auto merge of #164254 - nikic:llvm-update-4, r=cuviper