Skip to content

fix: 产物必须加载它链接的那一份库 —— 链接行顺序成为声明,共享库不再劫持运行时 (2026.8.11.3) - #414

Merged
Sunrisepeak merged 1 commit into
mainfrom
fix/origin-precedence-and-shared-cxx-runtime
Aug 11, 2026
Merged

fix: 产物必须加载它链接的那一份库 —— 链接行顺序成为声明,共享库不再劫持运行时 (2026.8.11.3)#414
Sunrisepeak merged 1 commit into
mainfrom
fix/origin-precedence-and-shared-cxx-runtime

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

fix: 产物必须加载它链接的那一份库 —— 链接行顺序成为声明,共享库不再劫持运行时 (2026.8.11.3)

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

`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`
@Sunrisepeak
Sunrisepeak merged commit a749e9f into main Aug 11, 2026
18 checks passed
@Sunrisepeak
Sunrisepeak deleted the fix/origin-precedence-and-shared-cxx-runtime branch August 11, 2026 15:15
Sunrisepeak pushed a commit that referenced this pull request Aug 15, 2026
….8.15.1

四个 issue 的实现,以及一处 issue 自身的机制描述被推翻。

## #422 — CRT 模型从不到达 std 模块(比 issue 说的更广)

issue 归因给 `cxx_runtime`。核实发现**它根本不参与这个决定**:

    flags.cppm:588  msvc_base += (linkage == "static") ? " /MT" : " /MD";

工程 TU 的 CRT 模型来自 **`linkage`**;而构建 std 的命令里
**一个 `/M` 开关都没有**,拿的是 cl 默认(`/MT`)。所以**默认配置就已经不匹配**
—— 默认 linkage 是 dynamic ⇒ 工程 `/MD`、std `/MT`。cl 先给 C5050 警告,再在
ucrt 头文件里给出真正的 C2375。不是 host-coupled 独有。

e2e 99 用了 `import std` 且是绿的,因为它的程序太小、碰不到 `free` 那条重定义。
**测试存在,但没走到会失败的形状。**

修法与 `macos_deployment_target` 完全同形 —— `stdmod::ensure_built` 早就为同一类
问题接过一个参数,注释写着「必须与 flags.cppm 给普通 TU 发的一致」。现在:

* `dialect` 新增 `dynamicRuntime` 与 `msvc_crt_flag(dialect, staticLinkage)`,
  **linkage → CRT 模型只在这一处推导**,flags.cppm 与 std 构建共用
* CRT flag 进入 `std_build_commands`,而它本来就是 std 缓存身份的一部分 ⇒
  **两种模型不可能再共用一个缓存目录**。issue 给的第二条路(单独设计缓存键)
  不需要做,做第一条就白送

单测两条:flag 必须出现在命令里;`/MT` 与 `/MD` 必须产出**不同的命令串**
(相同就意味着相同缓存键,也就是这次要消灭的静默背离);GNU 方言必须产出空串。

## #418 — 一个死字段 + 一个只写不读的观测量

* 删 `TargetEntry::cxxRuntimeTests`(解析处不读、应用处不用)。
  ⚠️ 同名的 `BuildConfig::cxxRuntimeTests` 是**活的**,按名字 grep 一把删会删错。
* `[target.<triple>]` 此前**没有未知键检查**(只有 `[targets.<name>]` 有),
  所以写在那里的键是被静默吞掉的。补上,现在报:
  `[target.x86_64-linux-gnu] has unsupported key 'cxx_runtime_tests' (ignored)`
* `CompileFlags::contractByRole` 接进 `resolution.json` 的
  `runtime.cxx_runtime_by_role`。实测输出里 `shared-library` 是
  `toolchain-coupled` 而其余是 `self-contained` —— 正是 #414 之后用户会问
  「我这个 .so 到底拿到哪一档」的那个观测量,此前只能靠 readelf 猜。

## #421-A / #412 — 写下来的状态与实际行为脱节

* `docs/05-mcpp-toml.md` 中英两份都写着 `defines` 进 P1689 扫描
  「这正是被宏保护的 `import` 能被解析的前提」。**对 mcpp 不成立** ——
  mcpp 自己的词法预扫描看到条件块里有 `import` 就直接拒,根本走不到宏求值。
  改成说明真实行为,并给出替代写法(条件放在 GMF 的 `#include` 上)。
* 删 `toolchain default msvc` 里那句「native MSVC 尚不支持」——
  它是假的(e2e 99 就是一次完整的原生模块构建),实际效果是劝退一个能用的功能。
* 删 `95_msvc_system_toolchain.sh` 头注释里与自身断言矛盾的那行。

## 顺带

* 取消跟踪 `bench/tools/__pycache__`,并加进 `.gitignore`
* 删掉误入 PR 的 `bench/results/*.superseded-225002/`(中止那次跑的残留)
Sunrisepeak pushed a commit that referenced this pull request Aug 15, 2026
… can be compared

