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>
Sunrisepeak added a commit that referenced this pull request Aug 11, 2026
`mcpp run` 一个 imgui/GLFW 工程,链接 rc=0,运行即死:

    undefined symbol: _ZNKSt13runtime_error4whatEv

## 缺陷一:`$ORIGIN` 被 SubOS 库视图遮蔽

2026.8.11.2(#413)首次把 SubOS 库视图(farm)写进产物 DT_RPATH,但它落在
`$ORIGIN` **之前**。而这两个目录在本生态里天然装着同名 SONAME —— mcpp 从
`compat.x11` 源码构建 `libX11.so` 部署到产物目录,xlings 又在 farm 里有
`xim:libX11`。于是**链接期用 A,运行期加载 B**。

真因不是"放错位置",而是**一条链接命令行的顺序由两个互不知情的生产者用 `+=`
决定**:`flags.cppm` 把 farm 拼进全局 ldflags(注释还写着 "so it is LAST"),
`plan.cppm` 把 `$ORIGIN` 拼进 per-unit,而链接规则渲染的是
`$ldflags $unit_ldflags`。三处各自都对,合起来是错的。

新增 `mcpp.build.link_line`:把 per-unit 尾部声明成**具名槽位**
(dependencies → cxxRuntime → runtimeFallback → loaderTag),相对顺序写在类型
里、由单测钉死。新增一个生产者必须先选一个槽 —— "选"正是"在产物自己的目录之前
还是之后"这个问题被提出来的地方。格式中立:槽位按职责命名,PE 的两个槽天然为空,
Mach-O 的 dependencies 装 `@loader_path`,没有任何 `if (platform)`。

## 缺陷二:共享库把自己的 C++ 运行时导出给了别人(ELF)

`SharedLibrary` 与可执行文件共用 `Distributable` 角色,于是拿到同一份
self-contained 契约:`-static-libstdc++`。ELF 上这不是"私有一份" —— 只有一个全局
符号命名空间,共享对象导出它定义的每一个全局符号。一个**纯 C** 的 compat 包因此
导出了 777 个 GLOBAL 标准库定义(`libXau.so`:39KB 的 Xau + 9.5MB 的 libstdc++)。

可执行文件链接时 `-lX11` 排在驱动的 `-lstdc++` 之前,ld 用它满足了
`runtime_error::what()`,归档成员从不拉入 —— **exe 的 `-static-libstdc++` 变成
空操作,它的 C++ 运行时事实上是那个 `.so`**。缺陷一之所以致命,根源在这里。

共享库默认契约改为**按目标格式分档**:

  ELF     toolchain-coupled   一个全局命名空间,先加载的定义胜出
  Mach-O  self-contained      机制本就是 -load_hidden,dyld 不归一;
                              且 toolchain-coupled 在 macOS 是死路(#202)
  PE      self-contained      没有全局命名空间,导入按 DLL 逐个按名解析

**只有 ELF 的行为变了,而它正是有缺陷的那个**;Mach-O/PE 产物字节不变。
显式 `cxx_runtime = { shared = "self-contained" }` 仍可选回自包含,此时自动补
`-Wl,--exclude-libs`(实测:按归档基名匹配,与 `-l` 还是完整路径无关),
让内嵌的运行时留在动态符号表之外 —— 逃生舱不会重新打开这个洞。

## 顺带修掉的架构债

`dist::default_contract` 自称"the role -> contract policy, in one place",实际
**没有任何生产调用方** —— 真正的策略在 `flags.cppm` 被第二次推导。这正是
`distribution.cppm` 开篇声讨的那类债("derived independently in five places")
换个位置复发。现在它是唯一真源。

`cxx_runtime` 补进 `[build]` 已知键白名单:它此前会打印 "unsupported key
(ignored)" —— 而 "ignored" 是假的,`--strict` 还会直接拒绝 manifest。整个特性的
唯一入口不能一边工作一边说自己被忽略了。

## 测试

- 新增 `test_link_line`(6):槽位顺序、空槽不产生多余分隔、顺序与赋值序无关
- 新增 `NinjaBackend.SubosFarmRpathFollowsTheArtifactsOwnDirectory`:断言在
  **合成后的**链接行上 `$ORIGIN` 早于 farm(只看其中一个变量,正是原缺陷隐形的原因)
- `test_distribution` +8:(Role × Format) 全表、`--exclude-libs` 只给共享库
- `test_manifest` +3:`shared` 键、未知角色键报错、标量拼写仍覆盖所有角色
- e2e 219 断言由「farm 是最后一个**绝对路径**条目」收紧为「**字面**最后一项」,
  并补一条**行为**不变量(`LD_DEBUG=libs` 实测同名 SONAME 解析到 `$ORIGIN`)。
  旧断言把 `$ORIGIN` 过滤掉了,对坏顺序与好顺序给出同一个结论;测试工程也从
  `int main()` 换成消费依赖共享库 —— 否则它连 `$ORIGIN` 都不产生
- 新增 e2e 222:共享库不得导出 GLOBAL 标准库符号、必须 NEEDED libstdc++.so.6、
  exe 无未定义 std 符号、显式自包含时护栏仍在

**红测**(两条新 e2e 对已发布 2026.8.11.2):219 报「farm is not the last entry」,
222 报「exports 713 GLOBAL standard-library symbols」—— 均以正确理由失败。

**⚠️ 行为不变量不得依赖崩溃**:缺陷二修好后崩溃会消失,依赖崩溃的断言会立刻假绿。
219 的不变量 4 因此测的是加载器的搜索过程,不是程序的退出码。

## 实测(helloegui:imgui + GLFW + X11)

| 判据 | 修复前 | 修复后 |
|---|---|---|
| DT_RPATH 尾部 | `… : <subos>/lib : $ORIGIN` | `… : $ORIGIN : <subos>/lib` |
| `libX11.so.6` 解析到 | farm 里的 xim:libX11 1.8.10 | `$ORIGIN`(farm 未被试到) |
| `bin/libX11.so` 导出 std 符号 | 2931(777 GLOBAL) | 0 |
| `bin/libXau.so` | 9 557 936 B | 39 368 B |
| exe 未定义 `runtime_error::what` | 有 | 无 |
| 运行 | symbol lookup error | GUI 正常启动 |

分析:`.agents/docs/2026-08-11-runtime-search-origin-precedence-analysis.md`
计划:`.agents/docs/2026-08-11-origin-precedence-implementation-plan.md`

Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
Sunrisepeak pushed a commit that referenced this pull request Sep 7, 2026
上一条提交让 host_link_tokens 的「信任 cfg」出口也发 `-L<载荷>/lib`,理由是 macOS
上 `-lc++` 会解析到系统那份而头文件来自载荷。**动机是真的,修法是错的。**

那样做会让 `-lc++` 找到工具链自己的 dylib,也就是 `dist::mechanism_for` 在 Mach-O
上明确拒绝的 ToolchainCoupled:LLVM 的 macOS libc++abi 与 libunwind dylib **向上
链接** `/usr/lib/libc++`,系统 libc++ 与工具链的那份同时载入,跨两份释放的对象在
libmalloc 里 abort(#202)。CI 报的正是这条路的第一步 —— 图形示例的构建程序在
macOS 15 上链接停在 `__cxa_end_catch`、`std::runtime_error::~runtime_error()` 与
其余那些系统 libc++ 会再导出、载荷那份不会的 ABI 符号上。

**它被推翻的方式值得记**:本地全部单测绿,而判据是我自己写的那条正向断言;红在一个
我没想到会受影响的对象上,在一个我没在本地跑过的宿主上。

改回去,把这条不对称与它的理由写进代码,判据改成陈述这条决定
(`OnlyTheSpelledOutExitNamesTheToolchainRuntimeDirs`,用 `CfgBypass::Never` 让
那条在 Linux 上不可达的分支可测)。构建程序的缓存键随之回到 default-v2 —— 行为与
这两条提交之前完全一致。

限制照实写下来:**macOS 14 上构建程序不能用 `std::print` / `std::println`**,而
`std::format` 是 header-only 的、没有这个问题。本轮 mcpp:plugins 的六个模块因此全部
改用它,那一半是真的修好了。
Sunrisepeak added a commit that referenced this pull request Sep 7, 2026
一句话:**引擎与规则层早就与平台无关,而生态只在 Linux 上完整**;把规则包在另外两个
平台上真跑一遍,暴露了三处缺陷 —— 一处已修、一处**我的修复被实测推翻**、一处是使用者
会撞上的规则。

设计文档:`.agents/docs/2026-09-07-heterogeneous-cross-platform-ecosystem.md`(含 §10
实施回填)。

## 一、`examples/10-graphics/offscreen`:一条真实的图形管线,判据是像素

此前所有异构示例都是**计算**。这一个是**图形**:顶点与片段两个着色器阶段、一条 render
pass、`vkCmdDraw` 画一个三角形、`vkCmdCopyImageToBuffer` 取回像素。

**离屏,而不是开窗**,因为那是可断言的形态:无窗口、无交换链、无表面扩展,在没有显示器
的 runner 上跑得起来,而结果是一段可以逐字节检查的缓冲区。程序自己断言四角等于清除色、
中心不等于清除色、三个通道都非零、alpha 为 255。

同一道接缝背后是一个自己写的软件光栅器。**实测两条腿的中心像素逐字节相同**
(`(124, 70, 62, 255)`),所以 CI 的反向腿断言的是「两条腿报出同一个像素、不同的设备名」
—— 图像是契约,设备名是唯一区分它们的东西。

CI 还在 macOS 与 Windows 上各构建一次(不跑,那两台没有 Vulkan 设备):断言的是**这个
平台用的那个着色器编译器**产出两个 SPIR-V 头、Vulkan 那一半编译并链接得上 loader 包。

### 一条使用者会撞上的规则:**依赖不能被 layer 条件化**

`cfg(accelerator = ...)` 下的 `[build]` 源生效而**依赖被忽略**,于是包被丢掉、包含它的
源被留下,构建死在 `vulkan/vulkan.h: No such file or directory`。示例 README 把这一条
写成了正文。

## 二、版本约束里的 `>` 被 cmd.exe 读成重定向(Windows)—— 已修

    Provisioning [xlings.workspace] entries declared by dependencies (xim:shaderc@>=2026.3)
    The filename, directory name, or volume label syntax is incorrect.

`shell::quote` 回答的是子进程的 argv 解析,cmd 不认那个转义,于是走到 `>` 时引号数是
偶数。`>=` 正是每个规则包声明下界用的形态,而**在此之前没有任何一条能在 Windows 上生效
的声明带过 `>`**。

修法是标准双重转义。判据是两个解析器的模拟器,断言**子进程收到的参数等于本来要传的那个
JSON**,外加一条反向腿断言旧写法确实让 cmd 看见了一个活的元字符。端到端判据在
mcpp-plugins:它今天用精确版本绕开,这个修复发布后改回 `>=`,那条 Windows job 就是端到
端判据。

## 三、macOS 14 上构建程序用不了 `std::println` —— 记录,未修,而且是因为**修法更糟**

`std::print` 不是 header-only 的:它的支持符号在 libc++ **dylib** 里,而 macOS 14 那一版
没有。直觉的修法是让 `host_link_tokens` 的「信任 cfg」出口也发 `-L<载荷>/lib`。

**试过了,CI 推翻了它。** 那会让 `-lc++` 解析到工具链自己的 dylib,也就是
`dist::mechanism_for` 在 Mach-O 上明确拒绝的 ToolchainCoupled —— LLVM 的 macOS
libc++abi 与 libunwind dylib **向上链接** `/usr/lib/libc++`,系统 libc++ 与工具链的那份
同时载入,跨两份释放的对象在 libmalloc 里 abort(#202)。CI 报的正是这条路的第一步:
链接停在 `__cxa_end_catch` 与其余那些系统 libc++ 会再导出、载荷那份不会的 ABI 符号上。

所以改回去,把这条不对称与它的理由写进代码,判据从「断言修复」改成**陈述这条决定**
(`OnlyTheSpelledOutExitNamesTheToolchainRuntimeDirs`;为此给策略枚举补了
`CfgBypass::Never`,让那条在 Linux 上不可达的分支可测)。

**限制照实写:macOS 14 上构建程序不能用 `std::print` / `std::println`。**
`std::format` 是 header-only 的,没有这个问题 —— 真正修好的那一半在 mcpp:plugins,
六个规则模块全部改用它。

## 四、文档

`docs/20` 新增「Which platforms each lane reaches」(中英双份):三件事同时为真才叫一条
lane 在某个平台上成立,而第三件(规则自己那段按宿主分岔的代码编译得过)是最容易被默认
成立的那一件。同节写明四处按宿主的差异、以及运行时适配层是 Linux 的构造 —— 它们不该被
「补到三平台」。「尚未实现」补两条按平台分的缺口,都写明缺的具体是什么。

## 生态侧(已合入)

* xim-pkgindex #778:`shaderc` 补 macOS arm64 与 Windows x86_64
* xim-pkgindex #779:CUDA 四个组件 + `dpcpp` 补 windows 段;共享 install/config 形状里
  三处按宿主分的支;一条量了很久却对十五个配方从没量到过的判据(它的入口是
  `content.find("xpm")` —— 文件里第一次出现这三个字母,而注释里就有)
* mcpp-plugins #9 → 0.2.5:着色器阶段冲突的拒绝、三平台各自的编译器、CUDA/SYCL 的
  Windows 形态、以及一个「每条规则都为本宿主编译过」的夹具 —— 它第一批运行就抓到三处
* mcpp-index #365:`mcpp:plugins` 0.2.5

## 沙箱验证(已跑,对着已发布物)

`xlings subos use ... --sandbox`,CN 镜像,空 `$HOME`、新 `/tmp`、PATH 上没有 mcpp:

    ok: Provisioning [xlings.workspace] entries declared by dependencies (xim:glslang@>=15.1.0)
    ok: both stages: 354 + 124 words
    ok: glslang: 15.1.0        (恰好一个版本)

本次发布后会用新的 tarball 再跑一次。
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