ci: debug boost-ext.ut macOS SIGSEGV (fork-local iteration) - #1
ci: debug boost-ext.ut macOS SIGSEGV (fork-local iteration)#1ZheFeng7110 wants to merge 33 commits into
Conversation
[Boost::ext].UT is the C++20 single-header unit testing framework shipped
by the boost-ext org (NOT an official Boost library; hence the new
mcpp namespace, not and not ). Exposed as the
C++23 module so users can write directly.
The v2.3.1 tarball already ships include/boost/ut.cppm (), but it cannot be used VERBATIM on either non-MSVC default
toolchain of CI's pinned mcpp 0.0.109:
* GCC 16.1 (-std=c++23) rejects unqualified at namespace scope in
the module purview with ( only brings in
); ut.hpp uses unqualified in reporter_junit.
* Clang 22.1 on the MSVC ABI (x86_64-windows-msvc) fails on the MSVC
builtins / referenced under at
ut.hpp:687 — clang sets but does not provide those builtins
(the adjacent upstream guards at lines 291/311/1147 already gate clang
out, but line 687 missed it).
So, like marzer.tomlplusplus, a wrapper reproduces
upstream's cppm VERBATIM plus two minimal shims: a at
purview top level (fixes GCC) and / gated to on (fixes clang-on-Windows; MSVC
itself never enters the guard). base ut.hpp stays pinned to the v2.3.1 tag.
Verified locally with mcpp 0.0.109 (CI pin):
- mcpp xpkg parse pkgs/b/boost-ext.ut.lua -> parse OK
- mcpp test -p boost-ext.ut (gcc 16.1.0, x86_64-windows-gnu default)
-> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
- mcpp test -p boost-ext.ut (llvm 22.1.8, x86_64-windows-msvc)
-> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
No feature: ut is header-only + single module unit, no extra compilable
sources to gate; the optional defines are compile-time,
not yet representable in the features table.
CN mirror intentionally omitted (no mcpp-res write access); the descriptor
uses plain-string upstream URLs which the lint (tests/check_mirror_urls.lua)
accepts as-is. Maintainer can backfill the gitcode release later.
Design notes in .agents/docs/2026-08-02-add-boost-ext-ut-plan.md.
…icit template instantiations CI on PR mcpplibs#142 showed workspace (linux) and workspace (windows) green, but workspace (macos) failed with exit 139 (SIGSEGV) immediately at `Running bin/ut` — the test binary never reached the 'Suite ...' console output. Root cause: the verbatim v2.3.1 ut.cppm leaves the member templates the `cfg::runner<reporter_junit<printer>>` dispatches to (`reporter_junit<>::on<...>`, `test::operator=<>`, `expect<bool>`) only implicitly instantiable; on Apple-Clang 20.1.7 + `-fmodules` the implicit path through the module BMI fails to emit them into the consuming executable, so `cfg` static init calling `reporter_junit::on(events::run_begin)` → `std::unordered_map::operator[]("global")` lands in an un-instantiated slot and segfaults. Upstream fixed this on `master` AFTER v2.3.1 by appending eight explicit template instantiations to ut.cppm; reproduce that block byte-for-byte in our `generated_files` cppm after `#include "ut.hpp"`. This is the same forward-port shape as marzer.tomlplusplus carrying a one-line cut from upstream master — once a >2.3.1 release ships it, switch `sources` to `*/include/boost/ut.cppm` and drop `generated_files` (this block comes back verbatim). `export import std;` is KEPT (with the existing `using std::size_t;` GCC shim): trialed dropping it to mirror nlohmann.json / marzer.tomlplusplus but GCC 16.1 then surfaces a cascade of `-Wtemplate-body` errors in ut.hpp:3288 (`std::empty`), :3282 (`call_steps_` member lookup), :3322 (`literals::operator""_test` using-decl resolution), and more — GCC's two-phase lookup in the module purview is stricter than Clang's and genuinely needs the std module to be import-visible. Local verification, clean state (`rm -rf tests/examples/boost-ext.ut/{target,.mcpp,mcpp.lock,compile_commands.json}`): * mcpp xpkg parse pkgs/b/boost-ext.ut.lua -> parse OK * mcpp test -p boost-ext.ut (gcc 16.1.0, x86_64-windows-gnu) -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed' * mcpp test -p boost-ext.ut (llvm 22.1.8, x86_64-windows-msvc) -> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed' Both Windows default toolchains stay green — dev 3 is a no-op redundancy on GCC / Clang-on-MSVC and a real fix on Clang-on-Darwin; macOS CI result will be confirmed by the next push to PR mcpplibs#142. Design notes in .agents/docs/2026-08-02-add-boost-ext-ut-plan.md §2.3 / §2.4 / §6.
…tic init
The macOS leg (Apple Clang 20.1.7 + libc++) failed with exit 139 at
'Running bin/ut'. A temporary lldb diagnostic workflow pinned it:
frame #0: reporter_junit::reporter_junit at ut.hpp:1620:31
stop reason = EXC_BAD_ACCESS (code=1, address=0xffffffffffffffe8)
ut.hpp:1620 is the member-init `std::streambuf* cout_save = std::cout.rdbuf();`.
This is a static-initialization ORDER failure: ut.hpp's module-exported
inline variable `cfg = runner<reporter_junit<printer>>{}` dynamically
initializes before Apple libc++'s `std::cout`, so `cfg`'s member-init
reads an unconstructed std::cout (hence the -0x18 garbage read). MSVC STL
and libstdc++ order their stream init (init_priority / ios_base::Init) so
Windows and Linux legs pass; Apple libc++ does not, and the module
boundary breaks the same-TU ordering the header form relies on.
Fix: add a persistent `std::ios_base::Init` guard object in the module
purview BEFORE `#include "ut.hpp"`. Same-TU dynamic init runs in
declaration order, so the guard constructs std::cout/std::cin/std::cerr
first; when `cfg` (declared later inside ut.hpp) later calls
`std::cout.rdbuf()` the stream objects are fully built. The anonymous-
namespace guard lives for the whole program so stream refcount stays >= 1.
The explicit-template-instantiation block (dev 3, lifted verbatim from
upstream master) is upstream's fix for a separate Clang module linkage
gap and does NOT address this SIOF; it stays as a no-op redundancy.
Local verification (clean state, both Windows default toolchains):
* mcpp xpkg parse pkgs/b/boost-ext.ut.lua -> parse OK
* mcpp test -p boost-ext.ut (llvm 20.1.7, x86_64-windows-msvc)
-> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
* mcpp test -p boost-ext.ut (gcc 16.1.0, x86_64-windows-gnu)
-> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
macOS result to be confirmed by the diagnostic workflow on this push.
…R split
The macOS leg still crashed at ut.hpp:1620 (`std::cout.rdbuf()`) even with
the ios_base::Init guard. The lldb backtrace showed the real story:
stop reason = EXC_BAD_ACCESS (code=1, address=0xffffffffffffffe8)
The address -0x18 means std::cout's vptr is ZERO — the stream was NEVER
constructed. Root cause: `export import std;` inside the module makes
`std::cout` refer to the MODULE's own std entities, not libc++'s — an
ODR split between the std module's std::cout and the library's std::cout.
Apple libc++ does not merge module std entities (libstdc++/MSVC STL do),
so libc++'s ios_base::Init constructs the library copy while the module's
copy stays all-zero; cfg's member-init then dereferences the null vptr.
Fix (matches every other successful module package in this index —
nlohmann.json, marzer.tomlplusplus, neargye.magic_enum): the module TU must
NOT `export import std;`. Pull stdlib via #include only so every std::*
symbol inside ut.hpp is the library's own ODR entity. Consumers `import
std;` themselves (the test member already does).
Removing `export import std;` alone used to explode on GCC with a cascade
of -Wtemplate-body errors, so two enabling changes:
1. the module's GLOBAL-MODULE-FRAGMENT #includes every stdlib header
ut.hpp needs (GMF declarations are visible to the purview);
2. cxxflags = { "-Wno-template-body" } silences GCC 16.1's remaining
two-phase-lookup pedantry inside ut.hpp's templates (accepted on
gcc 16.1, ignored by Clang).
Kept from before: `using std::size_t;` (GCC) and the `__argc`/`__argv`
define (Clang-on-Windows), plus the post-v2.3.1 explicit-template-
instantiation block lifted verbatim from upstream master (harmless no-op;
does not fix the macOS ODR split).
Local verification (clean state, both Windows default toolchains):
* mcpp xpkg parse pkgs/b/boost-ext.ut.lua -> parse OK
* mcpp test -p boost-ext.ut (llvm 20.1.7, x86_64-windows-msvc)
-> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
* mcpp test -p boost-ext.ut (gcc 16.1.0, x86_64-windows-gnu)
-> 'all tests passed (3 asserts in 2 tests)' / 'test result ok. 1 passed'
macOS result to be confirmed by the diagnostic workflow on this push.
…e.yml edits (fork-debug temporary)
boost-ext.ut macOS CI 崩溃(SIGSEGV)诊断报告日期:2026-08-02 1. 背景:这个 PR 在做什么这个 PR 要把 [Boost::ext].UT(一个 C++ 单头文件单元测试框架,upstream:boost-ext/ut)打包进 mcpp 包索引,并且以 C++23 模块( C++23 模块简单说:以前代码用 CI(GitHub Actions,仓库自带的
Windows 和 Linux 一直通过;macOS 在测试程序一启动就 SIGSEGV(退出码 139),这是本次要解决的问题。 2. 崩溃现象
测试程序 用 lldb 抓到的崩溃现场: 崩溃发生在程序启动的"静态初始化"阶段,执行 3. 需要理解的几个基础概念(通俗版)3.1 程序启动时会发生什么(静态初始化)一个 C++ 可执行文件从启动到 在 macOS 的 Mach-O 格式里,这张表叫 3.2
|
| 实验 | 链接方式 | 结果 |
|---|---|---|
| E | 默认动态链接系统 libc++.1.dylib |
✅ 通过(Suite 'global': all tests passed,exit 0) |
| F | 静态 libc++.a + libc++abi.a(复现 mcpp 方式) |
❌ 139 复现 |
| D | 静态 + 显式 -Wl,-dead_strip |
❌ 139(排除 dead-strip 因素) |
| G | 静态 + -Wl,-no_dead_strip |
该 lld 不支持此选项(链接失败) |
| I | 静态 + 手动把 iostream.cpp.o 强制链入 |
❌ 139(iostream.o 本就在,无用) |
| O | 静态库放在对象之前链接 | ✅ 测试通过但退出时 abort(134)——因为 clang 仍自动链了系统 dylib,两套流初始化 double-free |
| P | 静态 + -Wl,-force_load,libc++.a |
❌ 139 |
| M / M2 | 链接 xpkg 自带的 libc++.dylib |
❌ 134(先缺 @rpath,后触发 libc++abi 的 TMO 报错) |
E 通过 + F 复现 是最硬的证据:同一份代码,换链接方式就好/坏 → 根因在链接/初始化顺序,不在 ut 包代码。
阶段 E:包内修复尝试("在用户侧把流提前构造出来")——全部失败
既然动态链接在 CI 里不可选(工具链层面 mcpp 写死静态),尝试在 generated cppm 里加"前置初始化器",让流在 cfg 之前构造:
- K:module purview 里 anonymous namespace 的
std::ios_base::Initguard → 初始化器被 clang 丢弃(反汇编证实只有__cxa_guard/atexit,无构造调用),无效。 - K2:GMF(模块外)里的
std::ios_base::Init全局对象 → 初始化器生成了(编号 .97,排在 cfg 前),但构造调用为空(libc++ 20 的Init构造函数是空操作,不构造流)→ 无效。 - S:GMF 里
__attribute__((constructor))函数(内容构造std::ios_base::Init)→ constructor 函数在模块 TU 中被丢弃 → 无效。 - S2:GMF constructor 函数里直接构造 libc++ 内部类
std::__1::DoIOSInit(真正构造流的类)→ 同上被丢弃 → 无效。 - S3:purview 里声明
DoIOSInit并建对象 → 链接失败:模块作用域声明的类带模块后缀(DoIOSInit@boost.ut),与库里无后缀的符号对不上。 - S3b:声明和对象都放 GMF(external linkage)→ 初始化器没生成(GMF 全局对象的动态初始化在模块 TU 中被 clang 丢弃,之前的 .97 其实是 ut.hpp 里的另一个对象)→ 无效。
- S4:DoIOSInit 声明放 GMF(external,符号正确)+ purview 里的 inline 变量 force_init(inline 变量像 cfg 一样保证初始化器生成)→ 初始化器生成 ✅ 且确实调用了 force_init 的构造函数 ✅(反汇编实证)→ 但仍崩(cfg 里 cout vptr 仍为 0)。
- S5:同上,但更直接——purview 里
inline std::__1::DoIOSInit ut_doios{};→ 初始化器生成 ✅,反汇编证实它直接bl std::__1::DoIOSInit::DoIOSInit()(且该符号定义在二进制内)✅,发生在 cfg 之前 ✅ → 但仍崩在 cfg,cout vptr 仍为 0。
S5 是最关键的反面证据:流构造代码确实在 cfg 之前被调用了,但 cfg 读到的 std::cout 还是零。唯一自洽的解释是 ODR split 的最终形态:模块 TU 里(即使 GMF include <iostream>)std::cout 被当作模块实体,其引用/定义与 DoIOSInit 构造的那个(iostream.o 里的)不是同一份内存——模块里那份始终是零。也就是说:在"模块 + 静态 libc++ + lld"这个组合下,从模块侧无法可靠地让 cfg 读到已构造的 std::cout。
阶段 F:流程整理(当前仓库状态)
- 原分支
add-boost-ext-ut上的 PR(feat: add boost-ext.ut 2.3.1 C++23 module package mcpplibs/mcpp-index#142)macOS 一直红。 - 按用户要求,把后续迭代挪到 fork 内:新分支
fix-ut-macos-ci,PR 到自己的 fork(ci: debug boost-ext.ut macOS SIGSEGV (fork-local iteration) #1),并在 fork 的validate.yml里:- 删除了
mirror-cn-reachablejob(fork 内不跑 CN 镜像检查); - 临时把
validate.yml变更触发的"全量 workspace"改成"只测 boost-ext.ut"(否则 opencv 等冷构建要 150 分钟,无法迭代;合并上游前需 revert)。
- 删除了
- fork 内 PR 的 CI:linux/windows/lint 全绿,macOS 仍然红(就是本问题)。
6. 结论
- 这不是 ut 包代码的 bug:同一份代码动态链接就完全正常(实验 E)。
- 是工具链层面的缺陷组合:mcpp 0.0.109 的 macOS 工具链(llvm@20.1.7 + lld)静态链接 libc++(
-Wl,-load_hidden,libc++.a,库在对象之后),而 lld 没有按init_priority把 libc++ 的流构造初始化器排到用户初始化器之前(_GLOBAL__I_000100排到了__init_offsets最后)。libc++ 的流构造只依赖这一个库内对象(头文件里没有 guard,与 libstdc++/MSVC STL 不同),所以任何"用户全局对象在静态初始化期使用 std::cout"的程序都会崩——ut 的cfg恰好就是这样。 - 包内(generated cppm 层面)修复目前全部失败,最接近的 S5(让
DoIOSInit在 cfg 之前真正执行)也因模块/库的std::cout实体分裂而无效。继续深挖 S5 也许能找出 ODR split 的具体机制,但即使修好,方案也依赖 libc++ 内部符号(std::__1::DoIOSInit),太脆弱,不适合作为正式修复。
7. 下一步候选方案(按优先级)
- 给 mcpp 上报(最推荐,治本):向 mcpp-community/mcpp 提 issue,附本报告 §4/§5 的证据链(E/F 对照实验、build.ninja 的
-load_hidden、__init_offsets顺序、_GLOBAL__I_000100排最后)。
建议方向:- macOS 工具链改用动态 libc++(
-lc++,系统 dylib;实验 E 证明一切正常,也符合 clang 默认行为); - 或修复/升级 lld 对
init_priority初始化器的排序; - 或给工具链加开关让包/成员可选"动态 stdlib"(例如 mcpp.toml 里能表达
[target.'cfg(macos)'.build] ldflags覆盖-load_hidden——注意:目前 member 的 ldflags 只能追加,无法去掉 mcpp 注入的-nostdlib++ -load_hidden,这也是需要 mcpp 侧支持的点)。
- macOS 工具链改用动态 libc++(
- PR 层面的临时处置(在 mcpp 修复前让 PR 变绿):
- 方案 a:测试用例在 macOS 上改用 header 形式(
#include <boost/ut.hpp>)而非import boost.ut;——能绿,但测不到模块,违背包目的,需在 PR 说明; - 方案 b:macOS leg 上把该 member 标记为"已知失败/跳过"并链接到 mcpp issue——诚实但 CI 依旧非全绿;
- 方案 c:暂不合并,等 mcpp 工具链修复(选项 1)。
- 方案 a:测试用例在 macOS 上改用 header 形式(
- 继续深挖 S5(不推荐但可选):验证模块 TU 里
std::cout的符号是否带模块后缀(nm找_ZNSt3__14coutE的带W5boostW2ut变体),确认 ODR split;如果确认,尝试在 ut.hpp include 之前用#define cout或直接#undef/宏重定向等黑魔法把 cfg 里的std::cout换成库里的实体——即使成功也极其脆弱,不推荐作为正式修复。
附:关键实验速查
- 崩溃指令:
ldur x9, [x9, #-0x18](x9=0,读 0xffffffffffffffe8)→ std::cout 虚调用、vptr=0。 __init_offsets19 项,cfg 初始化器第 12~13 位,流初始化器_GLOBAL__I_000100第 19 位(最后,未执行)。- E(动态 dylib)✅ vs F(静态 .a)❌:唯一变量是链接方式。
- S5:
inline std::__1::DoIOSInit初始化器在 cfg 前实际调用了DoIOSInit::DoIOSInit(),cfg 仍读零 vptr → 模块/库实体分裂。 - mcpp 链接参数(build.ninja):
-nostdlib++ -Wl,-load_hidden,<…>/lib/libc++.a -Wl,-load_hidden,<…>/lib/libc++abi.a,-fuse-ld=lld。
Fork-local CI iteration for the boost-ext.ut macOS SIGSEGV (PR mcpplibs#142).
Contains (temporary, fork-only):
Status so far: macOS crash = libc++ static-link init-order failure (stream initializer _GLOBAL__I_000100 runs LAST in __init_offsets; ut's cfg reads unconstructed std::cout). Dynamic libc++ passes. Guard attempts ineffective (libc++ 20 Init ctor is empty).