`runtime_search` 自称是搜索顺序的唯一决定处 ——

    Search order = decreasing immutability. This is the one invariant in this
    module, and it is not stylistic.

—— 而**最关键的那个目录不在它的模型里**。`$ORIGIN` 由 `shared_library_link_flags`
在另一条 per-unit 通道发出,从不进闭包。#414 修的正是它排错了位置:farm 曾排在
`$ORIGIN` 前面,产物链接了一份 libX11、运行期加载了另一份。

直接后果是 `resolution.json` 与 DT_RPATH **天生不可逐项比对**,e2e 219 只能退而
断言「最后一个**绝对路径**条目」—— 而那个放宽本身对它命名的那个缺陷是盲的。

## 改动

* `Origin` 新增 `Artifact` 档,`rank()` 置于 `Package` 与 `SubosFarm` 之间
* ⚠️ `is_machine_local(Artifact)` 返回 **false** —— `$ORIGIN` 随产物走,到哪台
  机器都是同一个含义;`pack` 恰恰是把别的条目**改写成**这个形式的。标成
  machine-local 会让 `pack` 拒绝它唯一想要的那一项
* `runtime_search_closure()` 加入产物输出目录

⚠️ **并且只在真的会发出 `$ORIGIN` 时才记。** 它是按共享库消费者发的,一个不产
共享库的工程根本没有。无条件记录只会把这个缺陷换成它的镜像 —— 记录里多一项、
产物里没有,219 照样需要例外。两个方向都验了。

## 判据(#415 原文)

> e2e 219 能把「记录的闭包」与「产物的 DT_RPATH」**逐项**比对并通过,
> 而不必对 `$ORIGIN` 做任何过滤或例外。

219 新增 invariant 2b,实测输出:

    closure == DT_RPATH, 4 entries, item by item

    记录:  payload → payload → artifact → subos_farm
    产物:  payload → payload → $ORIGIN  → subos_farm

`artifact` 是闭包对 `$ORIGIN` 的拼法,除此之外不过滤、不翻译、不豁免。

**重档不做**:让闭包成为唯一的 rpath 生产者要给它引入 per-unit 概念,
改动量与风险明显更大,而收益只是消灭「两个生产者」。这次关的是**记录**的口子。

本地:83 个单测过,219 过。
Sunrisepeak pushed a commit that referenced this pull request Aug 15, 2026
…not one per artifact

全新 MCPP_HOME 的第一次构建里,`binding.loader` 与 `binding.libraryDirs` 都是空的
(第二次构建就有了),而 rule B 把这件事**按产物**报告 —— 每个产物两条,一个图形
工程 13 个产物就是 26 行同一句话,出现在用户的**第一次**构建上。

这是 binding 自身的属性,不是任何产物的属性。改成进入产物循环前说**一次**,
并说清「这在全新 MCPP_HOME 的首次构建上是预期的,第二次构建即可解决」。

rule B 仍然照常跑:它还有别的输入(PT_INTERP 同一性),整条跳过是拿噪声换盲区。

⚠️ **真因未定位,本提交不动时序。** issue 自己写了「精确接缝还需要一次探针确认 ——
不要照着这个推理直接改」。当前证据只支持:`search_dirs` 有值而 `loader` /
`library_dirs` 没有,说明填充它们的不是同一段代码或同一时刻。判据的另一半
(「要么 rule B 给出真实判决」)留给探针之后。

⚠️ 并且要写在这里:**即使 rule B 完全正常,它也抓不到 #414 那个崩溃** ——
`elf_runtime.cppm` 只比对 libc / PT_INTERP 的同一性,不管其它 SONAME 解析到谁。
这条修复不能替代 #414 的顺序修复,也不是它的兜底。
Sunrisepeak pushed a commit that referenced this pull request Aug 15, 2026
…local status

两条新不变量,第二条如果写反比它关掉的那个缺口更糟:

* **rank 位置**:`Package < Artifact < SubosFarm`,且 `Payload < Artifact`。
  这正是 #414 的那个顺序 —— farm 排在 `$ORIGIN` 前面时,产物链接了一份 libX11、
  运行期加载了另一份;而钉住的 payload 仍然赢,因为它是唯一没人会重新指向的。
* **`is_machine_local(Artifact) == false`**:`$ORIGIN` 由加载器相对产物解析,
  产物拷到哪台机器含义都一样 —— `pack` 恰恰是把**别的**条目改写成这个形式的。
  标成 machine-local 会让 pack 拒绝它唯一想要产出的那一项。

`to_string(Artifact) == "artifact"` 也钉住:那些字符串是 `resolution.json` 里
`origin` 字段的取值,改了就是改协议。

顺带核过:`rank()` 全仓只用于相对比较,`resolution.json` 写的是字符串而非数值,
所以这次重编号(SubosFarm 2→3、HostDefault 3→4)不影响任何持久化数据。
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.

2 participants