Skip to content

Commit edf33e5

Browse files
fix(build): make the C++ runtime contract one decision (#336) (#337)
* 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>
1 parent 76152d9 commit edf33e5

14 files changed

Lines changed: 1854 additions & 142 deletions

.agents/docs/2026-08-02-issue336-pr142-analysis.md

Lines changed: 387 additions & 0 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,32 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [2026.8.3.1] — 2026-08-03
7+
8+
### 修复
9+
10+
- **`[build] static_stdlib = false` 对测试二进制静默失效(#336)。** #124 在 0.0.52 明确写下这个 opt-out,`docs/05-mcpp-toml.md` 至今也还这么写;但 #202(0.0.86)把测试二进制改成与分发目标相同的静态 `-load_hidden` libc++ 时,新增的那条推导**没有带上 `staticStdlib`**。结果是约一年时间里 macOS 上的 `mcpp test` 没有任何办法回到动态 libc++,而文档一直在承诺它可以。
11+
12+
- **macOS 上全局对象在静态初始化期访问 `std::cout` 必崩(#336)。** Mach-O 没有按优先级排序的初始化段(`init_priority` 只在单个 TU 内有效),归档成员的初始化器按链接顺序排在最后;而 libc++ 的 `<iostream>` 不像 libstdc++ / MSVC STL 那样自带 `ios_base::Init` 守卫,流的构造只存在于库内对象里。两件事叠起来的后果是:默认配置下,任何在构造函数里碰 `std::cout` 的全局对象都会读到 vptr 为零的流,进程启动即 SIGSEGV —— 而且**包侧无法修复**,因为 `std::ios_base::Init` 在 libc++ 的头文件里只有前向声明(`ios:70`),标准为静态初始化次序提供的官方解药在 libc++ 上用户根本写不出来。
13+
14+
修法是让静态链接恢复动态链接本来就有的保证:macOS + 自包含时,mcpp 生成一个极小的 C 翻译单元并把它的对象排在链接行**最前**,由它先把流顶上去。它对 `ios_base::Init::Init()` 的引用是 **weak** 的 —— 换一份不这么拼这个 ABI 符号的工具链,链接与今天完全一样,shim 退化为空操作。
15+
16+
### 新增
17+
18+
- **C++ 运行时分发契约 `[build] cxx_runtime`** 三档:`self-contained`(默认)/ `toolchain-coupled` / `host-coupled`,可按角色(`{ default = ..., tests = ... }`)也可按目标三元组(`[target.<triple>] cxx_runtime`,与 `linkage` 并列 —— 它们本就是同一根轴)。`static_stdlib` 保留为忠实别名(`true``self-contained`,`false``host-coupled`)。
19+
20+
这个字段替换的旧字段名描述的是**手段**("静态链接 stdlib"),而它承载的其实是**意图**(产物能在哪些机器上跑)—— 这正是同一个 `true` 在四种配置上展开成四种不同结果的原因,其中一种是**静默空转**:Linux + clang/libc++ 工具链上 `static_stdlib = true` 一个 flag 都不发,交付的是工具链耦合的产物,而 manifest、文档和 `--version` 都说它是自包含的。
21+
22+
- **Linux + libc++ 工具链现在真的能自包含**:显式链入 `libc++.a` / `libc++abi.a` / `libunwind.a`(缺 libunwind.a 时产物仍会拉 `libunwind.so.1`,所以它是机制的一部分而不是可选项)。实测 `NEEDED` 只剩 libc / libm / loader。
23+
24+
- **兑现不了的契约一定会被报出来。** 工具链不带 `libc++.a`、macOS 没有 deployment floor、MSVC 运行时没有 `/MT` 机制、macOS 上没有可用的 `toolchain-coupled` 形态(LLVM 的 libc++abi/libunwind dylib 向上链 `/usr/lib/libc++`,会把第二份 libc++ 载进进程)—— 这些格子现在都会打印实际退到了哪一档。
25+
26+
### 变更
27+
28+
- **五处独立推导收敛成一处。** "这个产物自带 C++ 运行时吗"过去在 `flags.cppm``ldStdlibDefault` / `ldStdlibTest` / `-static-libstdc++` / MinGW `-static` 四处,加上 `ninja_backend.cppm` 里那个按 `LinkUnit::TestBinary` 的二分派,各推一遍 —— 这正是新语义只落到其中一处的成因。现在是 `src/build/distribution.cppm` 里的三层模型:角色(由链接单元内在决定)→ 契约(按角色取默认,可覆盖)→ 机制(唯一放 flag 的地方,且是**总函数**)。
29+
30+
- 相应地,C++ 运行时相关的链接 flag 从全局 `ldflags` 移到了**每个链接单元**`unit_ldflags` —— 两个角色在同一次构建里可以持有不同契约,这一点全局通道表达不了。它们都是驱动级 flag,相对库的位置无意义,Linux/Windows 的链接语义不变。
31+
632
## [2026.8.1.2] — 2026-08-01
733

834
### 新增

docs/05-mcpp-toml.md

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ cflags = ["-DFOO=1"] # Extra C compile flags
157157
cxxflags = ["-DBAR=2"] # Extra C++ compile flags (do not put -std=... here)
158158
ldflags = ["-lfoo"] # Extra link flags
159159
defines = ["BIZ=1", "QUX"] # Preprocessor macros for every TU (desugars to -D; reaches module scans)
160-
static_stdlib = true # Statically link libstdc++ (default true)
160+
cxx_runtime = "self-contained" # C++ runtime contract (§ below); static_stdlib is the old spelling
161161
target = "x86_64-linux-musl" # Default build target when no --target is passed
162162
# (≙ cargo build.target; e.g. "ship fully-static")
163163
macos_deployment_target = "14.0" # Minimum supported OS version for macOS artifacts (macOS only)
@@ -188,16 +188,66 @@ baseline, and 14.0 is the floor of LLVM's official static libraries themselves).
188188
This value enters the BMI fingerprint, so switching targets automatically rebuilds
189189
the module cache.
190190

191-
**Static runtime by default (portable by default)**: when `static_stdlib = true`
192-
(the default), macOS linking statically links in LLVM's bundled libc++/libc++abi —
193-
the system libc++ would otherwise pin the actual runnable version to the build
194-
machine's OS (older systems lack newer symbols, e.g. the support symbols behind
195-
`std::print`), and only static linking can truly deliver the floor. As a result,
196-
the default build's artifacts work out of the box on any macOS ≥ 14. Set
197-
`static_stdlib = false` to fall back to the dynamic system libc++ (the artifact is
198-
then only guaranteed to run on the build machine's version and above). A lower
199-
floor (11–13) requires a self-built libc++ archive (already verified to work, a
200-
data-level switch, available on request).
191+
### The C++ runtime contract (`cxx_runtime`)
192+
193+
`cxx_runtime` states what the produced artifact promises about the machine that
194+
runs it. It is a **distribution** property, not a build one — it describes the
195+
runtime dependency set, and the flags that deliver it differ per platform.
196+
197+
```toml
198+
[build]
199+
cxx_runtime = "self-contained" # applies to every target (the default)
200+
201+
# or, per role:
202+
[build.cxx_runtime]
203+
default = "self-contained" # binaries and shared libraries
204+
tests = "host-coupled" # test binaries never leave this machine
205+
206+
# or, per target triple — beside `linkage`, which is the same axis:
207+
[target.x86_64-linux-gnu]
208+
cxx_runtime = "host-coupled" # e.g. this build is for a distro package
209+
```
210+
211+
| value | the artifact needs, at run time | typical use |
212+
|---|---|---|
213+
| `self-contained` (default) | no C++ runtime outside itself | shipping a binary |
214+
| `toolchain-coupled` | the C++ runtime of the toolchain mcpp installed | local iteration |
215+
| `host-coupled` | whatever the driver resolves by default (the system runtime) | distro packaging, `dlopen` plugins that must share a runtime with their host |
216+
217+
**Self-contained by default (portable by default)**: on macOS this statically
218+
links LLVM's bundled libc++/libc++abi — the system libc++ would otherwise pin the
219+
runnable version to the build machine's OS (older systems lack newer symbols, e.g.
220+
the support symbols behind `std::print`), and only static linking can truly deliver
221+
the `macos_deployment_target` floor. On Linux/MinGW it is `-static-libstdc++` (GCC)
222+
or the whole-link `-static` (MinGW); on a Linux clang/libc++ toolchain it links
223+
libc++.a/libc++abi.a/libunwind.a explicitly. A lower macOS floor (11–13) requires a
224+
self-built libc++ archive (already verified to work, a data-level switch, available
225+
on request).
226+
227+
`static_stdlib` is the older spelling and still works: `true` means
228+
`self-contained`, `false` means `host-coupled`. An explicit `cxx_runtime` wins.
229+
230+
**A contract that cannot be honored is reported, never silently downgraded.** If a
231+
toolchain ships no `libc++.a`, or a contract has no mechanism on that platform
232+
(`self-contained` under the MSVC runtime would need `/MT`, which mcpp does not emit
233+
yet), the build prints what it fell back to instead of quietly producing a
234+
different artifact than the manifest asked for.
235+
236+
**Scope.** The contract governs the C++ runtime only. Static **libc** is a separate
237+
axis (`linkage = "static"` / `--static`, e.g. a musl target), and the deployment
238+
floor is a third (`macos_deployment_target`). Also, `host-coupled` means mcpp adds
239+
nothing to embed a C++ runtime; it does not strip the toolchain rpath the link
240+
carries for other reasons, so on ELF such an artifact may still find the
241+
toolchain's libraries first.
242+
243+
> **macOS + `self-contained` and static initialization order.** Mach-O has no
244+
> priority-ordered initializer section and libc++'s `<iostream>` carries no
245+
> `ios_base::Init` guard of its own (unlike libstdc++ and the MSVC STL), so a
246+
> stream initializer pulled out of `libc++.a` would otherwise run *after* the
247+
> program's own global constructors — a global whose constructor touches
248+
> `std::cout` would read an unconstructed stream and crash at process start. mcpp
249+
> links a tiny generated object first to force the streams up; nothing is required
250+
> of your code. See mcpp-community/mcpp#336.
201251
202252
`defines` takes **bare** macro names (no `-D`) and desugars each entry to `-D<x>` on
203253
both the C and C++ compile channels. It reaches every TU in the package — module

docs/zh/05-mcpp-toml.md

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ cflags = ["-DFOO=1"] # 额外 C 编译参数
151151
cxxflags = ["-DBAR=2"] # 额外 C++ 编译参数(不要放 -std=...)
152152
ldflags = ["-lfoo"] # 额外链接参数
153153
defines = ["BIZ=1", "QUX"] # 作用于每个 TU 的预处理宏(脱糖为 -D;会进入模块扫描)
154-
static_stdlib = true # 静态链接 libstdc++(默认 true)
154+
cxx_runtime = "self-contained" # C++ 运行时契约(见下节);static_stdlib 是旧拼写
155155
macos_deployment_target = "14.0" # macOS 产物的最低支持系统版本(仅 macOS 生效)
156156
cache = "global" # 依赖的全局构建缓存:global(默认)| local | off(见 §2.10)
157157
```
@@ -174,13 +174,59 @@ cargo/rustc、cc 等同样尊重该变量)> 本字段(项目默认,类似 SwiftP
174174
14.0 即 LLVM 官方静态库自身的下限)。该值会进入 BMI 指纹——切换 target
175175
会自动重建模块缓存。
176176

177-
**默认即静态运行时(portable by default)**:`static_stdlib = true`
178-
(默认)时,macOS 链接会静态链入 LLVM 自带的 libc++/libc++abi ——
179-
系统 libc++ 会把实际可运行版本钉死在构建机的 OS(老系统缺新符号,
180-
`std::print` 的支撑符号),静态化才能真正兑现 floor。因此默认构建的
181-
产物在任何 macOS ≥ 14 上开箱即用。设 `static_stdlib = false` 退回动态
182-
系统 libc++(产物只保证在构建机同版本及以上运行)。更低 floor(11–13)
183-
需自建 libc++ 归档(已验证可行,数据级切换,按需提供)。
177+
### C++ 运行时契约(`cxx_runtime`)
178+
179+
`cxx_runtime` 声明的是**产物对运行它的机器做出的承诺**。它是**分发**属性而非
180+
构建属性 —— 它描述的是运行期依赖集,而兑现它的 flag 逐平台不同。
181+
182+
```toml
183+
[build]
184+
cxx_runtime = "self-contained" # 作用于所有目标(默认值)
185+
186+
# 或者按角色分别指定:
187+
[build.cxx_runtime]
188+
default = "self-contained" # 可执行文件与共享库
189+
tests = "host-coupled" # 测试二进制从不离开本机
190+
191+
# 或者按目标三元组 —— 与 `linkage` 并列,因为它们是同一根轴:
192+
[target.x86_64-linux-gnu]
193+
cxx_runtime = "host-coupled" # 例如这次构建是为发行版打包
194+
```
195+
196+
| 取值 | 产物运行时需要 | 典型场景 |
197+
|---|---|---|
198+
| `self-contained`(默认) | 自身之外不需要任何 C++ 运行时 | 分发二进制 |
199+
| `toolchain-coupled` | mcpp 装的那份工具链的 C++ 运行时 | 本地迭代 |
200+
| `host-coupled` | 驱动默认解析到的那份(通常是系统运行时) | 发行版打包;必须与宿主共用同一份运行时的 `dlopen` 插件 |
201+
202+
**默认即自包含(portable by default)**:macOS 上这会静态链入 LLVM 自带的
203+
libc++/libc++abi —— 系统 libc++ 会把实际可运行版本钉死在构建机的 OS(老系统
204+
缺新符号,如 `std::print` 的支撑符号),只有静态化才能真正兑现
205+
`macos_deployment_target` 的 floor。Linux/MinGW 上它是 `-static-libstdc++`
206+
(GCC)或整条链的 `-static`(MinGW);Linux 上的 clang/libc++ 工具链则显式链入
207+
libc++.a/libc++abi.a/libunwind.a。更低的 macOS floor(11–13)需自建 libc++
208+
归档(已验证可行,数据级切换,按需提供)。
209+
210+
`static_stdlib` 是旧拼写,仍然有效:`true` 等价于 `self-contained`,`false`
211+
等价于 `host-coupled`。显式写了 `cxx_runtime` 时以后者为准。
212+
213+
**兑现不了的契约会被报出来,绝不静默降级。** 若工具链不带 `libc++.a`,或某个
214+
契约在该平台上没有对应机制(MSVC 运行时的 `self-contained` 需要 `/MT`,mcpp
215+
目前不发射),构建会打印实际退到了哪一档,而不是悄悄交付一个与 manifest 所述
216+
不同的产物。
217+
218+
**边界。** 该契约只管 C++ 运行时。静态 **libc** 是另一根轴(`linkage = "static"`
219+
/ `--static`,如 musl 目标),部署下限是第三根轴(`macos_deployment_target`)。
220+
另外,`host-coupled` 只承诺 mcpp 不做任何"把 C++ 运行时打进产物"的动作,它不会
221+
去掉链接因其它原因已经携带的工具链 rpath —— 所以在 ELF 上这类产物仍可能优先
222+
找到工具链的库。
223+
224+
> **macOS + `self-contained` 与静态初始化次序。** Mach-O 没有按优先级排序的
225+
> 初始化段,而 libc++ 的 `<iostream>` 也不像 libstdc++ / MSVC STL 那样自带
226+
> `ios_base::Init` 守卫 —— 于是从 `libc++.a` 里拉出来的流初始化器本来会排在
227+
> 程序自己的全局构造函数**之后**:一个在构造函数里碰 `std::cout` 的全局对象会
228+
> 读到尚未构造的流,进程启动即崩。mcpp 会把一个极小的生成对象排在链接最前面
229+
> 把流顶上去,你的代码不需要做任何事。详见 mcpp-community/mcpp#336
184230
185231
`defines` 接受****宏名(不带 `-D`),把每个条目脱糖为 `-D<x>`,同时作用于 C 和
186232
C++ 编译通道。它覆盖包内每个 TU(含模块接口单元),因此也会进入 P1689 模块扫描

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.8.2.2"
3+
version = "2026.8.3.1"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

0 commit comments

Comments
 (0)