Skip to content

Commit 3730d23

Browse files
authored
fix(build): macOS test binaries link the toolchain's own libc++ (A1) + unpin llvm (#202)
* fix(build): macOS test binaries link the toolchain's own libc++ (A1 root fix) TestBinary previously linked the SYSTEM -lc++ while compiling against the toolchain's libc++ HEADERS — a header/dylib version split that detonated when libc++ 22 moved string hashing out of line (__hash_memory undefined against Apple's older dylib; every gtest link on macOS+llvm-22.1.8 failed). Tests are host-only by definition, so an rpath into the toolchain registry is acceptable for them in a way it isn't for distributables. ldStdlibTest becomes: -nostdlib++ -L<llvm>/lib -lc++ -lc++abi -Wl,-rpath,<llvm>/lib (when the toolchain ships a libc++ dylib; system -lc++ fallback otherwise). Same-version headers and dylib by construction — future libc++ out-of-lining is self-consistent. Dynamic teardown also dissolves the static-destruction SIGABRT that originally motivated the system-lib exception. Distributables keep the static -load_hidden LLVM libc++. ci-macos returns to latest-first llvm install (the 20.1.7 pin's exit criterion): the job now PROVES each new llvm default self-consistent. New e2e 94_macos_test_stdlib (requires: macos): unordered_map<string> hashing under mcpp test + otool -L asserts no /usr/lib/libc++ reference. Design: .agents/docs/2026-07-08-root-cause-remediation-design.md A1. * fix(build): drop explicit -lc++abi from the test stdlib link libc++.1.dylib reexports its abi; linking libc++abi.dylib explicitly alongside the system abi (loaded transitively via macOS frameworks) doubled the __cxa_* runtime state — every test binary aborted before main (exit 6) on the first macOS CI round. * fix(build): restore -lc++abi (this llvm's libc++ doesn't reexport abi); add macOS failure forensics Round 2 proved the abi symbols are NOT reexported by this distribution's libc++.dylib (undefined __cxa_*/operator new at link). Round 1's runtime SIGABRT therefore has a different cause — the new forensics step captures otool -L, LC_RPATHs and a direct binary run on failure so the next round names it. * ci(macos): sandbox uses LLVM_ROOT's exact version; forensics captures crash reports The version-glob pick chose a stale cached 20.1.7 beside the freshly installed 22.1.8 — rounds 1-3 never actually tested the new llvm. Also capture the newest .ips crash report after a direct failing run (the SIGABRT is silent on stderr). * build(macos): toolchain pin llvm@20.1.7 -> 22.1.8; forensics extracts crash termination Rounds 1-4 never tested llvm 22: mcpp.toml's macos pin kept resolving 20.1.7 (whose lib/ dylibs silently SIGABRT at load — they were never exercised in the static-archive era). Pin the current llvm so mcpp's own macOS build+tests run on it; forensics now parses the .ips crash report (termination + triggered-thread frames) instead of dumping raw JSON. * fix(build): link the toolchain libc++/abi dylibs by path (round-5 crash: dual libc++ states) Crash report named it: exit-time locale::~locale freeing a pointer malloc never allocated — two libc++ copies in one process, from -l resolution mixing the sibling libc++.a into the link. By-path dylib linking removes the ambiguity. Forensics now also counts statically embedded libc++ text symbols (nm) to prove the binary is archive-free. * fix(build): test binaries use the same static -load_hidden libc++ as distributables Forensics rounds 5-6 closed the dynamic route: this llvm distribution's libc++abi/libunwind dylibs upward-link /usr/lib/libc++, so the system libc++ ALWAYS loads beside the toolchain's — gtest's static initializers then construct a stringstream in one copy and destroy it in the other (BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED in locale::~locale). Static hidden archives keep exactly one libc++ inside the binary — the shape mcpp's own shipped binaries have used green for months. If the historical gtest exit-teardown SIGABRT resurfaces it will now be caught by this same CI lane with forensics in place. * docs(remediation): A1 addendum — forensics-driven revision + xim llvm dylib self-containment item * test(e2e): 94 asserts 'N passed; 0 failed' — mcpp new's template adds test_smoke (2 passed)
1 parent e99beb6 commit 3730d23

6 files changed

Lines changed: 151 additions & 15 deletions

File tree

.agents/docs/2026-07-08-root-cause-remediation-design.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,33 @@ dylib reference at all).
5252
`xlings install llvm -y` first); delete the "TestBinary → system -lc++"
5353
comment block. LLVM 22.1.8 becomes usable on macOS.
5454

55+
### A1 addendum — evidence-driven revision (CI forensics, rounds 1–7)
56+
57+
The dynamic-dylib design above was falsified by crash-report forensics:
58+
this llvm distribution's libc++abi/libunwind dylibs upward-link
59+
/usr/lib/libc++, so the SYSTEM libc++ always loads beside the toolchain's
60+
(.ips image list), and gtest's initializers constructed a stringstream in
61+
one copy and destroyed it in the other
62+
(BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED at locale::~locale).
63+
64+
Process invariant: exactly one ACTIVE libc++ state per process. Options:
65+
system -lc++ (the original workaround — falsified by __hash_memory);
66+
toolchain dylib + rpath (falsified above); **static -load_hidden archives
67+
— adopted**: the already-proven distributable model, now uniform across
68+
ALL mcpp-built binaries (tests were the only exception, and the exception
69+
was the landmine); fixing the distribution itself — see below.
70+
71+
**Ecosystem root fix (new item, xim llvm packaging)**: rewrite the
72+
packaged lib/*.dylib install names/deps at packaging time (@rpath
73+
inter-references, no /usr/lib/libc++ upward link) so the dylib set is
74+
self-contained. Owned by the xlings-res/llvm builder; the C.1 consumer
75+
smoke gate keeps non-self-contained packages out of the index. Until
76+
then, dynamic toolchain-libc++ linking stays unsupported on macOS.
77+
78+
Also part of the exit criteria: mcpp.toml's own macos toolchain pin moves
79+
with the current llvm (20.1.7 → 22.1.8) so mcpp builds itself on what
80+
users get.
81+
5582
## A2 — GCC 15 module instantiation: raise the cross-toolchain floor (repos `xlings-res` packaging + `mcpp` CI)
5683

5784
**Defect anchor**: `src/manifest/types.cppm` `force_template_instantiations()`

.github/workflows/ci-macos.yml

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@ jobs:
5858
5959
- name: Install LLVM via xlings
6060
run: |
61-
# Pin 20.1.7: llvm 22.1.8's libc++ headers reference __hash_memory,
62-
# absent from the libc++ the link resolves on macOS (ld64.lld
63-
# undefined symbol; hermetic-link follow-up tracks the real fix).
64-
xlings install llvm@20.1.7 -y || xlings install llvm -y
61+
# latest-first: test binaries now link the toolchain's own libc++
62+
# (A1 root fix in flags.cppm), so new llvm releases are
63+
# self-consistent — this job is the proof for each new default.
64+
xlings install llvm -y || xlings install llvm@20.1.7 -y
6565
# Verify clang++ is available
6666
LLVM_ROOT=$(find "$HOME/.xlings" -path "*/xpkgs/xim-x-llvm/*/bin/clang++" | head -1 | xargs dirname | xargs dirname)
6767
echo "LLVM_ROOT=$LLVM_ROOT"
@@ -286,11 +286,10 @@ jobs:
286286
287287
- name: Configure dev mcpp sandbox to reuse xlings LLVM
288288
run: |
289-
# Discover whatever LLVM version `xlings install llvm` provided —
290-
# hardcoding it (20.1.7) broke the job the day xlings bumped its
291-
# default to 22.1.8.
292-
LLVM_PKG=$(ls -d "$HOME/.xlings/data/xpkgs/xim-x-llvm"/*/ 2>/dev/null | head -1)
293-
LLVM_PKG="${LLVM_PKG%/}"
289+
# Use EXACTLY the LLVM the install step resolved (env LLVM_ROOT) —
290+
# a version-glob pick chose a stale cached 20.1.7 next to the
291+
# freshly installed 22.1.8 and silently tested the wrong toolchain.
292+
LLVM_PKG="$LLVM_ROOT"
294293
test -d "$LLVM_PKG"
295294
LLVM_VER=$(basename "$LLVM_PKG")
296295
echo "MCPP_LLVM_VER=$LLVM_VER" >> "$GITHUB_ENV"
@@ -339,6 +338,42 @@ jobs:
339338
"$MCPP" self config --mirror GLOBAL
340339
"$MCPP" test
341340
341+
- name: Forensics — test-binary link + load state (on failure)
342+
if: failure()
343+
run: |
344+
BIN=$(find target -path "*/bin/test_manifest" | head -1)
345+
echo "binary: $BIN"
346+
[ -n "$BIN" ] || exit 0
347+
echo "--- otool -L ---"; otool -L "$BIN" || true
348+
echo "--- rpaths ---"; otool -l "$BIN" | grep -A2 LC_RPATH || true
349+
echo "--- statically embedded libc++? ---"
350+
nm "$BIN" 2>/dev/null | grep -cE "T __ZNSt3__1" || echo "0 (good: no libc++ code in binary)"
351+
echo "--- direct run ---"
352+
set +e
353+
"$BIN" > run.out 2>&1
354+
echo "exit=$?"
355+
head -20 run.out
356+
sleep 5
357+
echo "--- newest crash report (termination) ---"
358+
CR=$(ls -t "$HOME/Library/Logs/DiagnosticReports"/*.ips 2>/dev/null | head -1)
359+
if [ -n "$CR" ]; then
360+
python3 - "$CR" <<'PY'
361+
import json, sys
362+
lines = open(sys.argv[1]).read().splitlines()
363+
meta = json.loads(lines[0]); body = json.loads("\n".join(lines[1:]))
364+
print("proc:", meta.get("app_name"), "| exc:", body.get("exception", {}))
365+
print("termination:", body.get("termination", {}))
366+
t = [th for th in body.get("threads", []) if th.get("triggered")]
367+
for fr in (t[0].get("frames", [])[:12] if t else []):
368+
print(" ", fr.get("imageIndex"), fr.get("symbol", fr.get("imageOffset")))
369+
imgs = body.get("usedImages", [])
370+
for i, im in enumerate(imgs[:12]):
371+
print("img", i, im.get("path"))
372+
PY
373+
else
374+
echo "none"
375+
fi
376+
342377
- name: E2E suite
343378
# See ci-linux.yml — fail-fast on hung tests instead of burning the
344379
# whole job budget. Per-test 600s timeout lives in run_all.sh.

mcpp.toml

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

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

2424
# Per-target overrides: `mcpp build --target x86_64-linux-musl` (or the

src/build/flags.cppm

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ struct CompileFlags {
3535
std::string linkage; // "static" or ""
3636
// macOS per-unit C++ stdlib link (appended via unit_ldflags):
3737
// distributable targets get the static LLVM libc++ (portable across
38-
// macOS versions), TestBinary targets get the system -lc++ — they
39-
// only ever run on the build host, and statically linked libc++
40-
// SIGABRTs during static destruction unless the entry point guards
41-
// with _Exit (mcpp/xlings do; gtest main does not). Empty on other
38+
// macOS versions); TestBinary targets get the toolchain's own libc++
39+
// DYNAMICALLY (-L + -lc++ + rpath into the toolchain) — host-only
40+
// binaries, so the rpath is fine, and it keeps headers and dylib the
41+
// same version (the system -lc++ they used before was a version split
42+
// that broke on libc++ 22's out-of-line __hash_memory). Empty on other
4243
// platforms (stdlib handled by their existing paths).
4344
std::string ldStdlibDefault;
4445
std::string ldStdlibTest;
@@ -385,6 +386,30 @@ CompileFlags compute_flags(const BuildPlan& plan) {
385386
+ " -Wl,-load_hidden," + escape_path(libcxxAbiA);
386387
}
387388
}
389+
// TestBinary: SAME static -load_hidden libc++ as distributables.
390+
// Tests previously took the SYSTEM -lc++ while compiling against the
391+
// toolchain's libc++ HEADERS — a header/dylib version split that
392+
// detonated when libc++ 22 moved string hashing out of line
393+
// (undefined __hash_memory, 2026-07-08). The dynamic alternative
394+
// (toolchain libc++.dylib + rpath) is a dead end with this
395+
// distribution: its abi/unwind dylibs upward-link /usr/lib/libc++,
396+
// so the SYSTEM libc++ still loads next to the toolchain's and
397+
// gtest's initializers freed across the two copies
398+
// (BUG_IN_CLIENT_OF_LIBMALLOC, CI crash forensics rounds 5-6).
399+
// Static hidden archives keep exactly ONE libc++, inside the
400+
// binary — the same already-proven shape mcpp/xlings ship with.
401+
// Design: .agents/docs/2026-07-08-root-cause-remediation-design.md A1.
402+
if (!llvmRootForStdlib.empty()) {
403+
auto libDir = llvmRootForStdlib / "lib";
404+
auto libcxxA = libDir / "libc++.a";
405+
auto libcxxAbiA = libDir / "libc++abi.a";
406+
if (std::filesystem::exists(libcxxA)
407+
&& std::filesystem::exists(libcxxAbiA)) {
408+
f.ldStdlibTest = " -nostdlib++"
409+
" -Wl,-load_hidden," + escape_path(libcxxA)
410+
+ " -Wl,-load_hidden," + escape_path(libcxxAbiA);
411+
}
412+
}
388413
std::string version_min;
389414
if (!macosDeploymentTarget.empty()) {
390415
version_min = " -mmacosx-version-min=" + macosDeploymentTarget;

tests/e2e/94_macos_test_stdlib.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
# requires: macos
3+
# 94_macos_test_stdlib.sh — A1 regression: test binaries must link the
4+
# TOOLCHAIN's libc++, not the system one. The old system -lc++ exception
5+
# split headers (toolchain libc++) from the dylib (Apple's older libc++)
6+
# and broke on libc++ 22's out-of-line __hash_memory — exercised here via
7+
# std::unordered_map<std::string,...> string hashing.
8+
# Design: .agents/docs/2026-07-08-root-cause-remediation-design.md A1.
9+
set -e
10+
11+
TMP=$(mktemp -d)
12+
trap "rm -rf $TMP" EXIT
13+
cd "$TMP"
14+
15+
"$MCPP" new hashapp > /dev/null
16+
cd hashapp
17+
18+
mkdir -p tests
19+
cat > tests/hash_test.cpp <<'EOF'
20+
// Forces the string-hash path (__hash_memory-class symbols on libc++ >= 22).
21+
import std;
22+
23+
int main() {
24+
std::unordered_map<std::string, int> m;
25+
for (int i = 0; i < 100; ++i) m["key" + std::to_string(i)] = i;
26+
return (m.size() == 100 && m.at("key42") == 42) ? 0 : 1;
27+
}
28+
EOF
29+
30+
"$MCPP" test > test.log 2>&1 || { cat test.log; echo "FAIL: mcpp test failed"; exit 1; }
31+
grep -Eq "[0-9]+ passed; 0 failed" test.log # template ships its own test_smoke too
32+
33+
# The link assertion only applies to clang/llvm toolchains (gcc on macOS is
34+
# not a supported config; if resolution picked something else, the behavior
35+
# test above already covered the regression).
36+
TEST_BIN=$(find target -path "*/bin/hash_test" | head -1)
37+
test -x "$TEST_BIN"
38+
if grep -q "Resolved llvm@" test.log || otool -L "$TEST_BIN" | grep -q "libc++"; then
39+
echo "otool -L:"
40+
otool -L "$TEST_BIN"
41+
if otool -L "$TEST_BIN" | grep -q "/usr/lib/libc++"; then
42+
echo "FAIL: test binary links the SYSTEM libc++ (header/dylib split)"
43+
exit 1
44+
fi
45+
# Either the toolchain's dylib (rpath link) or no libc++ dylib at all
46+
# (static fallback) is acceptable — never Apple's.
47+
fi
48+
49+
echo "PASS 94_macos_test_stdlib"

tests/e2e/run_all.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ case "$OS" in
5858
fi
5959
;;
6060
Darwin)
61-
CAPS+=(unix-shell fresh-sandbox)
61+
CAPS+=(unix-shell fresh-sandbox macos)
6262
# macOS g++ is Apple Clang, not real GCC — don't add gcc capability.
6363
# Tests requiring gcc need actual GNU GCC (modules, gcm.cache, etc.)
6464
;;

0 commit comments

Comments
 (0)