Skip to content

fix(build): macOS test binaries link the toolchain's own libc++ (A1) + unpin llvm - #202

Merged
Sunrisepeak merged 9 commits into
mainfrom
fix/macos-test-libcxx
Jul 8, 2026
Merged

fix(build): macOS test binaries link the toolchain's own libc++ (A1) + unpin llvm#202
Sunrisepeak merged 9 commits into
mainfrom
fix/macos-test-libcxx

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Root-cause fix for the llvm 22.1.8 __hash_memory link failure. ci-macos in this PR runs with llvm latest (22.1.8) — its e2e suite (incl. new 94_macos_test_stdlib) IS the proof. See .agents/docs/2026-07-08-root-cause-remediation-design.md §A1.

…oot 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.
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.
…); 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.
… 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).
… 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.
…sh: 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.
…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.
@Sunrisepeak
Sunrisepeak merged commit 3730d23 into main Jul 8, 2026
6 checks passed
@Sunrisepeak
Sunrisepeak deleted the fix/macos-test-libcxx branch July 8, 2026 16:28
Sunrisepeak added a commit that referenced this pull request Jul 8, 2026
…(A1), cross gcc16 floor (A2/R5a) (#203)

Version bump + remediation-doc live status. Ships on top of the merged
PRs #201 (verified gated mirroring) and #202 (static libc++ for test
binaries, llvm 22.1.8 green); this branch adds the aarch64 cross floor:
<triple>-gcc@16.1.0 (xim entry #344), retiring GCC 15 from the cross
path. The manifest instantiation anchor REMAINS for the x86_64 musl
release build (musl-gcc 15.1.0) until R5b packages its gcc 16.
speak-agent added a commit that referenced this pull request Aug 2, 2026
* fix(build): make the C++ runtime contract one decision (#336)

`[build] static_stdlib = false` was silently ignored for test binaries from
0.0.86 to 2026.8.2.2 while docs/05-mcpp-toml.md kept documenting the opt-out.
The cause is structural: "does this artifact carry its own C++ runtime" was
derived independently in five places — ldStdlibDefault, ldStdlibTest, the
-static-libstdc++ string, the MinGW -static branch, and the
LinkUnit::TestBinary two-way switch in the ninja emitter — and #202's new
semantics landed in some of them and not the others.

Replaced by a three-layer model in src/build/distribution.cppm:

  Role      intrinsic to the link unit (test binaries run here and are
            discarded; archives embed no runtime at all)
  Contract  what the artifact promises about the machine that runs it
  Mechanism (contract x stdlib x binary format) -> flags, as a TOTAL function

Totality is the property that matters: every cell answers, and a cell that
cannot honor what was asked returns `degraded` plus a diagnostic the backend
must print. That turns three silent downgrades into reported ones — including
Linux + clang/libc++, where `static_stdlib = true` emitted no flag at all and
shipped a toolchain-coupled artifact while the manifest, the docs and the
build output all called it self-contained. It now links libc++.a/libc++abi.a/
libunwind.a for real (NEEDED drops to libc/libm/loader).

Also fixes the crash that made the gap visible. On macOS a global object whose
constructor touches std::cout SIGSEGVs at process start under the default
contract: Mach-O runs __init_offsets in link order and has no priority-ordered
init section, so the stream initializer pulled out of libc++.a lands last, and
libc++'s <iostream> has no ios_base::Init guard of its own (libstdc++ and the
MSVC STL do, which is why only macOS breaks). Nor could package code work
around it — std::ios_base::Init is only forward-declared in libc++'s headers,
so the standard's own remedy is unavailable there. mcpp now links a generated
C object first whose constructor calls ios_base::Init::Init(); the reference is
weak, so a toolchain spelling that symbol differently links exactly as before.

New surface: [build] cxx_runtime = "self-contained" | "toolchain-coupled" |
"host-coupled", per role via { default, tests } and per triple via
[target.<triple>].cxx_runtime — beside `linkage`, which is the same axis.
static_stdlib stays a faithful alias.

Analysis: .agents/docs/2026-08-02-issue336-pr142-analysis.md
Unblocks: mcpplibs/mcpp-index#142

* fix(build): a platform limit nobody asked about is not a diagnostic

The MSVC runtime has no self-contained mechanism at all (no /MT emission),
so the default contract degraded on EVERY Windows build and printed a warning
nobody could act on. A diagnostic is for a broken promise — mcpp said the
artifact would be self-contained and it is not. Where mcpp never made the
promise, the cell now stays quiet unless the contract was written down
explicitly. Cells that DO promise something (a missing libc++.a under the
default) still report regardless.

* fix(build): the macOS ordering shim must not be able to break the link

Two Mach-O facts the first CI round found the hard way:

  * an __asm__ label is used VERBATIM — clang does not prepend Mach-O's
    global '_'. The C++ symbol _ZNSt3__18ios_base4InitC1Ev therefore has to
    be written __ZNSt3__18ios_base4InitC1Ev, and getting it wrong is not a
    silent no-op: every macOS link failed with
    'undefined symbol: ZNSt3__18ios_base4InitC1Ev'.

  * plain __attribute__((weak)) on a declaration is NOT Mach-O's
    weak-undefined form, so it did not make the bad reference optional.
    weak_import is.

Both are now correct, but neither is the safety net. The backend only
generates the shim TU when the libc++ archive actually defines the symbol —
checked by scanning the ranlib index in the archive's first member, no
subprocess — so an unexpected libc++ spelling disables the ordering aid and
says so, instead of failing the build. A check upstream of the reference
cannot break a link the way the reference itself can.

* docs: record what shipped and what the CI rounds corrected (#336)

---------

Co-authored-by: sunrisepeak <speakshen@163.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant