Problem
Linking a mix of LTO and non-LTO static archives with mold 2.41.0 produces spurious "refers to a discarded COMDAT section probably due to an ODR violation" errors. The same link succeeds with mold 2.40.4 and with GNU ld. The resulting binary (via --noinhibit-exec) segfaults, confirming the COMDAT section was genuinely needed.
The issue is in do_lto(): after LTO compilation, is_reachable is reset for all archive members so that resolve_symbols() can re-derive which ones are actually needed. However, ComdatGroup::owner and InputSection::is_alive are not reset. If the round-1 COMDAT winner is no longer needed after LTO (e.g. LTO dead-code-eliminated the only call path into it), it won't be re-extracted — but it still owns the COMDAT group. Every other archive member that shares that COMDAT has its section killed with no surviving provider.
This was introduced in commit 632a0ee, specifically the || file->as_needed addition:
for (ObjectFile<E> *file : ctx.objs)
if (file->is_lto_obj || file->as_needed)
file->is_reachable = false;
Before that commit, only IR objects had their reachability reset, so non-LTO archive members kept their round-1 state and COMDAT ownership remained consistent.
Tested with GCC 15.2.0, mold 2.41.0 vs 2.40.4 and GNU ld.
Repro
shared.h
#pragma once
#include <string>
template <typename T>
__attribute__((noinline))
std::string convert(T val) {
char buf[32];
int n = snprintf(buf, sizeof(buf), "%ld", (long)val);
return std::string(buf, n);
}
a.cpp
#include "shared.h"
std::string do_a(int x) {
return "a_" + convert(x);
}
b.cpp
#include "shared.h"
std::string do_b(int x) {
return "b_" + convert(x);
}
mid.cpp
#include <string>
std::string do_a(int);
std::string do_b(int);
std::string use_a(int x) {
return do_a(x);
}
std::string use_b(int x) {
return do_b(x);
}
main.cpp
#include <string>
std::string use_b(int);
int main() {
auto r = use_b(42);
return r.empty() ? 1 : 0;
}
compile/link
g++ -std=c++20 -O2 -c a.cpp -o a.o
g++ -std=c++20 -O2 -c b.cpp -o b.o
ar rcs libutil.a a.o b.o
g++ -std=c++20 -O2 -flto=auto -fno-fat-lto-objects -c mid.cpp -o mid.o
gcc-ar rcs libmid.a mid.o
g++ -std=c++20 -O2 -flto=auto -fno-fat-lto-objects -c main.cpp -o main.o
g++ -std=c++20 -O2 -flto=auto -fno-fat-lto-objects -fuse-ld=mold main.o libmid.a libutil.a -o repro
shell script to run repro
#!/bin/bash
# Minimal reproducer for mold >= 2.41 false ODR violation with GCC LTO
#
# Bug in commit 632a0eea: do_lto() resets is_reachable for all archive
# members (as_needed), but does NOT reset ComdatGroup::owner or
# InputSection::is_alive.
#
# When the round-1 COMDAT winner is not re-extracted in round 2
# (because LTO dead-code-eliminated the functions that needed it), the
# stale owner prevents any other file from claiming the COMDAT group.
# All remaining COMDAT providers have their sections killed with no
# replacement → false "ODR violation" errors.
#
# Trigger conditions:
# 1. Non-LTO archive with 2+ members sharing a COMDAT group
# 2. LTO code with a function that references the COMDAT winner's TU
# AND another function that references a COMDAT loser's TU
# 3. Only the second function is reachable from main()
# 4. LTO dead-code-eliminates the first function → winner not re-extracted
#
# Result: FAIL with mold >= 2.41, PASS with mold < 2.41, PASS with GNU ld
set -e
CXX="${CXX:-g++}"
AR="${AR:-ar}"
GCC_AR="${GCC_AR:-gcc-ar}"
MOLD="${MOLD:-mold}"
echo "Using CXX=$CXX"
echo "Using MOLD=$($MOLD --version)"
DIR=$(mktemp -d)
trap "rm -rf $DIR" EXIT
# ===========================================================
# Header: noinline template function → produces COMDAT group
# in every TU that instantiates it.
# ===========================================================
cat > "$DIR/shared.h" <<'EOF'
#pragma once
#include <string>
template <typename T>
__attribute__((noinline))
std::string convert(T val) {
char buf[32];
int n = snprintf(buf, sizeof(buf), "%ld", (long)val);
return std::string(buf, n);
}
EOF
# ===========================================================
# Non-LTO archive: two members, each instantiating convert<int>
# (same template → same COMDAT group), plus unique functions.
#
# a.o is first → lowest priority → wins COMDAT in round 1.
# b.o is second → loses COMDAT → its convert<int> section killed.
# ===========================================================
cat > "$DIR/a.cpp" <<'EOF'
#include "shared.h"
std::string do_a(int x) {
return "a_" + convert(x);
}
EOF
cat > "$DIR/b.cpp" <<'EOF'
#include "shared.h"
std::string do_b(int x) {
return "b_" + convert(x);
}
EOF
# ===========================================================
# LTO code: mid.cpp has two functions:
# use_a() → calls do_a() from a.o
# use_b() → calls do_b() from b.o
#
# main() only calls use_b(). LTO dead-code-eliminates use_a(),
# so ltrans output has no undefined ref to do_a().
#
# Round 2: a.o not re-extracted (nothing needs do_a), but its
# COMDAT ownership persists → b.o's COMDAT killed → no provider.
# ===========================================================
cat > "$DIR/mid.cpp" <<'EOF'
#include <string>
std::string do_a(int);
std::string do_b(int);
std::string use_a(int x) {
return do_a(x);
}
std::string use_b(int x) {
return do_b(x);
}
EOF
cat > "$DIR/main.cpp" <<'EOF'
#include <string>
std::string use_b(int);
int main() {
auto r = use_b(42);
return r.empty() ? 1 : 0;
}
EOF
# ===========================================================
# Build
# ===========================================================
echo "--- Compiling non-LTO archive ---"
$CXX -std=c++20 -O2 -I"$DIR" -c "$DIR/a.cpp" -o "$DIR/a.o"
$CXX -std=c++20 -O2 -I"$DIR" -c "$DIR/b.cpp" -o "$DIR/b.o"
# a.o first → lower priority → wins COMDAT in round 1
$AR rcs "$DIR/libutil.a" "$DIR/a.o" "$DIR/b.o"
echo "--- Compiling LTO objects ---"
$CXX -std=c++20 -O2 -flto=auto -fno-fat-lto-objects \
-c "$DIR/mid.cpp" -o "$DIR/mid.o"
$GCC_AR rcs "$DIR/libmid.a" "$DIR/mid.o"
$CXX -std=c++20 -O2 -flto=auto -fno-fat-lto-objects \
-c "$DIR/main.cpp" -o "$DIR/main.o"
# ===========================================================
# Link
# ===========================================================
echo ""
echo "=== Linking with mold ==="
if $CXX -std=c++20 -O2 -flto=auto -fno-fat-lto-objects \
-B"$(dirname "$MOLD")" -fuse-ld=mold \
"$DIR/main.o" "$DIR/libmid.a" "$DIR/libutil.a" \
-o "$DIR/repro" 2>&1; then
echo "PASS: linked without error"
"$DIR/repro" && echo "Binary runs correctly" || echo "Binary returned $?"
else
echo "FAIL: false ODR violation reported"
echo ""
echo "=== Verify: link with --noinhibit-exec ==="
$CXX -std=c++20 -O2 -flto=auto -fno-fat-lto-objects \
-B"$(dirname "$MOLD")" -fuse-ld=mold \
-Wl,--noinhibit-exec \
"$DIR/main.o" "$DIR/libmid.a" "$DIR/libutil.a" \
-o "$DIR/repro" 2>&1 || true
if [ -x "$DIR/repro" ]; then
"$DIR/repro" && echo "Binary runs correctly (proves false positive)" \
|| echo "Binary returned $?"
fi
fi
Possible fix
This patch fixes the issue:
diff --git a/src/passes.cc b/src/passes.cc
index 2a2883a7..19b320b1 100644
--- a/src/passes.cc
+++ b/src/passes.cc
@@ -434,6 +434,20 @@ void do_lto(Context<E> &ctx) {
std::erase_if(ctx.objs, [](ObjectFile<E> *file) { return file->is_lto_obj; });
+ // Reset COMDAT state from the first resolution round. Ownership and
+ // is_alive persist across rounds, so if an archive member that won a
+ // COMDAT group is not re-extracted (because LTO eliminated the call
+ // path that pulled it in), the stale owner prevents any other file
+ // from providing that COMDAT, producing false ODR violations.
+ tbb::parallel_for_each(ctx.objs, [](ObjectFile<E> *file) {
+ for (ComdatGroupRef<E> &ref : file->comdat_groups) {
+ ref.group->owner = -1;
+ for (u32 i : ref.members)
+ if (InputSection<E> *isec = file->sections[i].get())
+ isec->is_alive = true;
+ }
+ });
+
resolve_symbols(ctx);
}
Problem
Linking a mix of LTO and non-LTO static archives with mold 2.41.0 produces spurious "refers to a discarded COMDAT section probably due to an ODR violation" errors. The same link succeeds with mold 2.40.4 and with GNU ld. The resulting binary (via --noinhibit-exec) segfaults, confirming the COMDAT section was genuinely needed.
The issue is in
do_lto(): after LTO compilation,is_reachableis reset for all archive members so thatresolve_symbols()can re-derive which ones are actually needed. However,ComdatGroup::ownerandInputSection::is_aliveare not reset. If the round-1 COMDAT winner is no longer needed after LTO (e.g. LTO dead-code-eliminated the only call path into it), it won't be re-extracted — but it still owns the COMDAT group. Every other archive member that shares that COMDAT has its section killed with no surviving provider.This was introduced in commit 632a0ee, specifically the
|| file->as_neededaddition:Before that commit, only IR objects had their reachability reset, so non-LTO archive members kept their round-1 state and COMDAT ownership remained consistent.
Tested with GCC 15.2.0, mold 2.41.0 vs 2.40.4 and GNU ld.
Repro
shared.h
a.cpp
b.cpp
mid.cpp
main.cpp
compile/link
shell script to run repro
Possible fix
This patch fixes the issue: