Skip to content

Commit b87a438

Browse files
committed
fix(toolchain): PayloadFirst artifacts had nowhere to find libgcc_s
The diagnostic added in the previous commit answered it on the first run: error while loading shared libraries: libgcc_s.so.1 RUNPATH: <home>/data/xpkgs/xim-x-glibc/2.44/lib64 <- one entry ldd: libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 The compiler's own runtime lives beside the compiler, not in the C library, and a produced binary links it whether or not the build mentions it. gcc's patched specs used to put that directory on the artifact; removing the specs rewrite has to put it back, and only the Sysroot path did. One helper now serves both modes. Invisible where it was written: on any machine with a host toolchain the artifact resolves libgcc_s.so.1 from /lib and runs. It failed only in a throw-away home on CI. The guard is a unit test, not an e2e. Which link mode an e2e reaches depends on whether the machine happens to have a usable sysroot -- this one does, so a PayloadFirst leg written for exactly this defect quietly ran Sysroot twice and passed with the bug in place. Verified by removing the fix: the unit test goes red, the e2e leg does not. The link model takes its mode from its input, so a test can name the mode instead of hoping for it. e2e 201 also now asserts that no artifact loads a library from /lib or /usr/lib at all. "Does it run" was the wrong question -- it is answerable yes on every developer machine while the artifact is quietly reaching outside the sandbox. tests/unit/test_link_model_runtime_dirs.cpp +4 (67 unit tests total)
1 parent 938b7d8 commit b87a438

4 files changed

Lines changed: 230 additions & 8 deletions

File tree

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ default-profile = "release"
1717
include_dirs = ["src/libs/json"]
1818

1919
[toolchain]
20-
default = "gcc@16.1.0"
20+
default = "llvm@22.1.8"
2121
macos = "llvm@22.1.8"
2222
windows = "llvm@20.1.7"
2323

src/toolchain/linkmodel.cppm

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,35 @@ ToolchainLinkModel resolve_link_model(const Toolchain& tc) {
320320
// cross-compile resolves by what it builds FOR.
321321
if (is_msvc_target(tc) || is_mingw_target(tc)) return lm;
322322

323+
// The compiler's OWN runtime lives beside it, not in the C library:
324+
// libgcc_s.so.1 for GCC. A produced binary links it whether or not the
325+
// build ever mentions it, so its directory has to be on the artifact's
326+
// RUNPATH -- and gcc's patched specs used to put it there, which is
327+
// exactly why removing that rewrite has to put it back.
328+
//
329+
// Both modes need this, and only one got it at first. PayloadFirst
330+
// artifacts came out with a single RUNPATH entry (the glibc payload) and
331+
// resolved libgcc_s.so.1 from the HOST -- or, on a machine without one,
332+
// not at all: `error while loading shared libraries: libgcc_s.so.1`. This
333+
// developer machine has a usable sysroot, so every local build took the
334+
// other branch and the gap only surfaced on CI (e2e 29).
335+
//
336+
// Not for musl (self-contained sysroot, static world) and not for clang,
337+
// which brings compiler-rt and libunwind instead.
338+
auto add_compiler_runtime_dir = [&] {
339+
if (is_musl_target(tc) || lm.clangDriver || tc.binaryPath.empty()) return;
340+
std::error_code lec;
341+
auto gccLib = tc.binaryPath.parent_path().parent_path() / "lib64";
342+
if (std::filesystem::exists(gccLib, lec))
343+
lm.libDirs.push_back(gccLib);
344+
};
345+
323346
auto payload_first = [&] {
324347
auto& pp = *tc.payloadPaths;
325348
lm.mode = CLibMode::PayloadFirst;
326349
lm.crtDir = pp.glibcLib;
327350
lm.libDirs.push_back(pp.glibcLib);
351+
add_compiler_runtime_dir();
328352
lm.systemIncludes.push_back(pp.glibcInclude);
329353
if (!pp.linuxInclude.empty())
330354
lm.systemIncludes.push_back(pp.linuxInclude);
@@ -358,12 +382,7 @@ ToolchainLinkModel resolve_link_model(const Toolchain& tc) {
358382
lm.loader = resolve_loader(tc.payloadPaths->glibcLib, tc.targetTriple);
359383
lm.libDirs.push_back(tc.payloadPaths->glibcLib);
360384
}
361-
if (!is_musl_target(tc) && !tc.binaryPath.empty()) {
362-
std::error_code lec;
363-
auto gccLib = tc.binaryPath.parent_path().parent_path() / "lib64";
364-
if (std::filesystem::exists(gccLib, lec))
365-
lm.libDirs.push_back(gccLib);
366-
}
385+
add_compiler_runtime_dir();
367386
// Supplement kernel headers when the sysroot lacks them (glibc's
368387
// local_lim.h needs <linux/limits.h>). Self-contained musl sysroots
369388
// ship their own; a cross target must not see host-arch headers.

tests/e2e/201_gcc_no_specs_pollution.sh

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
# never something a build asked for.
2121
set -euo pipefail
2222

23+
SELF_DIR=$(cd "$(dirname "$0")" && pwd) # captured before any cd
2324
TMP=$(mktemp -d)
2425
trap 'rm -rf "$TMP"' EXIT
2526
export MCPP_HOME=$HOME/.mcpp
@@ -80,4 +81,84 @@ echo "$file_out" | grep -q 'interpreter .*xim-x-glibc' || {
8081
out=$("$bin" 2>&1) || { echo "binary did not run: $out"; exit 1; }
8182
echo "$out" | grep -q 'Hello' || { echo "unexpected output: $out"; exit 1; }
8283

83-
echo "PASS: gcc artifact carries only the RUNPATH this build asked for"
84+
# 4. Nothing loads from the host.
85+
#
86+
# "Does it run" is not this question, and answering the wrong one is how a
87+
# real gap survived: with the compiler's own lib dir missing from RUNPATH,
88+
# artifacts resolved libgcc_s.so.1 from /lib and ran perfectly on any machine
89+
# that has a host toolchain -- which is every developer machine. It failed
90+
# only on CI's throw-away home, and only as `error while loading shared
91+
# libraries`, with no indication of what had gone missing or why.
92+
#
93+
# Asking where each library actually came from makes the same defect visible
94+
# everywhere, host toolchain or not.
95+
if command -v ldd > /dev/null 2>&1; then
96+
host_libs=$(ldd "$bin" 2>/dev/null \
97+
| grep -E '=> +(/lib|/usr/lib|/lib64|/usr/lib64)/' || true)
98+
if [[ -n "$host_libs" ]]; then
99+
echo "the artifact loads libraries from the host:"
100+
echo "$host_libs" | sed 's/^/ /'
101+
echo "Every shared library a payload build needs is in the payload;"
102+
echo "reaching outside it means a directory is missing from RUNPATH."
103+
exit 1
104+
fi
105+
fi
106+
107+
# ── The same assertions again, in the OTHER link mode ────────────────────
108+
#
109+
# RUNPATH is built by two separate paths -- CLibMode::Sysroot and
110+
# CLibMode::PayloadFirst -- and a machine only ever takes one of them. This
111+
# one has a usable sysroot, so everything above ran through Sysroot mode and
112+
# PayloadFirst went untested here for as long as this file has existed.
113+
#
114+
# It was not academic: PayloadFirst omitted the compiler's own lib dir, so
115+
# artifacts resolved libgcc_s.so.1 from the host. Invisible on any machine
116+
# with a host toolchain; on CI's throw-away home it was `error while loading
117+
# shared libraries` with no further explanation.
118+
#
119+
# A home with payloads but no subos usually has no sysroot to find, which is
120+
# what selects PayloadFirst -- but "usually" is the honest word: where gcc
121+
# reports a baked sysroot that happens to exist (another checkout on the same
122+
# machine), mcpp accepts it as a last resort and this leg runs Sysroot mode a
123+
# second time instead. Which is what it does on the machine these words were
124+
# written on.
125+
#
126+
# So the deterministic guard for this invariant is the unit test
127+
# (tests/unit/test_link_model_runtime_dirs.cpp), which names the mode rather
128+
# than arranging for it. This leg is still worth running: it covers a second
129+
# home shape end to end, and on CI -- where the baked path does not exist --
130+
# it is the real thing.
131+
export MCPP_HOME="$TMP/payload-first-home"
132+
MCPP_INHERIT_CONFIG=0 MCPP_INHERIT_SUBOS=0 \
133+
source "$SELF_DIR/_inherit_toolchain.sh"
134+
135+
cd "$TMP"
136+
"$MCPP" new pf > /dev/null
137+
cd pf
138+
cat >> mcpp.toml <<'EOF'
139+
140+
[toolchain]
141+
linux = "gcc@16.1.0"
142+
EOF
143+
144+
"$MCPP" build > "$TMP/pf.log" 2>&1 || {
145+
cat "$TMP/pf.log"; echo "payload-first build failed"; exit 1; }
146+
147+
pfbin=$(find target -type f -name pf -path '*/bin/*' | head -1)
148+
[[ -n "$pfbin" ]] || { echo "no payload-first binary produced"; exit 1; }
149+
150+
pf_host=$(ldd "$pfbin" 2>/dev/null \
151+
| grep -E '=> +(/lib|/usr/lib|/lib64|/usr/lib64)/' || true)
152+
if [[ -n "$pf_host" ]]; then
153+
echo "the payload-first artifact loads libraries from the host:"
154+
echo "$pf_host" | sed 's/^/ /'
155+
echo "RUNPATH was:"
156+
"$readelf" -d "$pfbin" 2>/dev/null | grep -iE 'RUNPATH|RPATH' | sed 's/^/ /'
157+
exit 1
158+
fi
159+
160+
"$pfbin" > /dev/null 2>&1 || {
161+
echo "payload-first binary did not run: $("$pfbin" 2>&1 | head -2)"; exit 1; }
162+
163+
echo "PASS: gcc artifacts carry only the RUNPATH the build asked for and load"
164+
echo " nothing from the host — in both sysroot and payload-first modes"
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// An artifact must find every library it needs inside the sandbox — in BOTH
2+
// link modes.
3+
//
4+
// RUNPATH is assembled by two separate paths, CLibMode::Sysroot and
5+
// CLibMode::PayloadFirst, and any given machine only ever takes one of them.
6+
// The compiler's own runtime (libgcc_s.so.1) lives beside the compiler, not in
7+
// the C library, and a produced binary links it whether or not the build ever
8+
// mentions it. gcc's patched specs used to supply that directory; removing the
9+
// specs rewrite therefore has to supply it explicitly — and at first only the
10+
// Sysroot path did.
11+
//
12+
// The consequence was invisible where it was written: on a machine with a host
13+
// toolchain the artifact simply resolved libgcc_s.so.1 from /lib and ran. It
14+
// failed only in a throw-away home on CI, as `error while loading shared
15+
// libraries` with nothing said about what was missing.
16+
//
17+
// Asserted here rather than in e2e on purpose. Which mode an e2e reaches
18+
// depends on whether the machine happens to have a usable sysroot — this one
19+
// does, so an e2e leg written for PayloadFirst quietly ran Sysroot twice and
20+
// passed with the defect in place. The link model takes its mode from its
21+
// input, so a test can name the mode instead of hoping for it.
22+
23+
#include <gtest/gtest.h>
24+
25+
import std;
26+
import mcpp.toolchain.linkmodel;
27+
import mcpp.toolchain.model;
28+
29+
namespace tc = mcpp::toolchain;
30+
31+
namespace {
32+
33+
void touch(const std::filesystem::path& p) {
34+
std::filesystem::create_directories(p.parent_path());
35+
std::ofstream(p) << "x";
36+
}
37+
38+
// A payload tree with a glibc beside a gcc, as xlings lays them out.
39+
struct Payload {
40+
std::filesystem::path root, compiler, glibcLib, gccLib, sysroot;
41+
Payload() {
42+
root = std::filesystem::temp_directory_path()
43+
/ std::format("mcpp_linkmodel_{}", std::random_device{}());
44+
auto xpkgs = root / "data" / "xpkgs";
45+
compiler = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++";
46+
touch(compiler);
47+
gccLib = xpkgs / "xim-x-gcc" / "16.1.0" / "lib64";
48+
touch(gccLib / "libgcc_s.so.1");
49+
glibcLib = xpkgs / "xim-x-glibc" / "2.39" / "lib64";
50+
touch(glibcLib / "libc.so.6");
51+
touch(glibcLib / "ld-linux-x86-64.so.2");
52+
touch(xpkgs / "xim-x-glibc" / "2.39" / "include" / "features.h");
53+
sysroot = root / "subos" / "default";
54+
touch(sysroot / "usr" / "include" / "stdlib.h");
55+
}
56+
~Payload() { std::error_code ec; std::filesystem::remove_all(root, ec); }
57+
58+
tc::Toolchain toolchain(bool withSysroot) const {
59+
tc::Toolchain t;
60+
t.compiler = tc::CompilerId::GCC;
61+
t.version = "16.1.0";
62+
t.binaryPath = compiler;
63+
t.targetTriple = "x86_64-linux-gnu";
64+
t.runtimeBinding = "glibc@2.39";
65+
tc::PayloadPaths pp;
66+
pp.glibcLib = glibcLib;
67+
pp.glibcInclude = glibcLib.parent_path() / "include";
68+
t.payloadPaths = pp;
69+
if (withSysroot) t.sysroot = sysroot;
70+
return t;
71+
}
72+
};
73+
74+
std::string joined(const tc::ToolchainLinkModel& lm) {
75+
auto id = [](const std::filesystem::path& p) { return p.string(); };
76+
std::string s;
77+
for (auto& tok : lm.link_tokens(id)) { s += tok; s += ' '; }
78+
return s;
79+
}
80+
81+
TEST(LinkModelRuntimeDirs, PayloadFirstCarriesTheCompilerRuntime) {
82+
Payload p;
83+
auto lm = tc::resolve_link_model(p.toolchain(/*withSysroot=*/false));
84+
ASSERT_EQ(lm.mode, tc::CLibMode::PayloadFirst);
85+
auto line = joined(lm);
86+
EXPECT_NE(line.find("-Wl,-rpath," + p.gccLib.string()), std::string::npos)
87+
<< "libgcc_s.so.1 lives here and nothing else will find it:\n" << line;
88+
}
89+
90+
TEST(LinkModelRuntimeDirs, SysrootCarriesTheCompilerRuntime) {
91+
Payload p;
92+
auto lm = tc::resolve_link_model(p.toolchain(/*withSysroot=*/true));
93+
ASSERT_EQ(lm.mode, tc::CLibMode::Sysroot);
94+
auto line = joined(lm);
95+
EXPECT_NE(line.find("-Wl,-rpath," + p.gccLib.string()), std::string::npos)
96+
<< line;
97+
}
98+
99+
// The C library's own directory, in both modes, for the same reason.
100+
TEST(LinkModelRuntimeDirs, BothModesCarryTheCLibrary) {
101+
Payload p;
102+
for (bool withSysroot : {false, true}) {
103+
auto line = joined(tc::resolve_link_model(p.toolchain(withSysroot)));
104+
EXPECT_NE(line.find("-Wl,-rpath," + p.glibcLib.string()),
105+
std::string::npos)
106+
<< "withSysroot=" << withSysroot << "\n" << line;
107+
}
108+
}
109+
110+
// And the interpreter. A sysroot says where headers live; it says nothing
111+
// about which loader runs the result, and gcc's `*link:` no longer answers.
112+
TEST(LinkModelRuntimeDirs, BothModesNameTheInterpreter) {
113+
Payload p;
114+
for (bool withSysroot : {false, true}) {
115+
auto line = joined(tc::resolve_link_model(p.toolchain(withSysroot)));
116+
EXPECT_NE(line.find("--dynamic-linker=" + p.glibcLib.string()),
117+
std::string::npos)
118+
<< "withSysroot=" << withSysroot << "\n" << line;
119+
}
120+
}
121+
122+
} // namespace

0 commit comments

Comments
 (0)