From bc77d5043063fb628b88bf578db005606c5cab8c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:02:22 +0800 Subject: [PATCH 1/4] fix(toolchain,build): an absent SubOS description must not stop the build; link C-only units with the C driver (2026.8.15.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三条互不相关的缺陷,共同点是「一个事实的缺失被当成了矛盾」。 #427 —— `mcpp build` 与 `mcpp toolchain install` 在 Linux 上硬失败 `ensure_post_install_fixup` 在调用方没给出运行时身份时,自己去读硬编码的 `/subos/default`,并把「读不到」变成 `std::unexpected`。触发条件与 沙箱无关:默认 SubOS 由早于 `subos_info` 块的 xlings 创建即可(实测现场是一个 16 字节、只有 `{"workspace":{}}` 的清单)。`allow_host_libs` 救不了 —— 该判定 发生在 hermeticity 策略之前;`mcpp toolchain install` 同样死。已发布三周。 真因是没做完的迁移。调用点的注释写着「fixup 是 RuntimeBinding 的消费者」, `runtimeId` 参数也早已加上并从四处传入,旧的自行推导没有删。而被它保护的 `gcc_post_install_fixup` 本来就正确处理空值(warning 降级)—— 那个 `else` 从未执行过。 * 删掉第二处推导。身份只能来自调用方。 * 未知降级,矛盾仍然失败:身份为空 ⇒ 跳过并说明;身份声明了却兑现不了 ⇒ 报错。 * 严重程度归调用方:`build` 以 info 级说明一次(按载荷去重), `toolchain install` 以 warning 级说明并给出 `xlings self update`。 * 降级不写 marker,以免「什么都没做」被读成「已经做过」。 * `toolchain_install` 自己解析一次 RuntimeBinding —— 缺了这一步,单删兜底会让 它永远跳过 fixup,把硬失败换成静默的坏安装。 #426 —— 纯 C 的共享库依赖 libstdc++ 所有链接一律走 `$cxx`。同一个对象、同一份 ldflags,只换驱动的实测对照: g++ 给出 `libstdc++.so.6` `libm.so.6` `libgcc_s.so.1` `libc.so.6`,gcc 只给出 `libc.so.6` —— 三个 NEEDED 全部由驱动带入,零真实引用。 * 按内容选驱动,谓词照 `unit_needs_std` 的形状写;⚠️ 查不到的对象(action 产物) 保守判为 C++,与那个谓词相反。 * 只换驱动不够:`-lstdc++exp` 是显式命名的,`-stdlib=libc++` 与 macOS 的 `libc++.a` 路径在契约表里。故 `CompileFlags` 增加 `ldC`(与 `ld` 同一表达式 产生,`ld` 由构造保证不变),契约表增加 `unitFlagsC`(`-static` 与 `-static-libgcc` 属于 libc/编译器运行时,保留)。 * 顺带补上 `std.compat.o` 缺失的 `unit_needs_std` 收窄 —— #416 只修了 `std.o`。 main 的 bench pin 守卫 —— 断言本身是错的 发版收尾 bump `.xlings.json` 后三平台 e2e 全红。该守卫要求 `reference_mcpp` 等于 bootstrap pin,理由是「参照臂就是 CI 装的那个」;两半都不成立(标准集不在任何 workflow 里;bootstrap pin 是自举下界,可合理滞后),而它声称防止的危险已由 `run-standard.sh` 自己防住(按精确版本解析 + 要求二进制自述版本)。 * 删掉跨文件相等断言,保留编译器 pin 那条真耦合。 * 报告表头直接写出实测版本(engine 键本来就带),运行时把请求与实际解析到的 路径写进 `meta.json`。 文档 * `edit-body` 改写为三种情形的表(`.cppm` 移动行号 / `.cppm` 原地等长 / 独立 `.cpp`),不再读作否定级联抑制的价值。 * ⚠️ 订正 SPEC.md 中已被实测证否的解释:决定因素是行号移动,不是「成员函数体 进 BMI」——`Version::str()` 原地修改后 BMI 逐字节相同。 * 记录一个具名缺口:没有「原地等长修改」的场景,而那正是日常改动。 测试 * `tests/unit/test_post_install.cpp` +4:门的四个分支。⚠️ 必须是单测 —— 任何低成本 e2e 都用符号链接继承载荷,而 fixup 对继承载荷提前返回, 被测代码一行都执行不到(与 221 是同一种假绿)。 * `tests/e2e/237` 用户可见契约;`tests/e2e/238` 驱动选择,两个方向都钉。 --- .../2026-08-15-issues-426-427-analysis.md | 613 ++++++++++++++++++ README.md | 22 +- README.zh-CN.md | 16 +- bench/README.md | 11 +- bench/README.zh-CN.md | 7 +- bench/SPEC.md | 64 +- bench/run-standard.sh | 26 + bench/tools/report.py | 10 +- docs/05-mcpp-toml.md | 7 + docs/zh/05-mcpp-toml.md | 5 + mcpp.toml | 2 +- src/build/distribution.cppm | 17 +- src/build/flags.cppm | 39 +- src/build/ninja_backend.cppm | 82 ++- src/build/prepare.cppm | 28 + src/toolchain/lifecycle.cppm | 31 +- src/toolchain/linkmodel.cppm | 7 + src/toolchain/post_install.cppm | 68 +- src/version.cppm | 2 +- tests/e2e/233_bench_matrix.sh | 35 +- tests/e2e/237_default_subos_without_info.sh | 162 +++++ .../238_c_only_unit_links_with_c_driver.sh | 144 ++++ tests/unit/test_post_install.cpp | 132 ++++ 23 files changed, 1449 insertions(+), 81 deletions(-) create mode 100644 .agents/docs/2026-08-15-issues-426-427-analysis.md create mode 100755 tests/e2e/237_default_subos_without_info.sh create mode 100755 tests/e2e/238_c_only_unit_links_with_c_driver.sh diff --git a/.agents/docs/2026-08-15-issues-426-427-analysis.md b/.agents/docs/2026-08-15-issues-426-427-analysis.md new file mode 100644 index 00000000..6fa69e39 --- /dev/null +++ b/.agents/docs/2026-08-15-issues-426-427-analysis.md @@ -0,0 +1,613 @@ +# #426 #427 与 main 当前红 —— 核实与修复方案(2026-08-15) + +**状态:分析 + 方案,待 review。三条全部在 HEAD 上实测复现,不是照抄 issue。** + +| # | 核实 | 性质 | 影响面 | 改动面 | +|---|---|---|---|---| +| 427 | 复现,且 ⚠️ **影响面比 issue 大得多** —— 与沙箱无关 | 可用性(硬失败) | Linux 上所有工具链安装路径 | 小 | +| 426 | 复现,且 ⚠️ **收益比 issue 说的大** —— 少的是三个 `NEEDED` 不是一个 | 分发正确性 | 纯 C / 纯汇编链接单元 | 中偏大 | +| main 红 | 复现,且 ⚠️ **守卫本身是错的** | CI 阻塞 | 三平台 e2e | 小 | + +三条互不依赖,可并行。#427 应优先:它是唯一的**硬失败**,且已发布三周。 + +--- + +## #427 —— 缺失的描述被升级成致命错误 + +### 一、核实 + +沙箱内复现(mcpp 2026.8.15.1): + +``` +Runtime SubOS 'default' does not describe itself: … (no `subos_info` block) … + Where the C runtime comes from a payload, there is now no declared runtime to + bind to — mcpp declines to guess a version, so the link falls back to the host + and the hermeticity check will say so. +Resolving toolchain +error: toolchain post-install fixup: cannot fix up gcc toolchain + '…/xim-x-gcc/16.1.0': default SubOS has no RuntimeBinding identity (…) +``` + +**输出自相矛盾。** 前三行完整描述了一套降级方案(「declines to guess … the +hermeticity check will say so」),第四行就地为同一个事实杀死构建。降级被设计过、 +被打印给用户,然后另一处调用点把它作废。 + +### 二、触发条件与沙箱无关 + +沙箱那份 `subos/default/.xlings.json`: + +``` +-rw-rw-r-- 1 speak speak 16 Jun 22 04:30 …/subos/default/.xlings.json +{"workspace":{}} +``` + +**16 字节,6 月 22 日** —— 一个早于 `subos_info` 写入器的 SubOS。宿主机上同一路径的 +文件有完整的块,所以宿主机正常。沙箱不是成因,它只是**保存了一个旧 SubOS**。 + +于是真实触发条件是:**默认 SubOS 由早于该块的 xlings 创建**。同样能达成的还有全新 +但未升级的 xlings、容器/CI 镜像里的旧 subos、任何未跑过 `xlings self update` 的机器。 +mcpp 自己的诊断文字就写着 `A newer xlings writes this block`,即它**已知**这是一个 +版本差,却按矛盾处理。 + +### 三、影响面(实测,均在同一沙箱) + +| 动作 | 结果 | +|---|---| +| `mcpp build` | ❌ `toolchain post-install fixup: …` | +| `mcpp build`,`allow_host_libs = true` | ❌ **同样失败** | +| `mcpp toolchain install gcc@16.1.0` | ❌ `post-install fixup failed: …` | + +两条推论: + +1. `allow_host_libs` 救不了 —— 致命判定发生在 hermeticity 策略**之前**且无条件, + 所以这不是「不够 hermetic 就拒绝」,是「不知道就拒绝」。 +2. 影响面不止 `mcpp build`。`lifecycle.cppm:587` 调用时**不传** `runtimeId`,因此 + `mcpp toolchain install` 在 Linux 上**必定**走这条兜底,即受影响的是所有工具链 + 安装路径。 + +### 四、对照实验:那道门是唯一的墙 + +只向沙箱的 `subos/default/.xlings.json` 补一个 `subos_info` 块(`runtime: +glibc@2.39`),其它一律不动: + +``` +Resolved gcc@16.1.0 → @mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++ +Compiling ctl427 v0.1.0 (.) +Finished dev [unoptimized + debuginfo] in 0.11s +``` + +构建完全成功。所以沙箱内既非只读、也无第二道墙;**唯一的阻塞是这个 gate**。 +(实验后已还原该文件。) + +### 五、真因,三层 + +**第一层:同一个决定有两处推导。** + +绑定解析器按项目**选中的** SubOS 取路径: + +```cpp +// src/platform/runtime_binding.cppm:162 +if (selection.mode == Mode::McppDefault || selection.subosName == "default") + return cfg.xlingsHome() / "subos" / "default"; +return selection.ownerRoot / ".mcpp" / ".xlings" / "subos" / selection.subosName; +``` + +fixup 的兜底则**硬编码**: + +```cpp +// src/toolchain/post_install.cppm:545 +auto info = mcpp::xlings::subos::read(cfg.xlingsHome() / "subos" / "default"); +``` + +这不只是重复,而且**结果可以不同**:项目选了 `subos = "foo"` 时,兜底会拿 +`default` 的 glibc 去 patch 载荷。这是一条独立于 #427 的正确性缺陷。 + +**第二层:兜底违反它自己调用点写下的架构。** `prepare.cppm:953` 的注释: + +```cpp +// Resolve one exact runtime contract before resolving/fixing a toolchain. +// The fixup is itself a consumer of RuntimeBinding: doing it first would +// recreate #392 by letting directory order choose a libc and only later +// discovering what the project selected. +``` + +「fixup 是 RuntimeBinding 的消费者」是明确的设计,而 `subos/default` 兜底正是这段 +注释禁止的「让别的东西去挑 libc」。**这不需要新设计 —— 它是一次没做完的迁移**: +`runtimeId` 参数已经加上并已从四个调用点传入,旧的自行推导没有删。 + +**第三层:降级分支是死代码。** 被 gate 保护的函数**本来就正确处理空值**: + +```cpp +// src/toolchain/post_install.cppm:439 +if (!glibcLibDir.empty() && … ) { …patchelf… } +else { + mcpp::ui::warning("could not locate sandbox glibc/gcc/patchelf paths; " + "gcc-built binaries may have unresolved PT_INTERP/RUNPATH"); +} +``` + +外层 gate 保证它永远拿不到空值,于是这个 `else` 从未执行过。与 +`repair-placed-where-flow-never-reaches` 是同一形状。 + +### 六、为什么 `221_subos_without_info_still_builds.sh` 是假绿 + +221 正是为这条规则写的(「DATA THAT IS MISSING OR NEWER MUST NOT INVALIDATE THE +PROGRAM THAT READS IT」),却抓不到它: + +* 221 建的是**项目级** SubOS(`[xlings] subos = "bare"` → `.mcpp/.xlings/subos/bare`), + 而 fixup 读的是**硬编码的** `/subos/default`。两者不相交,所以 221 的 + 空 SubOS 对这条代码路径毫无作用。 +* 跑 221 的机器(开发机、CI runner)其 `default` 都描述得出自己,gate 永远通过。 +* 221 的绿色一半断言「加上 `allow_host_libs` 必须能构建」—— 这一条在 #427 的现场 + **实测是失败的**。即该断言是对的,只是从未指向出问题的 SubOS。 + +**教训**:一个「缺失必须降级」的测试,必须让缺失发生在**被读取的那个对象**上。 +测试建了一个 SubOS,被测代码读的是另一个。 + +### 七、方案 + +**A. 删掉第二处推导(核心)。** + +`ensure_post_install_fixup` 不再自行读 SubOS。运行时身份**只能**来自调用方传入的 +`RuntimeBinding` 快照。调用方拿不到,fixup 就拿不到 —— 这正是「fixup 是消费者」的 +含义。删除 `post_install.cppm:543-553` 整段兜底,`subos_info` 的 include 一并移除。 + +副产品:「项目选了 foo,却按 default 打补丁」这条缺陷随之消失,无需单独修。 + +**A′. ⚠️ `mcpp toolchain install` 必须补上解析,否则 A 会把它变成永久跳过。** +(自我 review 发现,见 R1。) + +`lifecycle.cppm:587` 调用时**不传** `runtimeId`,而 `toolchain_install(cfg, …)` 手里 +只有 `cfg`,没有任何 RuntimeBinding。单做 A,这条路径在 Linux 上会 `runtimeId` 恒空 +⇒ **永远跳过 fixup** —— 而它正是最需要 fixup 的路径(「without it a fresh-sandbox +glibc gcc cannot find the C library」)。 + +修正:`toolchain_install` 自己调用一次 `resolve_runtime_binding`(与 `prepare.cppm:962` +同一个解析器、同样的默认 selection),把 `runtimeId` 与 `libraryDirs.front()` 传下去。 +这比被删的兜底**更正确**:它尊重 selection、走统一的降级与 note,而不是硬编码 +`subos/default`。 + +**B. 未知降级,矛盾照旧失败。** + +* `runtimeId` 为空 ⇒ `glibcLibDir` 为空 ⇒ 直接调用 fixup 体,由那个已存在的 `else` + 发一次 warning 并跳过 patch。这恰好回到 2026.8.8.4 的行为,而二分表已证明该版本在 + 同一沙箱同一工程上成功。 +* `runtimeId` **非空但载荷缺失/版本对不上** ⇒ 仍然 `std::unexpected`。 + 这是矛盾不是缺失:身份被声明了却兑现不了,猜测会让同一份 mcpp.toml 在不同机器上 + 得到不同 ABI。`select_glibc_payload_lib` 现有的检查保持致命。 + +判据的分界线就是这一句:**未知降级,矛盾失败。** + +**C. 严重程度归调用方,不归被调方。** + +`ensure_post_install_fixup` 返回「做了什么 / 跳过了什么」,而不是自行决定生死: + +```cpp +struct FixupOutcome { bool applied; std::string skippedReason; }; +std::expected ensure_post_install_fixup(…); +``` + +* `mcpp build`(`prepare.cppm` 四处):`skippedReason` 非空时以 info 级说明一次 + ——「工具链未按运行时打补丁,原因 X;若后续报 `stdlib.h not found` 或 + hermeticity 失败,即由此而来」。构建继续。 +* `mcpp toolchain install`(`lifecycle.cppm:587`):用户显式要求安装,以 warning 级 + 报告并给出 `xlings self update` 这一条可操作指令,退出码仍为 0(安装本身成功了)。 + +同一事实两种量级是合理的;**不合理的是量级被写死在被调方**,让 `build` 无从选择。 + +**D. 顺带修一处诊断。** 现在的错误文本把用户指向 +`/home/speak/.mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0` —— 一个他不该写的目录。 +降级后的 warning 应指向真正的动作:`xlings self update`。 + +### 八、测试(两侧都要钉) + +新增 `tests/e2e/237_default_subos_without_info.sh`,`# requires: elf`: + +1. 构造一个**独立 MCPP_HOME**,其 `subos/default/.xlings.json` 写成 `{"workspace":{}}` + —— 即沙箱现场的字面复制。⚠️ 必须是 `/subos/default`,不是项目级 SubOS, + 否则重蹈 221 的假绿。 +2. 断言 `mcpp build` **成功**,且输出**说明**了降级(「一个没人打印的降级与没有降级 + 无法区分」)。 +3. 反向:把 `subos_info.runtime` 写成一个**不存在的** glibc 版本,断言构建**失败**, + 且消息说的是载荷缺失而非「无法描述自己」。这一条防止 A/B 把矛盾也放行。 +4. 断言 `mcpp toolchain install` 在同一 home 下退出 0。 + +单测 `tests/unit/test_post_install.cpp`:`ensure_post_install_fixup` 传入空 +`runtimeId` 时返回 `applied=false, skippedReason≠""`,而非 error。 + +### 九、判据 + +* 一个 `subos/default/.xlings.json` 只有 `{"workspace":{}}` 的 Linux 机器上, + `mcpp build` 与 `mcpp toolchain install` 均成功; +* 同一机器上 `git grep -n '"subos" / "default"' src/toolchain/` 无结果; +* `subos_info.runtime` 指向不存在的载荷时仍然失败; +* 宿主机(SubOS 描述完整)行为逐字节不变 —— 用 `build.ninja` 归一化 diff 核对。 + +--- + +## #426 —— 链接驱动应由内容决定 + +### 一、核实(实测) + +纯 C 共享库(`[targets.purec] kind = "shared"`,一个只含 C 函数的 `.c`): + +``` +build.ninja: rule cxx_shared + command = $cxx -shared @$out.rsp -o $out $ldflags … + cxx = …/xim-x-gcc/16.1.0/bin/g++ +``` + +同一个 `.o`、同一份 `ldflags`,只换驱动: + +| 驱动 | `NEEDED` | +|---|---| +| `g++` | `libstdc++.so.6` `libm.so.6` `libgcc_s.so.1` `libc.so.6` | +| `gcc` | `libc.so.6` | + +`nm -D --undefined-only` 显示该 `.so` 唯一像 C++ 的未定义符号是 +`__cxa_finalize@GLIBC_2.2.5` —— glibc 的弱符号,不来自 libstdc++。即三个 `NEEDED` +**全部**由驱动带入,零真实依赖。`purec_add` 在两种链接下都正常导出、可 `dlopen`。 + +**issue 低估了收益**:少的不是一个 `NEEDED`,是三个。 + +### 二、两条修法的决定性区别是可移植性 + +issue 列的两条中,`--as-needed` 不能作为主方案,理由与「优雅」无关: + +1. **它不跨平台。** `--as-needed` 是 GNU ld / lld 的特性。macOS 的 ld64 没有它 + (最接近的 `-dead_strip_dylibs` 语义不同且作用于整条链接线),MSVC 的 `link.exe` + 没有对应概念。选驱动则三平台都成立。 +2. **它治不了这个错误。** 一个纯 C 的库由 C++ 驱动链接,即使 `NEEDED` 被裁掉, + 命令行仍然是错的 —— C++ 驱动还会带入 `-lm`、C++ 的启动/异常段落与不同的默认库 + 顺序。`--as-needed` 只是把症状扫掉。 +3. **它的作用域会越界。** 现在全仓唯一一处 `--as-needed` 用的是 + `-Wl,--push-state,--as-needed -latomic -Wl,--pop-state`(`flags.cppm:240`), + 括起来正是为了把作用域限死。全局启用会波及用户显式命名的库,以及只被 `dlopen` + 使用的库 —— GPU/GL 那条链已经为此付过代价。 + +结论:**方案 1(按内容选驱动)。方案 2 不作为替代,可在方案 1 之后单独评估。** + +### 三、方案 + +**数据已经在,只是没有到达发射链接边的那一步。** `CompileUnit` 携带 +`mcpp::SourceKind kind`(`ModuleInterface` / `Cxx` / `C` / `GasAsm` / `NasmAsm`); +`LinkUnit` 只有 `std::vector objects`。 + +改动分三处,**换驱动只是其中一处** —— 只换驱动会半途而废,见 (3)。 + +**(1) 判定谓词,照 `unit_needs_std` 的形状写。** + +`ninja_backend.cppm:1169-1179` 已经有一个完全同形的先例:按 `cu.object` 建一张表, +再对 `lu.objects` 查表。照抄结构即可,**不需要给 `LinkUnit` 加字段**(加字段会牵动 +Plan 的序列化与缓存键,而这个决定只有发射时才用得到): + +```cpp +std::unordered_map objectIsCxx; +for (auto& cu : plan.compileUnits) + objectIsCxx[cu.object.generic_string()] = + cu.kind == SourceKind::ModuleInterface || cu.kind == SourceKind::Cxx; + +auto unit_needs_cxx_runtime = [&](const LinkUnit& lu) { + for (auto& o : lu.objects) { + auto it = objectIsCxx.find(o.generic_string()); + if (it == objectIsCxx.end()) return true; // ⚠️ 未知 ⇒ 保守 + if (it->second) return true; + } + return false; +}; +``` + +⚠️ **与 `unit_needs_std` 的关键差异是查不到时的取值。** 那个查不到取 `false` +(不链 `std.o`),这个查不到必须取 **`true`**。action 产出的对象 +(`prepare.cppm:5182` / `5420-5422`)不在 `plan.compileUnits` 里,语言未知,按 C 链 +会得到未定义符号。 + +**静态依赖不需要单独传播**:`kind = "lib"` 依赖的对象经 `append_package_objects` +(`plan.cppm:1547`)直接进入 `lu.objects`,其 `cu.kind` 就在表里。共享库依赖由动态 +链接器解决,其自身的 `NEEDED` 与本单元的驱动无关。 + +**(2) 规则选择,并同时堵住 `std.compat.o`。** + +`ninja_backend.cppm:1691-1710` 的 `switch` 按谓词选 `c_link` / `c_shared` +(用 `$cc`,该变量已存在)或 `cxx_link` / `cxx_shared`。`cxx_archive` 走 `ar`, +与驱动无关,不变。 + +⚠️ **同一个 `switch` 里 `std.compat.o` 没有收窄**(自我 review 发现,见 R4): + +```cpp +if (has_std_artifacts && unit_needs_std(lu)) ins += std_o_dst; // #423 已收窄 +if (has_std_compat) ins += compat_o_dst; // ← 无条件 +``` + +`std.compat.o` 由 C++ TU 编出,链进纯 C 单元会带来**真实**的 libstdc++ 依赖。实测配置 +下 `has_std_compat` 为 false 故当前不发作,但这是 #416 修了一半留下的不对称。方案 B +必须一并处理:C 链接单元**既不收 `std.o` 也不收 `std.compat.o`**,且 `std.compat.o` +补上与 `std.o` 相同的 `unit_needs_std` 收窄。 + +**(3) ⚠️ `ldflags` 里的 C++ 专属 token 必须同时不发。** + +只换驱动是不够的 —— `flags.ld` 与 `unit_ldflags` 都可能带 C++ 运行时 token: + +| token | 来源 | C 链接下的后果 | +|---|---|---| +| `-lstdc++exp` | `flags.cppm:965`(MinGW + libstdc++) | **显式命名的库,照样链进去** —— 修复失效 | +| `-stdlib=libc++` | `linkmodel.cppm:178/189` | clang 警告并忽略 | +| `--rtlib=compiler-rt --unwindlib=libunwind` | `linkmodel.cppm:189` | 改变 C 链接的运行时选择 | +| `-static-libstdc++` | 契约表经 `unit_ldflags`(`dist::Format::Pe`) | gcc 接受但无意义 | + +做法:**让 `compute_flags` 一次算出两份 ld**,即 `CompileFlags` 增加 `ldC`,与 `ld` +在同一个函数体内并行产生;`ninja_backend.cppm:485` 发两个全局 +`ldflags` / `c_ldflags`。**`ld` 的计算路径一个字符不动**,所以「C++ 链接命令逐字节 +不变」这条反向判据是由构造保证的,而不是靠测试碰运气。 + +`unit_ldflags` 侧同理:契约表按 `dist::Format` 取值时一并按链接语言取。 +⚠️ **只去掉 C++ 运行时那几项**(见 R5):MinGW 的 `-static` 也来自契约表,但它表达的是 +PE 的「自包含」语义、与语言无关,C 单元**必须保留**它。 + +**跨平台**: + +| 平台 | 现状 | 变化 | +|---|---|---| +| Linux / MinGW (gcc) | `g++` | 纯 C 单元改 `gcc`;MinGW 另需去掉 `-lstdc++exp` | +| macOS (clang) | `clang++` | 纯 C 单元改 `clang`,并去掉 `-stdlib=libc++` 等三项 | +| MSVC | `separateLinker` 走 `$ld`(link.exe) | **无变化** —— 驱动不参与链接。加一条断言把这个事实钉住 | + +**不新增开关。** 需要强制的用户已经有 `ldflags = ["-lstdc++"]`。多一个 +`linker_language` 键就是多一处可以与真相不一致的声明。 + +**规模修正**:因 (3),改动面从「中」上修为「中偏大」。若要拆两步,(1)+(2) 单独落地 +在 Linux/gcc 上即可得到实测的全部收益(该配置下 `ldflags` 不含任何 C++ token), +但**不可在 macOS / MinGW 上只做 (1)+(2)** —— 那会得到一个「换了驱动却仍带 +`-lstdc++exp`」的半吊子状态,比不改更难诊断。 + +### 四、边界与风险 + +* **纯 C 目标静态链接了一个 C++ 静态库**:传递规则(第 2 条第二项)覆盖已声明的 + 依赖。若用户用裸 `ldflags = ["-lfoo"]` 引入一个 C++ 库,则会得到未定义 `_Z…` —— + **响亮失败,不是静默错误**,且可由用户加 `-lstdc++` 解决。这是可接受的方向。 +* **不影响 `import std`**:模块接口单元的 `kind` 是 `ModuleInterface`,必然 true。 +* 与 #416 的 `std.o` 收窄正交:那条管「链不链这个对象」,这条管「用哪个驱动」。 + issue #426 已明确证否「症状源自 `std.o`」。 + +### 五、判据(两侧) + +* 纯 C 共享库工程 `readelf -d` 中**没有** `libstdc++.so.6` / `libm.so.6` / + `libgcc_s.so.1`,且导出符号与可 `dlopen` 性不变; +* 一个 C++ 工程的链接命令与产物**逐字节不变**(归一化 diff `build.ninja`); +* 一个「纯 C 可执行 + 依赖一个 C++ 共享库」的工程仍能链接并运行 —— 传递规则的正向判据; +* macOS 上纯 C 单元的 `otool -L` 不含 `libc++`; +* MSVC 上 `build.ninja` 的链接规则**无变化**。 + +--- + +## main 当前红 —— bench 参照版本与 bootstrap pin 的耦合 + +### 一、核实 + +`bb53e81`(发版收尾的 pin bump)后,三平台 e2e 全红,失败点同一处: + +``` +FAIL: bench/matrix.json + reference_mcpp=2026.8.11.3 but .xlings.json bootstraps mcpp 2026.8.15.1 — + the reference arm IS the bootstrapped binary, so these two must agree … +``` + +`.xlings.json` 被 bump 到 2026.8.15.1,`bench/matrix.json` 的 `reference_mcpp` +留在 2026.8.11.3。守卫是 #423 加的,由 `tests/e2e/233_bench_matrix.sh:269` 触发。 + +### 二、⚠️ 守卫本身是错的 + +它断言的危险 —— 「the old column silently becomes some other release」—— +**已经被 `run-standard.sh` 自己防住了**: + +```sh +# bench/run-standard.sh:125-136 +for c in "$HOME"/.xlings/data/runtimedir/mcpp-"$REFERENCE_MCPP"-*/mcpp; do + got="$("$c" --version …)" + if [ "$got" = "$REFERENCE_MCPP" ]; then REF_BIN="$c"; break; fi + echo " note: $c reports '$got', not '$REFERENCE_MCPP'; ignoring it" +done +[ -n "$REF_BIN" ] || echo " note: no mcpp@$REFERENCE_MCPP binary found; …" +``` + +参照臂按**精确版本**取路径,并要求二进制**自述**该版本,取不到就明说列缺失。 +两个 pin 不一致时不会量错东西,只会在没装那个版本时少一列。 + +而守卫断言的前提「the reference arm IS the bootstrapped binary」不成立:bench 标准集 +不在任何 workflow 里(`grep -rn run-standard .github/workflows` 无结果),它在开发机上 +手工跑,`runtimedir` 下常年并存多个版本。参照臂是**被显式点名**的那个,不是被 +bootstrap 装的那个。 + +更根本的一点:bootstrap pin 是**自举起点**,判据是上界而非义务,可以合理滞后于最新 +发布;bench 的参照臂语义是「上一个已发布版本」。把两者钉成相等,是给一个 bench 旋钮 +强加了发布流程的约束。 + +### 三、方案 + +1. **删除 `233_bench_matrix.sh:262-272` 的跨文件相等断言。** 保留同文件里两条真实的 + 不变量:`reference_mcpp` 必须是精确版本;`matrix.json` 的编译器 pin 必须与 + `bench/src/toolchain.cppm` 一致(那一条是真耦合 —— 一处决定装什么、一处决定要哪个 + 路径,不一致会让每个 cell 报一个路径错误)。 +2. **让报告自述它实际量到的版本。** `report.py:237` 现在把列名写死成 `mcpp (old)` / + `mcpp (旧版)`,应改为显示实测版本(`mcpp 2026.8.11.3`);README pin 表里的那一行 + 同样由生成而非手写。 + + ⚠️ **不能改 engine 标签**(自我 review 发现,见 R6)。journal 的 cell 只有 + `engine / compiler / profile / scenario`,参照臂的 engine 标签就是 `mcpp` + (`run-standard.sh:280`),不含版本;而 **engine 标签是 resume 的键**,改它会让 + 已有 journal 全部失效、整套数据重跑。修正做法:`run-standard.sh:131` 已经拿到并 + **校验过**该二进制自述的版本(`$got`),把它连同解析出的路径写进 journal 旁的 + `meta.json`,`report.py` 从那里取。纯增量,不动 resume 键。 + +第 2 条是把守卫想买的性质**结构化地**买到:漂移不再靠断言拦截,而是无处藏身 —— +报告直接写着它量的是哪个版本。 + +### 四、判据 + +* main 三平台 e2e 恢复绿; +* `matrix.json` 与 `.xlings.json` 故意写成不同版本时,`233` 通过,而生成的报告表头 + 显示 `matrix.json` 里那个版本; +* 删掉 `runtimedir` 下的参照二进制后,报告显示该列缺失而非显示错误数据。 + +--- + +## 任务依赖与顺序 + +``` +main 红(独立,最短) ──► 先做,恢复 CI 信号 +#427(独立,硬失败) ──► 并行,优先级最高 +#426(独立,中偏大) ──► 并行 +``` + +三条无共享代码路径:#427 在 `src/toolchain/`,#426 在 `src/build/{plan,ninja_backend,flags}`, +main 红在 `tests/e2e/` 与 `bench/tools/`。可在同一 PR 内并行实现,分三组 commit。 + +## 反向判据(防止修过头) + +* #427:宿主机(SubOS 完整)的 `build.ninja` 与产物必须**逐字节不变**。 + 若变了,说明改动动到了正常路径,而不只是缺失路径。 +* #426:C++ 工程的链接命令必须**逐字节不变**。 +* main 红:必须能构造出「两个 pin 不同」且 `233` 通过的情形,否则说明只是把断言挪了地方。 + +--- + +# 自我 review —— 方案自身的缺陷与修正 + +对上面三份方案逐条找洞,每条都对 HEAD 核实过。**其中 R1 是方案 A 的真缺陷:按初稿 +实施会引入一个新的、更隐蔽的问题。** + +## R1 ⚠️ 方案 A 会让 `mcpp toolchain install` 永久跳过 fixup + +`toolchain_install(const GlobalConfig& cfg, …)`(`lifecycle.cppm`)手里只有 `cfg`, +没有任何 RuntimeBinding,`:587` 因此**不传** `runtimeId`。删掉兜底之后,这条路径在 +Linux 上 `runtimeId` 恒空 ⇒ 永远走降级分支 ⇒ **永远不打补丁**。 + +而 fixup 的存在理由正是这条路径:「without it a fresh-sandbox glibc gcc cannot find +the C library (stdlib.h not found)」。初稿等于把一个硬失败换成一个静默的坏安装 —— +比原缺陷更难诊断。 + +**修正(已并入方案 A′)**:`toolchain_install` 自己调用一次 `resolve_runtime_binding`, +与 `prepare.cppm:962` 用同一个解析器,把 `runtimeId` 与 `libraryDirs.front()` 传下去。 + +**教训**:「删掉重复推导」只在**所有**调用方都能提供那个事实时成立。删之前必须逐个 +调用方核对谁能提供 —— 五个调用点里有一个不能。 + +## R2 降级面比担心的窄(核实结论,方案不变) + +初稿的隐忧:健康机器上 `runtimeId` 若也可能为空,删掉兜底就成了回归。 + +核实:`resolve_runtime_binding` 在缺 `subos_info` 时**不早退** +(`runtime_binding.cppm:277`「CONTRADICTION vs ABSENCE」那段),后面还会用 +`/lib*/libc.so.6` 的物理链接反推身份(`managed_glibc_identity`,`:352`)。 +所以只有**既无声明、又无物理 libc** 的 SubOS 才会得到空身份。 + +沙箱现场正是如此 —— 那个 subos 的 `lib/` 里**没有** `libc.so.6`,物理推导也无从下手。 +即降级路径只覆盖真正一无所知的情形,不会波及正常机器。 + +## R3 降级时的幂等标记(补充,免费的正确性) + +降级写出的 `.mcpp-fixup.json` 其 `glibcLib` 为空,与打过补丁的指纹不同 ⇒ 用户后来跑 +了 `xlings self update`、身份可知之后,fixup 会**自动重跑**。这条性质由现有的内容 +指纹机制免费提供,但必须在测试里钉住,否则将来有人「优化」成布尔标记就会退化成 +「补好了 xlings 却仍不打补丁」。 + +## R4 ⚠️ `std.compat.o` 仍是无条件的 —— #416 只修了一半 + +`ninja_backend.cppm:1697 / :1707`: + +```cpp +if (has_std_artifacts && unit_needs_std(lu)) ins += std_o_dst; // #423 已收窄 +if (has_std_compat) ins += compat_o_dst; // ← 无条件 +``` + +`std.compat.o` 由 C++ TU 编出,链进纯 C 单元会带来**真实**的 libstdc++ 依赖(不同于 +#426 那个纯粹由驱动带入、可完全去掉的)。实测配置下 `has_std_compat` 为 false,故 +当前不发作 —— 但这是 #423 留下的不对称,方案 B 必须一并收窄,否则修好了驱动却在另 +一个配置上重新引入同一个症状。已并入方案 B (2)。 + +## R5 MinGW 的 `-static` 不是 C++ 运行时项 + +方案 B (3) 初稿说「C 单元拿不到 C++ 运行时那几项」,措辞过宽:契约表(`dist::Format::Pe`) +同时提供 `-static`,它表达的是 PE 的自包含语义、与语言无关。C 单元丢掉它会破坏 +`build-mcpp-helper-self-containment` 记的那条结论。已在方案 B (3) 收窄措辞。 + +## R6 ⚠️ 方案 C 的「从 journal 读版本」不成立 + +核实:journal 的 cell 只有 `engine / compiler / profile / scenario` +(`report.py:74`),参照臂的 engine 标签就是 `mcpp`(`run-standard.sh:280`),不含版本。 +而 **engine 标签是 resume 的键** —— 把它改成 `mcpp@2026.8.11.3` 会让所有已有 journal +失配、整套标准集重跑,代价远大于收益。 + +修正:`run-standard.sh:131` 已经取到并**校验过**该二进制自述的版本,写进 journal 旁的 +`meta.json` 即可,`report.py` 从那里取。纯增量,不动 resume 键。已并入方案 C2。 + +## R7 方案 A3 的提示会重复打印 + +一次 `mcpp build` 里 `ensure_post_install_fixup` 最多被调四次(manifest 工具链、 +默认工具链、MinGW 首次、build.mcpp host 工具链)。降级提示要按 `(kind, payloadRoot)` +去重,否则同一条消息刷四遍 —— 与 #417 那半个「说一次就够」是同一条规矩。 + +## R8 顺序与并行性不变 + +R4 让方案 B 多收一处 `std.compat.o`,但仍不与 A / C 争同一个文件。三条依旧可并行, +只是 B 内部多一步。 + +--- + +## 自我 review 的结论 + +| 编号 | 性质 | 处置 | +|---|---|---| +| R1 | **方案缺陷** —— 会引入更隐蔽的新问题 | 已改方案(新增 A′) | +| R6 | **方案缺陷** —— 做法不可行且代价大 | 已改方案(C2 改为 `meta.json`) | +| R4 | **遗漏** —— 方案不完整 | 已并入方案 B (2) | +| R5 | 措辞过宽,会误删 | 已收窄方案 B (3) | +| R3 / R7 | 补充,防止将来退化 | 已并入方案 A | +| R2 | 隐忧证否,方案不变 | 记录核实结论 | + +三条方案在改动后自洽。**A′ 是必须与 A 同时落地的**,单做 A 会造成回归。 + +--- + +# 实施记录 —— 与方案不同的地方(2026.8.15.2) + +方案在实施中被现实修正了四处。记在这里,因为「方案说 X,代码做了 Y」不写下来就 +是下一个人要重新发现的东西。 + +## I1 ⚠️ R6 的前提错了:engine 键**本来就带版本** + +R6 断定 journal 不含参照臂的版本、只能另写 `meta.json`。核实代码后:mbench 按 +**二进制自述的版本**给每条臂命名(`report.py` 见到的是 `mcpp@2026.8.11.3`),版本 +一直都在键里。`short_name` 只是把它丢掉了。 + +所以列名改动是三行:`return "mcpp " + base[len("mcpp@"):]`,不需要新数据源。 +`meta.json` 仍然写,但作用变小且不同 —— 它记的是**请求 vs 实际解析到的路径**, +即「matrix.json 要 2026.8.11.3,这台机器上解析到了/没解析到」,这是 engine 键 +表达不了的。 + +## I2 e2e 到不了 fixup 的门,单测才行 + +方案 A 的测试计划写的是 e2e 构造一个 `subos/default` 缺描述的 MCPP_HOME。写出来 +才发现:任何低成本 e2e 都用符号链接继承工具链载荷,而 `ensure_post_install_fixup` +**第一件事**就是对继承载荷提前返回(「owner is responsible for its fixup」)—— +被测代码一行都执行不到。 + +这正是 §六里 221 的形状,差点原样重演一次。改为: + +* `tests/unit/test_post_install.cpp` —— 载荷是测试自己 registry 里的真实目录, + 门的四个分支(降级/矛盾/不留标记/无需 fixup)逐条钉住; +* `tests/e2e/237` —— 只钉用户可见的契约:构建不再死在 fixup、`allow_host_libs` + 能到链接、`toolchain install` 退出 0。 + +## I3 `[targets.X] sources` 不隔离同包对象 + +方案 B 的 e2e 初稿在**一个工程里**放纯 C、混合、C++ 三个 target。实测:同一个包的 +对象会进入该包每一个链接单元 —— `libpurec.so` 拿到了 `mixed_cxx.o`,于是「纯 C 单元」 +在这个布局下根本不存在。改成三个独立工程。 + +## I4 降级时**不写** marker + +方案 A2 原说「降级写出的 marker 其 `glibcLib` 为空,身份可知后会自动重跑」。实现时 +改为**根本不写 marker**:让「什么都没做」有机会被读成「已经做过」是不必要的风险, +而不写 marker 的代价只是每次构建重新判断一次(纯内存)。单测 +`ASkippedFixupLeavesNoMarkerBehind` 钉住这一点。 diff --git a/README.md b/README.md index 80426bd7..54d1b59f 100644 --- a/README.md +++ b/README.md @@ -334,13 +334,21 @@ data: [`standard-20260814-linux-x86_64`](bench/results/standard-20260814-linux-x skips the cascade when the interface is unchanged. This is default behaviour and requires no configuration. The `mcpp (old)` column measures the previous release at 81.72s, level with cmake, so the effect is new in this revision. -* **`edit-body` is the control case**, and it is the row where the cascade is - genuinely owed: mcpp is 1.1x rather than 200x, and an engine faster here would - be omitting work. `+opt` does not omit it either — it performs the same work - 2.9x faster. Worth stating precisely: the perturbation **inserts a line**, and - under GCC that shifts the recorded source location of every declaration after - it, which changes the BMI. The cascade follows from the changed BMI, not from - the edited body — measured in +* **`edit-body` measures the case where the cascade is genuinely owed** — and + whether an edit owes one depends on where the body lives: + + | the function body is in… | editing it | this row | + |---|---|---| + | a `.cppm`, and the edit **moves lines** | GCC records declaration positions, so the BMI changes → cascade owed | **what is measured: 1.1x, and 2.9x with `+opt`** | + | a `.cppm`, edited **in place** (same line count) | GCC does not serialise non-template bodies → BMI unchanged → no cascade | ~200x, like `touch-hub` | + | a separate `.cpp` implementation unit | that file has no BMI at all → no cascade, on every compiler | ~200x | + + The perturbation here inserts a statement, so it takes the first row: every + engine has to rebuild the importers, and one that did not would be skipping + work. `+opt` does not skip it either — it does the same work 2.9x faster. + Splitting interface from implementation is the sturdiest of the three, because + it does not depend on GCC's body handling or on avoiding line shifts. + Measured in [`.agents/docs/2026-08-15-module-edit-granularity.md`](.agents/docs/2026-08-15-module-edit-granularity.md). * **`bmi_schedule` is opt-in and disabled by default** (`auto` resolves to off). It moves code generation off the critical path, so it helps only where a diff --git a/README.zh-CN.md b/README.zh-CN.md index 1ab445d5..de55f3f8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -327,10 +327,18 @@ cmake 4.4.2 / xmake 3.1.0 · `-` 表示未测,本表没有 · cmake 与 xmake 按时间戳判断,重编全部下游单元;mcpp 将编译器刚产出的 BMI 与上 一份比较,接口未变则不触发级联。这是默认行为,无需任何配置。`mcpp (旧版)` 一列 测得上一个发布版为 81.72s,与 cmake 同量级,因此该效果在本版本中才生效。 -* **`edit-body` 为对照组**,也是级联确实欠着的那一行:mcpp 为 1.1x 而非 200x, - 在这一行更快的引擎意味着省略了应做的工作。`+优化` 同样不省略,只是把同一份工作 - 加快 2.9 倍。有一点需要说准:该扰动是**插入一行**,而 GCC 会因此移动其后所有声明 - 的源码位置记录,BMI 随之改变。级联来自变化后的 BMI,而非被编辑的函数体 —— 实测见 +* **`edit-body` 量的是级联确实欠着的那种改动** —— 一次改动欠不欠级联,取决于函数体 + 写在哪里: + + | 函数体所在 | 改动它 | 对应本行 | + |---|---|---| + | `.cppm`,且改动**移动了行号** | GCC 在 BMI 里记录声明位置,BMI 随之改变 → 欠级联 | **本行所测:1.1x,`+优化` 2.9x** | + | `.cppm`,**原地等长**修改 | GCC 不序列化非模板函数体 → BMI 不变 → 不级联 | 约 200x,与 `touch-hub` 同档 | + | 独立的 `.cpp` 实现单元 | 该文件根本不产生 BMI → 不级联,且跨编译器成立 | 约 200x | + + 这里的扰动插入一条语句,因此落在第一行:所有引擎都必须重建导入者,更快只能意味着 + 漏做。`+优化` 也不漏做,只是把同一份工作加快 2.9 倍。三者中**接口与实现分离最稳**, + 因为它既不依赖 GCC 对函数体的处理方式,也不依赖你避免行号移动。实测见 [`.agents/docs/2026-08-15-module-edit-granularity.md`](.agents/docs/2026-08-15-module-edit-granularity.md)。 * **`bmi_schedule` 为 opt-in,默认关闭**(`auto` 解析为 off)。它将代码生成移出关键 路径,因此仅在级联必需时有效:`cold` 86.69s → 35.73s、`edit-body` 80.87s → diff --git a/bench/README.md b/bench/README.md index 16f79b53..e9e379b0 100644 --- a/bench/README.md +++ b/bench/README.md @@ -213,7 +213,7 @@ than what it said. | bazel | **9.2.0** | `matrix.json` → `tools` | | gcc | **16.1.0** | `bench/src/toolchain.cppm` | | clang / libc++ | **22.1.8** (Windows: 20.1.7) | `bench/src/toolchain.cppm` | -| reference mcpp | **2026.8.11.3** | `matrix.json` → `reference_mcpp` | +| reference mcpp | **2026.8.11.3** | `matrix.json` → `reference_mcpp`; the run records which release it actually resolved in `meta.json`, and the report names it in the column header | | mcpp (the workload) | **2026.8.11.3** — `a749e9f` | submodule `projects/mcpp/mcpp-2026.8.11.3` | | xlings (combined style) | **2026.8.11.2** — `b1563fe` | submodule `projects/xlings/xlings-2026.8.11.2` | | xlings (split style) | **2026.8.13.1** — `f072075` | submodule `projects/xlings/xlings-2026.8.13.1` | @@ -325,9 +325,12 @@ Four things this says, and the fixture can say none of them: touching a hub interface costs cmake and xmake a full 83-second rebuild because they decide by timestamp, and 0.40s for an engine that compares the BMI it just produced against the previous one. -4. **`edit-body` is the control.** mcpp is deliberately *not* fast there (0.89x): - the interface genuinely changed, so the cascade is owed. An engine that were - fast on that row would have skipped work it owed. +4. **`edit-body` is the control.** mcpp is deliberately *not* fast there + (0.89x): the perturbation inserts a statement into an interface unit, which + moves the source position GCC records for every declaration after it, so the + BMI changes and the cascade is owed. An engine that were fast on that row + would have skipped work it owed. A body edit that does NOT move lines, or one + in a separate `.cpp`, owes no cascade and mcpp skips it — see §7. > **The xmake column is from a SEPARATE run.** Its numbers in the original > five-arm run were invalid — xmake normalises `--buildir` to a path relative to diff --git a/bench/README.zh-CN.md b/bench/README.zh-CN.md index 8ceede65..a559c730 100644 --- a/bench/README.zh-CN.md +++ b/bench/README.zh-CN.md @@ -203,7 +203,7 @@ __format/format_functions.h:99:30: error: call to implicitly-deleted default | bazel | **9.2.0** | `matrix.json` → `tools` | | gcc | **16.1.0** | `bench/src/toolchain.cppm` | | clang / libc++ | **22.1.8**(Windows:20.1.7) | `bench/src/toolchain.cppm` | -| 参照 mcpp | **2026.8.11.3** | `matrix.json` → `reference_mcpp` | +| 参照 mcpp | **2026.8.11.3** | `matrix.json` → `reference_mcpp`;每次跑把实际解析到的版本写进 `meta.json`,报告的表头直接写出它 | | mcpp(被测工作负载) | **2026.8.11.3** — `a749e9f` | 子模块 `projects/mcpp/mcpp-2026.8.11.3` | | xlings(合并风格) | **2026.8.11.2** — `b1563fe` | 子模块 `projects/xlings/xlings-2026.8.11.2` | | xlings(分离风格) | **2026.8.13.1** — `f072075` | 子模块 `projects/xlings/xlings-2026.8.13.1` | @@ -278,12 +278,15 @@ SNAPSHOT」。 | `noop` | 什么都不动 | 「已经是最新」有多便宜 | | `touch-hub` | 给被大量 import 的单元改 mtime,**内容不变** | 引擎能不能证明接口没变? | | `edit-comment` | 往同一个单元里插一条注释 | 字节**确实**变了但接口没变 —— 只有比较产出 BMI 的引擎能止住级联 | -| `edit-body` | 函数体内部一处真实语义修改 | 日常循环。接口单元里的内联函数体,BMI 合理地变了,级联是**对的** | +| `edit-body` | 函数体内部一处真实语义修改(插入一条语句) | 日常循环。改动移动了行号,接口单元的 BMI 因此改变,级联是**对的** | | `touch-leaf` | 给没人 import 的单元改 mtime | 重编 1 个 + 链接 | `edit-comment` 与 `edit-body` 是**刻意分开**的:不分开的话,一个能跳过纯注释重建 的引擎就可以宣传成「改代码快 12 倍」,而那实际上是一句关于注释的话。 `edit-body` 是反方向的对照 —— 那里没有引擎应该快,快了就是漏了该做的活。 +一次函数体修改欠不欠级联,取决于**函数体写在哪里**、以及改动是否移动行号: +`.cppm` 里移动行号 ⇒ 欠;`.cppm` 里原地等长修改 ⇒ 不欠;独立 `.cpp` ⇒ 不欠, +且跨编译器成立。详见 `SPEC.md`。 --- diff --git a/bench/SPEC.md b/bench/SPEC.md index 5e7e9912..d27a17aa 100644 --- a/bench/SPEC.md +++ b/bench/SPEC.md @@ -62,6 +62,15 @@ table that was measuring something other than what it said: | the workloads | git submodules under `bench/projects/` | xlings was cloned from its default branch at run time (`--hub src/xlings.cppm` named a file that had stopped existing); **mcpp's own sources were the checkout**, so every commit on a branch changed the thing being measured | | the reference mcpp | `matrix.json.reference_mcpp` | a report said how fast this branch is, never whether it got faster | +> The reference pin is **not** required to equal the `.xlings.json` workspace +> pin. A guard once required that, on the theory that the reference arm is +> whatever CI bootstraps; neither half holds (the standard set runs on a +> developer box, and the bootstrap pin is a self-hosting floor that may lag a +> release), and bumping the pin after a release turned every e2e shard red on +> `main`. `run-standard.sh` resolves the arm by exact version and requires the +> binary to report that version itself, so a mismatch drops the column with a +> note instead of measuring the wrong release. + `--compiler payload:gcc` / `payload:clang` is the spelling that delivers the second row: it resolves to the driver **inside mcpp's own registry**, so every engine is handed the same binary. That is the suite's fairness rule @@ -233,7 +242,7 @@ job: the cell still runs, and its note says what to distrust. | `noop` | nothing | how cheap is "already up to date" | | `touch-hub` | mtime bump on a widely-imported unit, **content unchanged** | can the engine prove the interface did not change? | | `edit-comment` | a comment inserted into that same unit | the bytes *did* change but the interface did not — only an engine that compares the produced BMI avoids the cascade | -| `edit-body` | a real semantic edit inside a function body | the everyday loop — and whether a cascade is owed depends on **where the body lives**, not on the edit. See below. | +| `edit-body` | a real semantic edit inside a function body | the everyday loop — and whether a cascade is owed depends on **where the body lives and whether the edit moves lines**, not on what the body now does. See below. | | `touch-leaf` | mtime bump on a unit nobody imports | recompile 1 + link | #### ⚠️ `edit-body` perturbs a DIFFERENT FILE in each variant, and the two ask @@ -301,25 +310,52 @@ Measured directly, GCC 16.1, comparing the BMI before and after: | what is edited | BMI | cascade | |---|---|---| -| a free exported function's body, in the `.cppm` | **byte-identical** | not owed | -| a **member function of an exported class**, inline in the `.cppm` | **differs** | **owed** | -| a body in a separate `.cpp` implementation unit | **byte-identical** | **not owed** | - -A class's member function bodies are part of the class definition, which every -importer has to see, so they are serialised into the BMI. A free function's body -is not, and nothing in an implementation unit is. - -So "editing one function rebuilt forty modules" is not inherent to named modules -— it is a consequence of where the body was written. `mcpp`'s own -`src/version_req.cppm` is the first case (the perturbation lands in -`Version::str()`, a member of an exported class), which is why its `edit-body` -row is a near-full rebuild and why that is correct. +| a body in a `.cppm`, edit **moves lines** (inserts or deletes one) | **differs** | **owed** | +| a body in a `.cppm`, edited **in place** (same line count) | **byte-identical** | not owed | +| a body in a separate `.cpp` implementation unit | **no BMI exists** | not owed | + +GCC 16.1 does not serialise non-template function bodies, so changing what a +body *does* is invisible to importers. What it does serialise is the source +position of each declaration — so inserting a line moves every declaration +after it and the BMI changes for that reason alone. + +⚠️ **An earlier version of this section said the deciding factor was whether the +body belonged to an exported class.** That was reasoning, and the measurement +refuted it: editing `Version::str()` — a member of an exported class — in place +rebuilt its object and left the BMI byte-identical, so no importer was touched. +The deciding factor is line movement, not class membership. + +Two consequences: + +* "editing one function rebuilt forty modules" is not inherent to named modules. + It follows from the edit moving lines in an interface unit. +* the third row is the sturdiest, because it holds for **every** compiler and + for every edit: a `.cpp` implementation unit produces no BMI, so nothing + downstream can depend on its contents. Clang, whose BMI carries more than + GCC's, cascades on an in-place body edit in a `.cppm` but not on a `.cpp`. **This is what the two xlings pins measure.** Moving the implementations out of the interface units takes `edit-body` from 88.33s to **1.77s** on the same project — ~50x, the largest single effect anywhere in this suite, and a code style rather than an engine feature. +#### KNOWN GAP: there is no scenario for an in-place body edit + +The three rows in the table above are not equally covered. `edit-body` inserts a +statement, so only the **first** row is ever measured; the second — a semantic +edit that keeps the line count — has no scenario at all. + +That is the everyday case, and it is the only one that would show cascade +suppression on a *real code change* rather than on a timestamp (`touch-hub`) or +a comment (`edit-comment`). Its absence makes the published tables read as +though the effect applies only when the code does not change, which understates +it. + +Closing it is a `replace_in_first_body` beside `insert_into_first_body` (an +equal-length substitution, e.g. one integer literal for another of the same +width) plus a scenario token — and a re-run of the standard set, which is why it +is recorded here rather than half-added with no data behind it. + Real projects run five of the six: `touch-leaf` needs a unit nobody imports *and* a stable name for it, which a generated fixture has by construction and a real tree does not. diff --git a/bench/run-standard.sh b/bench/run-standard.sh index cb19864d..ccf6b447 100755 --- a/bench/run-standard.sh +++ b/bench/run-standard.sh @@ -255,6 +255,32 @@ echo "standard set: $(printf '%s\n' "$PLAN" | wc -l) cells, ${RUNS} run(s) each, echo "output : ${OUT#"$ROOT"/}" echo +# WHICH RELEASE THE `mcpp (old)` COLUMN ACTUALLY MEASURED, recorded rather than +# asserted. `matrix.json` states a REQUEST; this file states the OUTCOME — the +# path that was resolved and the version that binary reported about ITSELF (the +# loop above refuses any binary whose `--version` disagrees). +# +# This replaces a guard that required `reference_mcpp` to equal the `.xlings.json` +# bootstrap pin. Making the report self-describing removes the drift instead of +# policing it: a reader never has to hold two files in their head to know what +# the "old" column is, and the two pins are free to be what they each are. +mkdir -p "$OUT" +python3 - "$OUT/meta.json" "$REFERENCE_MCPP" "$REF_BIN" "$UNDER_TEST" <<'PY' +import json, sys +out, requested, path, under_test = sys.argv[1:5] +json.dump({ + "schema": 1, + "reference_mcpp": { + "requested": requested or None, + # null ⇒ not resolved on this machine; the old-vs-new column is absent + # from the report rather than silently filled by another release. + "measured": (requested if path else None), + "binary": path or None, + }, + "under_test": under_test or None, +}, open(out, "w", encoding="utf-8"), indent=2) +PY + # ⚠️ NOT `printf ... | while`. A pipeline runs its right-hand side in a SUBSHELL, # so a failure counter incremented inside the loop does not survive it and the # script exits 0 no matter what happened. Verified with a stub engine that exits diff --git a/bench/tools/report.py b/bench/tools/report.py index 23b930ee..63b0b75e 100755 --- a/bench/tools/report.py +++ b/bench/tools/report.py @@ -234,7 +234,15 @@ def short_name(engine, newest, lang="en"): return "mcpp +优化" if lang == "zh" else "mcpp +opt" if base == newest: return "mcpp" - return "mcpp (旧版)" if lang == "zh" else "mcpp (old)" + # ⚠️ NAME THE RELEASE, do not write "old". The version is already in the + # engine key — it is the version that binary reported about ITSELF — so a + # header that hides it is throwing away the one fact that makes the column + # checkable. A guard used to enforce this from outside, by requiring + # `matrix.json`'s `reference_mcpp` to equal the `.xlings.json` bootstrap + # pin; that coupled a bench knob to the release pipeline and turned `main` + # red when the pin moved. Printing what was measured removes the drift + # instead of policing it. + return "mcpp " + base[len("mcpp@"):] def columns_legend(short, engines, lang): diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 1a7f4b78..9e025c6f 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -330,6 +330,13 @@ The **compile** phase is not bounded, only the build *program*. See runs it. It is a **distribution** property, not a build one — it describes the runtime dependency set, and the flags that deliver it differ per platform. +> **A target with no C++ in it has no C++ runtime contract to honour.** mcpp +> links such a target with the C driver and leaves the C++ runtime flags off the +> line entirely, so a pure-C shared library does not acquire a dependency on +> `libstdc++`/`libc++` it has no use for. One C++ translation unit anywhere in +> the target puts the whole target back on the C++ driver. This is derived from +> the sources — there is no key for it. + ```toml [build] cxx_runtime = "self-contained" # applies to every target (the default) diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 6ee1bc2d..01bdf315 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -292,6 +292,11 @@ MCPP_BUILD_PROGRAM_TIMEOUT=<秒> 本次调用(最高) `cxx_runtime` 声明的是**产物对运行它的机器做出的承诺**。它是**分发**属性而非 构建属性 —— 它描述的是运行期依赖集,而兑现它的 flag 逐平台不同。 +> **不含 C++ 的目标没有 C++ 运行时契约需要兑现。** mcpp 用 C 驱动链接它, +> 并且完全不发 C++ 运行时相关的 flag,因此一个纯 C 的共享库不会平白拿到 +> `libstdc++` / `libc++` 依赖。目标里只要有一个 C++ 翻译单元,整个目标就回到 +> C++ 驱动。这一判定由源码推导,没有对应的配置键。 + ```toml [build] cxx_runtime = "self-contained" # 作用于所有目标(默认值) diff --git a/mcpp.toml b/mcpp.toml index 88acf547..9ecd880a 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.15.1" +version = "2026.8.15.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/distribution.cppm b/src/build/distribution.cppm index bbc3e3ee..3b4c667f 100644 --- a/src/build/distribution.cppm +++ b/src/build/distribution.cppm @@ -240,6 +240,14 @@ struct Mechanism { // Flags for this link unit, each with a leading space. Per-unit rather // than global precisely so two roles in one build can differ. std::string unitFlags; + // The subset that is NOT a statement about the C++ runtime, for a link + // unit with no C++ in it (mcpp#426). Accumulated HERE, beside the flags + // themselves, rather than filtered downstream: which of these is a C++ + // decision is knowledge this table has and a string filter would have to + // re-derive. On macOS the difference is not cosmetic — the self-contained + // contract names `libc++.a`/`libc++abi.a` by path, so a pure-C library + // would otherwise have the C++ runtime linked INTO it. + std::string unitFlagsC; Contract effective = Contract::SelfContained; // effective != requested. `diagnostic` is then non-empty and the caller // is required to surface it — see INV-1/INV-4 in the analysis doc. @@ -410,10 +418,15 @@ Mechanism resolve(const MechanismInput& in) { // keeps it regardless of the C++ runtime contract. const bool wantStatic = m.effective == Contract::SelfContained || in.fullStaticLibc; - if (wantStatic) m.unitFlags += " -static"; + // `-static` and `-static-libgcc` are libc / compiler-runtime + // decisions, not C++ ones: a self-contained pure-C DLL wants both. + if (wantStatic) { m.unitFlags += " -static"; m.unitFlagsC += " -static"; } if (m.effective == Contract::SelfContained) { m.unitFlags += " -static-libstdc++"; - if (in.hostIsWindows) m.unitFlags += " -static-libgcc"; + if (in.hostIsWindows) { + m.unitFlags += " -static-libgcc"; + m.unitFlagsC += " -static-libgcc"; + } } return m; } diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 61a650d5..758e88d1 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -35,6 +35,16 @@ struct CompileFlags { std::string as; // asm-safe subset for .S/.s via the C driver std::string nasm; // NASM global flags (.asm; own spelling) std::string ld; // ldflags string + // The same link line for a unit with NO C++ in it (mcpp#426). Linking a + // pure-C library with the C++ driver gave it `NEEDED libstdc++.so.6`, + // `libm.so.6` and `libgcc_s.so.1` with not one symbol referencing them — + // measured: the C driver leaves exactly `libc.so.6`. + // + // Produced in the SAME expression as `ld`, with only the C++ runtime + // tokens elided, so `ld` itself is unchanged by construction rather than + // by testing. Swapping the driver alone is not enough: `-lstdc++exp` is + // named explicitly and would survive it. + std::string ldC; // The LAST-RESORT run-time search path (today: the SubOS library view). // NOT part of `ld`, and that is the whole point: `ld` is rendered BEFORE // the per-unit flags, and the per-unit flags are where the artifact's own @@ -57,6 +67,10 @@ struct CompileFlags { // `static_stdlib = false` could not express for test binaries before #336. // Produced by exactly one call to `dist::resolve` per role. std::array ldStdlibByRole{}; + // The same, for a link unit with no C++ in it (mcpp#426). Comes from the + // contract table's own `unitFlagsC`, so "is this flag a C++ decision" is + // answered where the flag is written. + std::array ldStdlibCByRole{}; // The contract each role actually got (after any degradation). std::array contractByRole{}; @@ -72,6 +86,9 @@ struct CompileFlags { const std::string& ldStdlibFor(mcpp::build::dist::Role r) const { return ldStdlibByRole[static_cast(r)]; } + const std::string& ldStdlibCFor(mcpp::build::dist::Role r) const { + return ldStdlibCByRole[static_cast(r)]; + } }; enum class LinkIntentFlavor { Elf, MachO, PeGnu, PeMsvc }; @@ -444,6 +461,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { std::string compile_toolchain_flags; std::string link_toolchain_flags; + std::string link_toolchain_flags_c; // same, minus C++ runtime selection const bool isClangWithCfg = dm.hasCfg; // LLVM root of a clang-with-cfg toolchain — used by the macOS link // path below to locate libc++.a/libc++abi.a for staticStdlib. @@ -479,6 +497,8 @@ CompileFlags compute_flags(const BuildPlan& plan) { link_toolchain_flags = " --no-default-config"; if (lm.mode == mcpp::toolchain::CLibMode::Sysroot) link_toolchain_flags += lm.link_flags(ninjaEsc); + link_toolchain_flags_c = link_toolchain_flags + + std::string(mcpp::toolchain::ClangDriverModel::kLinkDriverFlagsC); link_toolchain_flags += mcpp::toolchain::ClangDriverModel::kLinkDriverFlags; f.sysroot = link_toolchain_flags; @@ -486,6 +506,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { // GCC (or Clang without cfg): --sysroot from probe, or the payload // headers + C runtime (-B for crt discovery, -L for -lc/-lm). link_toolchain_flags = lm.link_flags(ninjaEsc); + link_toolchain_flags_c = link_toolchain_flags; // nothing C++-only here f.sysroot = link_toolchain_flags; } @@ -874,7 +895,8 @@ CompileFlags compute_flags(const BuildPlan& plan) { mi.explicitRequest = wasAsked; auto r = dist::resolve(mi); auto i = static_cast(role); - f.ldStdlibByRole[i] = r.unitFlags; + f.ldStdlibByRole[i] = r.unitFlags; + f.ldStdlibCByRole[i] = r.unitFlagsC; f.contractByRole[i] = r.effective; if (r.streamInitShim) f.needsStreamInitShim = true; if (!r.diagnostic.empty() && role_is_built(role)) @@ -965,6 +987,9 @@ CompileFlags compute_flags(const BuildPlan& plan) { mingw_stdexp = " -lstdc++exp"; f.ld = std::format("{}{}{}{}", link_intent_ld, user_ldflags, mingw_stdexp, link_extra); + // `-lstdc++exp` is named explicitly, so swapping g++ for gcc would not + // drop it — the C line has to leave it out. + f.ldC = std::format("{}{}{}", link_intent_ld, user_ldflags, link_extra); return f; } @@ -976,6 +1001,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { // not apply. f.ldBinary = mcpp::toolchain::link_tool(plan.toolchain); f.ld = link_intent_ld + user_ldflags; + f.ldC = f.ld; // link.exe: no driver, nothing implicit to elide return f; } // PE link, MSVC-ABI Clang (native MinGW is handled by the target-keyed @@ -1017,6 +1043,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { // the response file is ours, and 2026.8.5.3 already fixed it. f.ld = std::format(" -fuse-ld=lld{}{}{}", link_intent_ld, user_ldflags, link_extra); + f.ldC = f.ld; // no C++ runtime token on this line } else if constexpr (mcpp::platform::needs_explicit_libcxx) { // macOS. The C++ runtime itself is decided by the contract table above // (dist::Format::MachO) and rides unit_ldflags; what is left here is @@ -1058,6 +1085,9 @@ CompileFlags compute_flags(const BuildPlan& plan) { f.ld = std::format("{}{}{} -fuse-ld=lld{}{}{}{}", full_static, b_flag, macos_sdk, version_min, link_intent_ld, user_ldflags, link_extra); + // macOS decides the C++ runtime in the contract table (MachO), which + // rides unit_ldflags — this line has nothing C++-only on it. + f.ldC = f.ld; } else { // libatomic: 16-byte / oversized std::atomic needs the out-of-line // __atomic_* libcalls from libatomic, which the driver won't add on @@ -1070,6 +1100,13 @@ CompileFlags compute_flags(const BuildPlan& plan) { link_toolchain_flags, b_flag, runtime_dirs, link_intent_ld, atomic_ld, payload_ld, user_ldflags, link_extra); + // Same expression, same operands, one substitution: `-stdlib=libc++` + // is the only C++-only token that reaches this line. `-static` and + // `-latomic` are NOT C++ decisions and stay. + f.ldC = std::format("{}{}{}{}{}{}{}{}{}", full_static, + link_toolchain_flags_c, b_flag, runtime_dirs, + link_intent_ld, atomic_ld, payload_ld, + user_ldflags, link_extra); } return f; diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 8674883c..a6acad5e 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -483,6 +483,11 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(std::format("rcflags ={}\n", rcf)); } append(std::format("ldflags ={}\n", flags.ld)); + // mcpp#426: the same line for a link unit with no C++ in it. ALWAYS + // emitted, even when identical — `c_link` references `$c_ldflags`, and a + // conditional definition would make an empty link line the failure mode on + // exactly the toolchains where the two happen to agree. + append(std::format("c_ldflags ={}\n", flags.ldC)); // `ar` for cxx_archive. if (!flags.arBinary.empty()) { @@ -1024,6 +1029,18 @@ std::string emit_ninja_string(const BuildPlan& plan) { link_rule("cxx_shared", "$cxx -shared $in -o $out $ldflags $soname_flag $unit_ldflags", "SHARED"); + // mcpp#426: a link unit with no C++ translation unit in it is + // linked by the C driver. `g++` appends `-lstdc++` unconditionally, + // and mcpp uses `--as-needed` in exactly one place (`-latomic`), so + // a pure-C shared library came out with NEEDED libstdc++.so.6, + // libm.so.6 and libgcc_s.so.1 against zero referencing symbols. + // Measured with the C driver on the same object and the same + // flags: libc.so.6, and nothing else. + link_rule("c_link", + "$cc $in -o $out $c_ldflags $unit_ldflags", "LINK"); + link_rule("c_shared", + "$cc -shared $in -o $out $c_ldflags $soname_flag $unit_ldflags", + "SHARED"); } } @@ -1177,6 +1194,32 @@ std::string emit_ninja_string(const BuildPlan& plan) { } return false; }; + + // mcpp#426: does this link unit contain any C++ at all? + // + // ⚠️ THE UNKNOWN CASE IS THE OPPOSITE OF `unit_needs_std`'s. That one treats + // an object it cannot find as "does not need std" — a missing `std.o` is + // recoverable. Here a wrong answer is an undefined-symbol link failure, and + // objects produced by an `action` (prepare_actions) are NOT in + // `plan.compileUnits` and have no declared language. Unknown ⇒ C++. + // + // No separate dependency walk is needed: a `kind = "lib"` dependency's + // objects are appended into `lu.objects` by `append_package_objects`, so + // their language is already in this table. A shared-library dependency + // carries its own NEEDED and does not decide this unit's driver. + std::unordered_map objectIsCxx; + for (auto& cu : plan.compileUnits) + objectIsCxx[cu.object.generic_string()] = + cu.kind == mcpp::SourceKind::ModuleInterface + || cu.kind == mcpp::SourceKind::Cxx; + + auto unit_needs_cxx_runtime = [&](const LinkUnit& lu) { + for (auto& o : lu.objects) { + auto it = objectIsCxx.find(o.generic_string()); + if (it == objectIsCxx.end() || it->second) return true; + } + return false; + }; if (has_std_artifacts) { append(std::format("build {} : stage_file {}\n", escape_ninja_path(std_bmi_dst), escape_ninja_path(plan.stdBmiPath))); @@ -1688,25 +1731,34 @@ std::string emit_ninja_string(const BuildPlan& plan) { ins += " " + escape_ninja_path(o); } + // mcpp#426. `separateLinker` (link.exe) takes no driver at all, so the + // question does not arise there and the msvc rules are untouched. + const bool cxxUnit = separateLinker || unit_needs_cxx_runtime(lu); + // Both std objects are C++ translation units, so a unit that takes + // either of them IS a C++ unit — asserted rather than assumed, because + // the two predicates are computed independently. + const bool takesStd = cxxUnit && has_std_artifacts && unit_needs_std(lu); + // ⚠️ `std.compat.o` had no `unit_needs_std` narrowing at all: mcpp#416 + // fixed `std.o` and left its neighbour unconditional, which put a C++ + // TU's global initialiser into every link unit whose toolchain merely + // HAD a prebuilt std.compat. Same predicate, same reason. + const bool takesCompat = cxxUnit && has_std_compat && unit_needs_std(lu); + std::string rule; switch (lu.kind) { case LinkUnit::Binary: case LinkUnit::TestBinary: - if (has_std_artifacts && unit_needs_std(lu)) - ins += " " + escape_ninja_path(std_o_dst); - if (has_std_compat) - ins += " " + escape_ninja_path(compat_o_dst); - rule = "cxx_link"; + if (takesStd) ins += " " + escape_ninja_path(std_o_dst); + if (takesCompat) ins += " " + escape_ninja_path(compat_o_dst); + rule = cxxUnit ? "cxx_link" : "c_link"; break; case LinkUnit::StaticLibrary: - rule = "cxx_archive"; + rule = "cxx_archive"; // `ar`: no driver, no runtime break; case LinkUnit::SharedLibrary: - if (has_std_artifacts && unit_needs_std(lu)) - ins += " " + escape_ninja_path(std_o_dst); - if (has_std_compat) - ins += " " + escape_ninja_path(compat_o_dst); - rule = "cxx_shared"; + if (takesStd) ins += " " + escape_ninja_path(std_o_dst); + if (takesCompat) ins += " " + escape_ninja_path(compat_o_dst); + rule = cxxUnit ? "cxx_shared" : "c_shared"; break; } std::string implicit; @@ -1743,7 +1795,13 @@ std::string emit_ninja_string(const BuildPlan& plan) { // of libX11 than it was linked against. mcpp::build::link_line::UnitTail tail; tail.dependencies = join_flags(lu.linkFlags); - tail.cxxRuntime = flags.ldStdlibFor(role_of(lu.kind)); + // mcpp#426: a link unit with no C++ in it takes only the part of + // the contract that is not a statement about the C++ runtime. + // Swapping the driver is not sufficient by itself — this slot names + // `libc++.a` by path on macOS and `-static-libstdc++` on MinGW. + tail.cxxRuntime = cxxUnit + ? flags.ldStdlibFor(role_of(lu.kind)) + : flags.ldStdlibCFor(role_of(lu.kind)); // An archive has no run-time search path of its own: `ar` never // reads `$unit_ldflags`, so rpath flags there would be dead bytes // in every graph that builds a static library. diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 17a2e7e7..8ebc05d7 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -974,6 +974,30 @@ prepare_build(bool print_fingerprint, const auto runtimeLibDir = runtimeBindingSnapshot.libraryDirs.empty() ? std::filesystem::path{} : runtimeBindingSnapshot.libraryDirs.front(); + // mcpp#427: a toolchain fixup that could not run is a DEGRADATION, not a + // failure — the build continues without it. But it has to be said, or the + // eventual `stdlib.h: No such file or directory` arrives with no way to + // connect it to its cause. + // + // Deduplicated by payload: `ensure_post_install_fixup` is called from up + // to four seams in one build (manifest toolchain, default toolchain, + // MinGW first-run, build.mcpp host toolchain) and they routinely resolve + // the SAME payload. Saying it once is the rule mcpp#417 already paid for. + auto fixupNoticed = std::make_shared>(); + auto report_fixup = [fixupNoticed]( + const mcpp::toolchain::FixupOutcome& outcome, + const std::filesystem::path& payloadRoot) { + if (outcome.skippedReason.empty()) return; + if (!fixupNoticed->insert(payloadRoot.generic_string()).second) return; + mcpp::ui::info("Toolchain", std::format( + "not bound to a C runtime: {}.\n" + " The toolchain is used as installed. If a compile later " + "reports a missing libc header, or the hermeticity check refuses " + "the link, this is why — `xlings self update` writes the SubOS " + "description that supplies it.", + outcome.skippedReason)); + }; + constexpr std::string_view kCurrentPlatform = mcpp::platform::name; // M5.5: toolchain resolution priority: @@ -1289,6 +1313,7 @@ prepare_build(bool print_fingerprint, runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) return std::unexpected(std::format( "toolchain post-install fixup: {}", fixed.error())); + else report_fixup(*fixed, payload->root); // Canonical rendering, whatever spelling the manifest/config used: // "Resolved gcc@16.1.0 → x86_64-linux-musl → ". mcpp::ui::info("Resolved", @@ -1438,6 +1463,7 @@ prepare_build(bool print_fingerprint, runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) return std::unexpected(std::format( "default toolchain post-install fixup: {}", fixed.error())); + else report_fixup(*fixed, payload->root); // Persist the default so we don't ask again next time. if (auto wr = mcpp::config::write_default_toolchain(**cfg, defaultSpec); wr) { @@ -1562,6 +1588,7 @@ prepare_build(bool print_fingerprint, runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) return std::unexpected(std::format( "MinGW toolchain post-install fixup: {}", fixed.error())); + else report_fixup(*fixed, payloadR->root); // Persist both axes so the repair happens once, not on every build. if (mcpp::config::write_default_toolchain(**cfgR, pins::kFirstRunWinGnu)) @@ -1672,6 +1699,7 @@ prepare_build(bool print_fingerprint, runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) return std::unexpected(std::format( "host toolchain post-install fixup: {}", fixed.error())); + else report_fixup(*fixed, payload->root); auto htc = mcpp::toolchain::detect(frontend); if (!htc) return std::unexpected(htc.error().message); mcpp::ui::info("Resolved", std::format( diff --git a/src/toolchain/lifecycle.cppm b/src/toolchain/lifecycle.cppm index 5ff6db9d..047cebbb 100644 --- a/src/toolchain/lifecycle.cppm +++ b/src/toolchain/lifecycle.cppm @@ -23,6 +23,8 @@ import mcpp.toolchain.post_install; import mcpp.ui; import mcpp.log; import mcpp.platform.xlings; +import mcpp.platform.xlings.runtime_selection; +import mcpp.platform.runtime_binding; namespace mcpp::toolchain { @@ -584,11 +586,38 @@ export int toolchain_install(const mcpp::config::GlobalConfig& cfg, // Post-install fixup (patchelf / specs / cfg regeneration) — ONE // pipeline shared by every toolchain install path, dispatched and // made idempotent inside ensure_post_install_fixup. + // + // ⚠️ RESOLVE THE RUNTIME BINDING HERE, do not let the fixup guess. + // The fixup is a CONSUMER of RuntimeBinding (`prepare.cppm` says so + // where it does the same thing) and it no longer derives an identity + // of its own — so a caller that passes nothing gets no fixup at all. + // `mcpp toolchain install` is the path that needs it MOST: without + // the patchelf/specs wiring a fresh sandbox gcc cannot find the C + // library. There is no project here, so the selection is mcpp's + // default SubOS, which is exactly what `RuntimeSelection{}` means. + std::string runtimeId; + std::filesystem::path runtimeLibDir; + if (auto rb = mcpp::platform::runtime::resolve_runtime_binding( + mcpp::xlings::runtime::RuntimeSelection{}, {}, cfg)) { + runtimeId = rb->runtimeId; + if (!rb->libraryDirs.empty()) runtimeLibDir = rb->libraryDirs.front(); + } // a binding that cannot be resolved degrades below, it does not stop + // the install — same rule the build path follows. + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( - cfg, payload->root, pkg); !fixed) { + cfg, payload->root, pkg, runtimeId, runtimeLibDir); !fixed) { mcpp::ui::error(std::format( "post-install fixup failed: {}", fixed.error())); return 1; + } else if (!fixed->skippedReason.empty()) { + // The user asked for this install explicitly, so the degradation + // is louder here than on a build — but it is still a warning, and + // the install itself succeeded. + mcpp::ui::warning(std::format( + "installed, but not bound to a C runtime: {}.\n" + " Run `xlings self update` to write the SubOS " + "description, then re-run this command to complete the wiring.", + fixed->skippedReason)); } mcpp::ui::status("Installed", diff --git a/src/toolchain/linkmodel.cppm b/src/toolchain/linkmodel.cppm index 1bf69f8e..95ccde5b 100644 --- a/src/toolchain/linkmodel.cppm +++ b/src/toolchain/linkmodel.cppm @@ -188,6 +188,13 @@ struct ClangDriverModel { static constexpr std::string_view kLinkDriverFlags = " -stdlib=libc++ -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind"; + // The same selection for a link that has no C++ in it (mcpp#426). Only + // `-stdlib=` comes off: the compiler runtime and the unwinder are just as + // much a C decision, and dropping them would make a C link resolve + // __udivti3 differently from every other link in the same build. + static constexpr std::string_view kLinkDriverFlagsC = + " -fuse-ld=lld --rtlib=compiler-rt --unwindlib=libunwind"; + // Same, as argv tokens, WITHOUT `-stdlib=libc++`: a driver invocation that // both compiles and links (build.mcpp) already carries it on the compile // side via HostFlagOptions::clangStdlibSelect, and repeating it is noise. diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index cc425330..394755c2 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -20,7 +20,6 @@ import mcpp.toolchain.linkmodel; import mcpp.toolchain.registry; import mcpp.ui; import mcpp.platform.xlings; -import mcpp.platform.xlings.subos_info; namespace mcpp::toolchain { @@ -507,7 +506,24 @@ void llvm_post_install_fixup(const mcpp::config::GlobalConfig& cfg, // Bump when the fixup logic changes so existing installs re-run it. constexpr std::string_view kFixupRev = "hermetic-4-exact-runtime"; -export std::expected +// What the fixup DID, so the caller can decide how loud to be about it. +// +// mcpp#427: this used to be `expected`, and "the runtime identity is +// unknown" was an ERROR inside this function. That made one severity for every +// caller, and it was the wrong one: `mcpp build` died on a machine whose +// default SubOS predates xlings' `subos_info` block — an absence, not a +// contradiction — while the very function this gate protects already degrades +// correctly on an empty `glibcLibDir` (it warns and skips patching). The +// degradation existed and was unreachable. +// +// Severity belongs to the caller. `build` continues; `toolchain install`, +// which the user asked for explicitly, says so at warning level. +export struct FixupOutcome { + bool applied = false; // patching actually ran + std::string skippedReason; // non-empty ⇒ degraded, and why +}; + +export std::expected ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, const std::filesystem::path& payloadRoot, const XimToolchainPackage& pkg, @@ -516,8 +532,9 @@ ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, std::string kind; if (pkg.needsGccPostInstallFixup) kind = "gcc"; else if (pkg.ximName == "llvm") kind = "llvm"; - else return {}; - if constexpr (mcpp::platform::is_windows) return {}; // PE world: no fixups + else return FixupOutcome{}; + if constexpr (mcpp::platform::is_windows) + return FixupOutcome{}; // PE world: no fixups // Ownership guard: payloads inherited via symlink from another MCPP_HOME // are not ours to patch — their owner already ran the fixup, and patching @@ -533,25 +550,31 @@ ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, "skip {} fixup: payload '{}' resolves outside this home — " "inherited payload, owner is responsible for its fixup", kind, payloadRoot.string())); - return {}; + return FixupOutcome{}; } auto xlEnv = mcpp::config::make_xlings_env(cfg); std::filesystem::path glibcLibDir; + std::string skipped; if constexpr (mcpp::platform::is_linux) { + // ⚠️ ONE DERIVATION, AND IT IS NOT THIS FUNCTION'S. + // + // The caller resolves a RuntimeBinding first and hands the identity in; + // `prepare.cppm` says so where it does it ("The fixup is itself a + // consumer of RuntimeBinding: doing it first would recreate #392 by + // letting directory order choose a libc"). This function used to ALSO + // read `/subos/default` when the identity was empty — + // a second derivation that (a) contradicted that architecture, (b) + // consulted a HARDCODED SubOS even when the project selected another + // one, and (c) turned an absent description into a fatal error. + // + // Unknown degrades. A CONTRADICTION — an identity that is declared but + // cannot be honoured — still fails, below. std::string selected(runtimeId); if (selected.empty()) { - auto info = mcpp::xlings::subos::read( - cfg.xlingsHome() / "subos" / "default"); - if (!info.present || info.runtime.empty()) { - return std::unexpected(std::format( - "cannot fix up {} toolchain '{}': default SubOS has no " - "RuntimeBinding identity ({})", - kind, payloadRoot.string(), info.note)); - } - selected = std::move(info.runtime); - } - if (!selectedRuntimeLibDir.empty()) { + skipped = "the selected SubOS declares no runtime identity, so " + "there is no C runtime to bind this toolchain to"; + } else if (!selectedRuntimeLibDir.empty()) { constexpr std::string_view prefix = "glibc@"; auto version = selected.starts_with(prefix) ? std::string_view(selected).substr(prefix.size()) @@ -578,6 +601,14 @@ ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, } } + // Nothing to bind to. Report it and change nothing — patching a payload + // against a runtime we cannot name would be a guess, and a guess here is + // an ABI. NO MARKER IS WRITTEN: "we did nothing" must never be able to + // read back as "already applied", so the day the SubOS learns to describe + // itself the fixup runs on the next invocation. + if (!skipped.empty()) + return FixupOutcome{.applied = false, .skippedReason = std::move(skipped)}; + // Content-fingerprinted marker: a marker whose INPUTS drifted (different // glibc payload, newer fixup logic) re-runs the fixup — "a process once // exited 0" is not evidence the current inputs were ever applied. @@ -593,7 +624,8 @@ ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, try { nlohmann::json actual; is >> actual; - if (actual == expected) return {}; // fixup already applied + if (actual == expected) // fixup already applied + return FixupOutcome{.applied = true, .skippedReason = {}}; } catch (...) { /* corrupt marker → re-run */ } } } @@ -603,7 +635,7 @@ ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, std::ofstream os(markerPath); os << expected.dump(2) << "\n"; - return {}; + return FixupOutcome{.applied = true, .skippedReason = {}}; } diff --git a/src/version.cppm b/src/version.cppm index fd0fe70d..c99e6bc6 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.15.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.15.2"; } // namespace mcpp diff --git a/tests/e2e/233_bench_matrix.sh b/tests/e2e/233_bench_matrix.sh index 4d290a79..301a1d42 100755 --- a/tests/e2e/233_bench_matrix.sh +++ b/tests/e2e/233_bench_matrix.sh @@ -257,19 +257,28 @@ for t in ("cmake", "xmake", "bazel", "gcc", "llvm"): if not re.match(r"^\d+(\.\d+)+$", str(m.get("reference_mcpp", ""))): fail.append("reference_mcpp must be an exact released version — it is the old-vs-new column") -# ...and it must be the version the repository already bootstraps from. -# -# They are the same decision written in two files: `.xlings.json` says which -# released mcpp CI installs, and that installed binary IS the reference arm the -# bench compares against. Let them drift and the "old" column silently becomes -# some other release, with every ratio still looking perfectly reasonable. -xlings_pin = os.path.join(root, ".xlings.json") -if os.path.isfile(xlings_pin): - ws = json.load(open(xlings_pin, encoding="utf-8")).get("workspace", {}).get("mcpp") - if ws and ws != m.get("reference_mcpp"): - fail.append(f"reference_mcpp={m.get('reference_mcpp')} but .xlings.json bootstraps " - f"mcpp {ws} — the reference arm IS the bootstrapped binary, so these " - f"two must agree or the old-vs-new column compares the wrong release") +# ⚠️ WHAT IS DELIBERATELY *NOT* CHECKED HERE: that `reference_mcpp` equals the +# `.xlings.json` workspace pin. +# +# That check existed and was wrong. It read "the reference arm IS the binary CI +# bootstraps", and neither half holds: the standard set runs on a developer box +# (no workflow invokes `bench/run-standard.sh`), where `runtimedir` holds many +# released versions at once and the arm is the one NAMED here; and the bootstrap +# pin is a self-hosting starting point whose criterion is an upper bound, so it +# may legitimately lag the newest release. +# +# The hazard it claimed to prevent — "the old column silently becomes some other +# release" — cannot happen: `run-standard.sh` resolves the arm by exact version +# AND requires the binary to report that version itself, printing a note and +# dropping the column when it cannot. Drift is reported, not measured. +# +# What it DID do was couple a bench knob to the release pipeline: bumping the +# bootstrap pin after a release turned every e2e shard on all three platforms +# red, on `main`, for a file the pin has no authority over. +# +# The COMPILER pins below are a different matter and stay: those two files +# decide *what gets installed* and *which path is handed to the engines*, and a +# mismatch makes every cell fail on a missing directory. # ...and so are the COMPILER pins, for the same reason and with a worse failure. # diff --git a/tests/e2e/237_default_subos_without_info.sh b/tests/e2e/237_default_subos_without_info.sh new file mode 100755 index 00000000..ff819150 --- /dev/null +++ b/tests/e2e/237_default_subos_without_info.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# requires: elf +# A DEFAULT SubOS that predates `subos_info` must not stop the build (#427). +# +# THE REGRESSION THIS PINS +# +# `ensure_post_install_fixup` needs a C runtime to bind a freshly installed +# toolchain payload to. It took that identity from the caller's RuntimeBinding +# and, when the caller had none, read `/subos/default` itself and +# turned a missing description into a hard error: +# +# error: toolchain post-install fixup: cannot fix up gcc toolchain '…': +# default SubOS has no RuntimeBinding identity (… no `subos_info` block) +# +# Shipped in 2026.8.10.x; 2026.8.8.4 was fine. Measured trigger: a SubOS whose +# `.xlings.json` is `{"workspace":{}}` — 16 bytes, written by an xlings older +# than the block. mcpp's own message says as much ("A newer xlings writes this +# block"), i.e. it KNEW this was a version difference and failed anyway. +# +# It is the index-floor rule and mcpp#221 again: DATA THAT IS MISSING OR NEWER +# MUST NOT INVALIDATE THE PROGRAM THAT READS IT. +# +# ⚠️ WHY `221_subos_without_info_still_builds.sh` DOES NOT COVER THIS. +# +# 221 creates a PROJECT-LOCAL SubOS (`[xlings] subos = "bare"` → +# `/.mcpp/.xlings/subos/bare`). The fixup read a HARDCODED +# `/subos/default`. The two never intersect, so 221's empty SubOS +# reached none of this code, and on any machine whose real `default` describes +# itself the gate simply passed. A test for "absence degrades" has to put the +# absence on THE OBJECT THAT IS READ. +# +# WHAT THIS FILE CAN AND CANNOT REACH +# +# The gate returns early for a payload that resolves outside the caller's +# registry ("inherited payload, owner is responsible for its fixup"), and this +# test inherits its toolchain by symlink so it costs no download. So the gate's +# own branches — degrade vs. contradiction, marker vs. no marker — are pinned in +# tests/unit/test_post_install.cpp, where the payload is a real directory inside +# the test's registry. What is asserted HERE is the user-visible contract: the +# build no longer dies on the fixup, and `allow_host_libs` reaches the link. +set -e + +_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +if [ -z "${MCPP:-}" ]; then + MCPP="$(bash "$_root/.github/tools/newest_artifact.sh" "$_root" mcpp 2>/dev/null || true)" + [ -n "$MCPP" ] || { echo "SKIP: no mcpp binary built yet"; exit 0; } +fi +case "$MCPP" in /*) ;; *) MCPP="$_root/$MCPP" ;; esac +[ -x "$MCPP" ] || { echo "FAIL: MCPP=$MCPP is not executable"; exit 1; } + +TMP=$(mktemp -d) +trap "chmod -R u+w $TMP 2>/dev/null; rm -rf $TMP || true" EXIT + +# A HOME of our own. The toolchain payload is inherited from the real one by +# symlink so this test costs no download — the fixup's containment guard skips +# patching an inherited payload anyway, which is exactly the "no patching +# happens" state this test is about. +export MCPP_HOME="$TMP/home" +REAL_HOME="${MCPP_HOME_REAL:-$HOME/.mcpp}" +mkdir -p "$MCPP_HOME/registry/data" "$MCPP_HOME/registry/subos/default" +if [ -d "$REAL_HOME/registry/data/xpkgs" ]; then + ln -s "$REAL_HOME/registry/data/xpkgs" "$MCPP_HOME/registry/data/xpkgs" +else + echo "SKIP: no xpkgs payloads to inherit from $REAL_HOME"; exit 0 +fi +for d in "$REAL_HOME/registry"/*; do + b="$(basename "$d")" + case "$b" in data|subos) continue ;; esac + ln -s "$d" "$MCPP_HOME/registry/$b" 2>/dev/null || true +done + +mkdir -p "$TMP/proj/src" +cd "$TMP/proj" +cat > mcpp.toml <<'EOF' +[package] +name = "nosubosinfo" +version = "0.1.0" + +[toolchain] +default = "gcc@16.1.0" +macos = "llvm@22.1.8" +windows = "llvm@20.1.7" +EOF +echo 'int main() { return 0; }' > src/main.cpp + +# ── 1. absence: the exact 16 bytes an old xlings leaves behind ────────────── +printf '{"workspace":{}}' > "$MCPP_HOME/registry/subos/default/.xlings.json" + +set +e +out="$("$MCPP" build 2>&1)" +rc=$? +set -e +echo "$out" | sed 's/^/ /' + +case "$out" in + *"has no RuntimeBinding identity"*) + echo "FAIL: an undescribed DEFAULT SubOS still kills the build." + echo " Absence leaves some facts unknown; it does not make the" + echo " build wrong. The fixup degrades — it must not decide." + exit 1 ;; +esac +# The build MAY still stop here, but only for a reason that is about the C +# RUNTIME rather than about the toolchain fixup — and that distinction is the +# whole fix. With no declared runtime there is nothing to bind to, mcpp +# declines to guess a glibc version, and the hermeticity guard says so in terms +# the user can act on. Same accepted outcome as 221, and its wording is checked +# here so a future failure cannot inherit this test's blessing. +if [ "$rc" != 0 ]; then + case "$out" in + *"hermetic link check failed"*) ;; + *) + echo "FAIL: mcpp build exited $rc for a reason this test does not" + echo " recognise. An undescribed SubOS may cost hermeticity;" + echo " it must not cost anything else." + exit 1 ;; + esac +fi +echo " absence: no fixup error (exit $rc)" + +# ── 2. ...and with the host runtime allowed, it must BUILD ───────────────── +# The green half. Without it, part 1 could be satisfied by mcpp failing for +# some accepted reason on every platform, which is not what "the build is +# unaffected" means. +# +# ⚠️ THIS IS WHAT WAS BROKEN. In the reported sandbox, `allow_host_libs = true` +# did NOT help: the fixup gate fired before the hermeticity policy was ever +# consulted, so the one escape hatch mcpp documents for this exact situation +# was unreachable. Absence must cost hermeticity and nothing else. +cat >> mcpp.toml <<'EOF' + +[build] +allow_host_libs = true +EOF +rm -rf target +"$MCPP" build > allow.log 2>&1 || { + echo "FAIL: even with allow_host_libs, an undescribed default SubOS blocks" + echo " the build. That is the escape hatch being unreachable." + tail -20 allow.log | sed 's/^/ /' + exit 1; } +echo " absence + allow_host_libs: builds" + +# ── 3. `mcpp toolchain install` survives it too ───────────────────────────── +# The path the fix could most easily have BROKEN: it carries no RuntimeBinding +# of its own, so removing the fallback without also giving it a resolver would +# have made it skip the fixup forever — a silently incomplete install in place +# of a loud failure. See tests/unit/test_post_install.cpp for the gate itself. +set +e +iout="$("$MCPP" toolchain install gcc@16.1.0 2>&1)" +irc=$? +set -e +[ "$irc" = 0 ] || { + echo "FAIL: \`mcpp toolchain install\` exited $irc with an undescribed SubOS" + echo "$iout" | sed 's/^/ /' + exit 1; } +case "$iout" in + *"has no RuntimeBinding identity"*) + echo "FAIL: the install path still turns an absent description into an error" + exit 1 ;; +esac +echo " toolchain install: exit 0" + +echo "237 an undescribed default SubOS costs hermeticity and nothing else OK" diff --git a/tests/e2e/238_c_only_unit_links_with_c_driver.sh b/tests/e2e/238_c_only_unit_links_with_c_driver.sh new file mode 100755 index 00000000..3d109a76 --- /dev/null +++ b/tests/e2e/238_c_only_unit_links_with_c_driver.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# requires: elf +# A link unit with no C++ in it is linked by the C driver (#426). +# +# Every link went through `$cxx`. `g++` appends `-lstdc++` unconditionally, and +# mcpp uses `--as-needed` in exactly one place (`-latomic`, flags.cppm), so a +# PURE C shared library came out depending on the C++ runtime: +# +# $ readelf -d bin/libpurec.so | grep NEEDED +# libstdc++.so.6 ← a library with no C++ in it +# libm.so.6 +# libgcc_s.so.1 +# libc.so.6 +# +# Measured against the C driver on the same object with the same ldflags: +# `libc.so.6`, and nothing else. All three extra entries came from the driver; +# `nm -D --undefined-only` shows the only C++-looking undefined symbol is +# `__cxa_finalize@GLIBC_2.2.5`, which is glibc's, not libstdc++'s. +# +# Nobody's build breaks from this — it is a distribution defect. Anyone +# deploying that .so has to ship a C++ runtime with it, and its runtime closure +# is larger than the code justifies. +# +# ⚠️ BOTH DIRECTIONS. Checking only that libstdc++ disappeared would pass an +# implementation that linked EVERYTHING with the C driver, which turns every +# C++ target into a pile of undefined `_ZSt…`. So a C++ target must still link, +# run, and use the C++ rule. +set -e + +_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +if [ -z "${MCPP:-}" ]; then + MCPP="$(bash "$_root/.github/tools/newest_artifact.sh" "$_root" mcpp 2>/dev/null || true)" + [ -n "$MCPP" ] || { echo "SKIP: no mcpp binary built yet"; exit 0; } +fi +case "$MCPP" in /*) ;; *) MCPP="$_root/$MCPP" ;; esac +[ -x "$MCPP" ] || { echo "FAIL: MCPP=$MCPP is not executable"; exit 1; } + +TMP=$(mktemp -d) +trap "rm -rf $TMP || true" EXIT + +# ⚠️ THREE SEPARATE PROJECTS, not three targets in one. Targets in a single +# package share that package's objects — a `[targets.X] sources` list does not +# fence them off — so a C++ target next door would put its object into the +# pure-C library's link and the predicate under test would never see a C-only +# unit at all. Discovered by writing it the other way first. +tc_block() { + printf '[toolchain]\ndefault = "gcc@16.1.0"\nmacos = "llvm@22.1.8"\nwindows = "llvm@20.1.7"\n' +} + +# ── 1. a pure-C shared library ───────────────────────────────────────────── +mkdir -p "$TMP/purec/src" +{ printf '[package]\nname = "purec"\nversion = "0.1.0"\n\n'; tc_block + printf '\n[targets.purec]\nkind = "shared"\n'; } > "$TMP/purec/mcpp.toml" +cat > "$TMP/purec/src/purec.c" <<'EOF' +int purec_add(int a, int b) { return a + b; } +EOF + +echo "== 1. pure C ==" +( cd "$TMP/purec" && "$MCPP" build --release ) > "$TMP/purec.log" 2>&1 || { + echo "FAIL: the pure-C project did not build"; tail -30 "$TMP/purec.log"; exit 1; } + +NINJA="$(find "$TMP/purec/target" -name build.ninja | head -1)" +grep -qE '^build [^:]*libpurec\.so[^:]*: c_shared ' "$NINJA" || { + echo "FAIL: the pure-C shared library is not linked by the C rule:" + grep -E '^build [^:]*libpurec\.so' "$NINJA" | sed 's/^/ /' + exit 1; } + +SO="$(find "$TMP/purec/target" -name 'libpurec.so*' -type f | head -1)" +[ -n "$SO" ] || { echo "FAIL: no libpurec.so produced"; exit 1; } +for lib in libstdc++.so.6 libm.so.6 libgcc_s.so.1; do + if readelf -d "$SO" | grep -q "\[$lib\]"; then + echo "FAIL: the pure-C library still depends on $lib" + readelf -d "$SO" | grep NEEDED | sed 's/^/ /' + exit 1 + fi +done +readelf -d "$SO" | grep -q '\[libc.so.6\]' || { + echo "FAIL: the pure-C library lost its dependency on libc itself —" + echo " the C driver was not given the link flags it needs." + readelf -d "$SO" | grep NEEDED | sed 's/^/ /' + exit 1; } +# ...and it still WORKS. Fewer NEEDED entries is only an improvement if the +# artifact still resolves and runs. +nm -D --defined-only "$SO" | grep -q ' T purec_add' || { + echo "FAIL: purec_add is no longer exported"; exit 1; } +echo " c_shared, NEEDED = libc only, symbol exported" + +# ── 2. one C++ TU keeps the whole unit on the C++ driver ─────────────────── +mkdir -p "$TMP/mixed/src" +{ printf '[package]\nname = "mixed"\nversion = "0.1.0"\n\n'; tc_block + printf '\n[targets.mixed]\nkind = "shared"\n'; } > "$TMP/mixed/mcpp.toml" +cat > "$TMP/mixed/src/mixed.c" <<'EOF' +int mixed_c(int a) { return a * 2; } +EOF +cat > "$TMP/mixed/src/mixed_cxx.cpp" <<'EOF' +#include +extern "C" int mixed_cxx_len(const char* s) { + return static_cast(std::string(s).size()); +} +EOF + +echo "== 2. mixed C/C++ ==" +( cd "$TMP/mixed" && "$MCPP" build --release ) > "$TMP/mixed.log" 2>&1 || { + echo "FAIL: the mixed project did not build"; tail -30 "$TMP/mixed.log"; exit 1; } +NINJA="$(find "$TMP/mixed/target" -name build.ninja | head -1)" +grep -qE '^build [^:]*libmixed\.so[^:]*: cxx_shared ' "$NINJA" || { + echo "FAIL: a unit containing a C++ translation unit was linked by the C" + echo " driver. One C++ object decides the unit, and this direction" + echo " fails as undefined symbols rather than as a fat binary." + grep -E '^build [^:]*libmixed\.so' "$NINJA" | sed 's/^/ /' + exit 1; } +echo " cxx_shared" + +# ── 3. a C++ binary is untouched, and runs ───────────────────────────────── +mkdir -p "$TMP/cxx/src" +{ printf '[package]\nname = "cxxbin"\nversion = "0.1.0"\n\n'; tc_block; } > "$TMP/cxx/mcpp.toml" +cat > "$TMP/cxx/src/main.cpp" <<'EOF' +import std; +int main() { std::println("cdriver-ok"); return 0; } +EOF + +echo "== 3. C++ binary ==" +( cd "$TMP/cxx" && "$MCPP" build --release ) > "$TMP/cxx.log" 2>&1 || { + echo "FAIL: the C++ project did not build"; tail -30 "$TMP/cxx.log"; exit 1; } +NINJA="$(find "$TMP/cxx/target" -name build.ninja | head -1)" +grep -qE '^build [^:]*bin/cxxbin[^:]*: cxx_link ' "$NINJA" || { + echo "FAIL: the C++ binary is no longer linked by the C++ rule" + grep -E '^build [^:]*bin/cxxbin' "$NINJA" | sed 's/^/ /' + exit 1; } +BIN="$(find "$TMP/cxx/target" -path '*/bin/cxxbin' -type f | head -1)" +OUT="$("$BIN")" || { echo "FAIL: the C++ binary exited $?"; exit 1; } +[ "$OUT" = "cdriver-ok" ] || { echo "FAIL: binary printed '$OUT'"; exit 1; } +echo " cxx_link, runs" + +# ── 4. c_ldflags is always defined ───────────────────────────────────────── +# `c_link`/`c_shared` reference `$c_ldflags`. Defining it only when it differs +# from `$ldflags` would make an EMPTY link line the failure mode on exactly the +# toolchains where the two happen to agree — a link that succeeds and produces +# something unrunnable. +grep -q '^c_ldflags ' "$NINJA" || { + echo "FAIL: c_ldflags is not defined, but the C rules reference it"; exit 1; } +echo " c_ldflags: defined even where it equals ldflags" + +echo "238 C-only link units use the C driver OK" diff --git a/tests/unit/test_post_install.cpp b/tests/unit/test_post_install.cpp index 34b5e8fd..a9f5b5e0 100644 --- a/tests/unit/test_post_install.cpp +++ b/tests/unit/test_post_install.cpp @@ -2,6 +2,9 @@ import std; import mcpp.toolchain.post_install; +import mcpp.config; +import mcpp.toolchain.registry; +import mcpp.platform; // detect_baked_loader parses gcc SPECS GRAMMAR, not plain text. The baked // loader path is embedded inside %-spec conditionals, e.g. @@ -58,3 +61,132 @@ TEST(DetectBakedLoader, EmptyWhenNoGnuLoaderPresent) { } } // namespace + +// ── the post-install fixup gate (mcpp#427) ─────────────────────────────── +// +// A unit test rather than an e2e ON PURPOSE, and the reason is the defect +// itself. `ensure_post_install_fixup` returns early — before any of the logic +// under test — for a payload that resolves OUTSIDE the caller's registry +// ("inherited payload, owner is responsible for its fixup"). Every affordable +// e2e inherits its toolchain by symlink to avoid a multi-gigabyte download, so +// no e2e can reach this code. That is precisely the shape of mcpp#221: a test +// that creates the missing thing somewhere the code under test never looks, +// and passes. +// +// Here the payload is a real directory inside the test's own registry, so the +// containment guard passes and the gate actually runs. + +namespace fixup_gate { + +struct Sandbox { + std::filesystem::path root; + mcpp::config::GlobalConfig cfg; + + Sandbox() { + root = std::filesystem::temp_directory_path() + / std::format("mcpp-fixup-{}", std::random_device{}()); + std::filesystem::create_directories(root / "registry" / "data" / "xpkgs"); + cfg.registryDir = root / "registry"; + } + ~Sandbox() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + + // A payload physically inside the registry, so the #273 containment guard + // treats it as ours to patch and the gate is actually reached. + std::filesystem::path payload(std::string_view name) { + auto p = root / "registry" / "data" / "xpkgs" / name / "1.0.0"; + std::filesystem::create_directories(p / "bin"); + return p; + } +}; + +mcpp::toolchain::XimToolchainPackage gcc_pkg() { + mcpp::toolchain::XimToolchainPackage pkg; + pkg.ximName = "gcc"; + pkg.ximVersion = "16.1.0"; + pkg.needsGccPostInstallFixup = true; + return pkg; +} + +} // namespace fixup_gate + +// ⚠️ THE REGRESSION. An empty runtime identity used to be +// std::unexpected("… default SubOS has no RuntimeBinding identity …") +// and `prepare.cppm` turned that into `error: toolchain post-install fixup: …`, +// so `mcpp build` AND `mcpp toolchain install` both died on any Linux machine +// whose default SubOS predated xlings' `subos_info` block. Absence is not a +// contradiction: it degrades. +TEST(PostInstallFixup, AnAbsentRuntimeIdentityDegradesInsteadOfFailing) { + if constexpr (!mcpp::platform::is_linux) { + GTEST_SKIP() << "the runtime binding gate is Linux-only"; + } else { + fixup_gate::Sandbox sb; + auto result = mcpp::toolchain::ensure_post_install_fixup( + sb.cfg, sb.payload("xim-x-gcc"), fixup_gate::gcc_pkg(), + /*runtimeId=*/"", /*selectedRuntimeLibDir=*/{}); + + ASSERT_TRUE(result.has_value()) + << "an undescribed SubOS became a hard error again: " + << result.error(); + EXPECT_FALSE(result->applied); + // ...and it must SAY why. A degradation nobody can report is + // indistinguishable from a fixup that silently stopped working. + EXPECT_FALSE(result->skippedReason.empty()) + << "degraded silently — the caller has nothing to print"; + } +} + +// The other direction, so relaxing absence cannot quietly relax everything. A +// runtime that is DECLARED but cannot be produced is wrong information, not +// missing information: guessing a different glibc would make one mcpp.toml mean +// different ABIs on different machines. +TEST(PostInstallFixup, ADeclaredRuntimeThatCannotBeHonouredStillFails) { + if constexpr (!mcpp::platform::is_linux) { + GTEST_SKIP() << "the runtime binding gate is Linux-only"; + } else { + fixup_gate::Sandbox sb; + auto result = mcpp::toolchain::ensure_post_install_fixup( + sb.cfg, sb.payload("xim-x-gcc"), fixup_gate::gcc_pkg(), + /*runtimeId=*/"glibc@0.0.0-does-not-exist", + /*selectedRuntimeLibDir=*/{}); + + EXPECT_FALSE(result.has_value()) + << "a runtime identity that names nothing installed was accepted"; + } +} + +// No marker may be written for a fixup that did not happen. The marker is a +// content fingerprint whose whole job is to answer "were these inputs ever +// applied"; one written for "we did nothing" reads back as "already applied", +// and the fixup would then never run again — including on the day the user runs +// `xlings self update` and the identity finally becomes knowable. +TEST(PostInstallFixup, ASkippedFixupLeavesNoMarkerBehind) { + if constexpr (!mcpp::platform::is_linux) { + GTEST_SKIP() << "the runtime binding gate is Linux-only"; + } else { + fixup_gate::Sandbox sb; + auto payload = sb.payload("xim-x-gcc"); + auto result = mcpp::toolchain::ensure_post_install_fixup( + sb.cfg, payload, fixup_gate::gcc_pkg(), /*runtimeId=*/"", {}); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_FALSE(std::filesystem::exists(payload / ".mcpp-fixup.json")) + << "'we did nothing' was recorded as 'already applied'"; + } +} + +// A package that needs no fixup at all is not a degradation and must not report +// one, or the caller warns about every msvc/system install. +TEST(PostInstallFixup, APackageWithNoFixupReportsNothingToReport) { + fixup_gate::Sandbox sb; + mcpp::toolchain::XimToolchainPackage none; + none.ximName = "msvc"; + none.ximVersion = "system"; + auto result = mcpp::toolchain::ensure_post_install_fixup( + sb.cfg, sb.payload("xim-x-msvc"), none, "", {}); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_TRUE(result->skippedReason.empty()) + << "a toolchain with no fixup reported a degradation: " + << result->skippedReason; +} From 3f08a50ae6b0d5075942b5c8fa0b939751a6eac3 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:18:06 +0800 Subject: [PATCH 2/4] test(e2e): 237's green half must not depend on a host C runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 本机绿 CI 红,方向与平时相反:这次是**本机有** runner 没有的东西。 原来的绿色一半是「加上 `allow_host_libs = true` 必须能构建」。落回宿主需要宿主 真有一份 C 运行时,而 runner 上 `crt1.o` / `crti.o` / `libm` 一个都没有 —— 那半个断言实际在断言运行机器的属性,不是 mcpp 的行为。 换成不依赖机器的对照:**同一个 home、同一批载荷、同一个工程,只给 SubOS 补上 `subos_info` 块**,必须构建成功。这同时更强 —— 它证明第 1 部分里的失败确实只是 降级,而不是这个环境本来就编不出东西。 绑定哪个 glibc 也不能猜:向真实 home 的 `subos_info.runtime` 要,取不到才回落到 目录扫描。装了两个 glibc 载荷的机器上,`ls | head -1` 会按字母序挑中工具链没有 针对它打过补丁的那个,让对照因为与被控变量无关的原因失败。 --- tests/e2e/237_default_subos_without_info.sh | 64 +++++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/tests/e2e/237_default_subos_without_info.sh b/tests/e2e/237_default_subos_without_info.sh index ff819150..71a62d36 100755 --- a/tests/e2e/237_default_subos_without_info.sh +++ b/tests/e2e/237_default_subos_without_info.sh @@ -117,27 +117,55 @@ if [ "$rc" != 0 ]; then fi echo " absence: no fixup error (exit $rc)" -# ── 2. ...and with the host runtime allowed, it must BUILD ───────────────── -# The green half. Without it, part 1 could be satisfied by mcpp failing for -# some accepted reason on every platform, which is not what "the build is -# unaffected" means. +# ── 2. THE CONTROL: describe the same SubOS, change nothing else ─────────── +# The green half. Without it, part 1 could be satisfied by mcpp failing for some +# accepted reason on every platform, which is not what "the build is unaffected" +# means. This is also what proves the degradation in part 1 is a degradation and +# not a broken toolchain: same home, same payloads, same project — one JSON +# block is the entire difference. # -# ⚠️ THIS IS WHAT WAS BROKEN. In the reported sandbox, `allow_host_libs = true` -# did NOT help: the fixup gate fired before the hermeticity policy was ever -# consulted, so the one escape hatch mcpp documents for this exact situation -# was unreachable. Absence must cost hermeticity and nothing else. -cat >> mcpp.toml <<'EOF' - -[build] -allow_host_libs = true -EOF +# ⚠️ NOT `allow_host_libs = true`. That was the first version and it FAILED ON +# CI while passing here: falling back to the host needs a host C runtime, and +# the runners have no `crt1.o` / `libm` to fall back TO. The green half must not +# depend on a fact about the machine — binding to the payload glibc does not. +# ⚠️ ASK THE REAL HOME WHICH GLIBC IT USES, do not take the first directory. +# A machine with two glibc payloads installed has one the toolchain was patched +# against and one it was not, and `ls | head -1` picks by alphabetical order — +# which would make this control fail for a reason that has nothing to do with +# what it is controlling for. +GLIBC_DIR="$MCPP_HOME/registry/data/xpkgs/xim-x-glibc" +GLIBC_VER="$(python3 - "$REAL_HOME/registry/subos/default/.xlings.json" <<'PY' +import json, sys +try: + rt = json.load(open(sys.argv[1], encoding="utf-8"))["subos_info"]["runtime"] + print(rt.split("@", 1)[1] if "@" in rt else "") +except Exception: + print("") +PY +)" +[ -n "$GLIBC_VER" ] && [ -d "$GLIBC_DIR/$GLIBC_VER" ] \ + || GLIBC_VER="$(ls "$GLIBC_DIR" 2>/dev/null | head -1)" +[ -n "$GLIBC_VER" ] || { echo "SKIP: no glibc payload to bind to"; exit 0; } +python3 - "$MCPP_HOME/registry/subos/default/.xlings.json" "$GLIBC_VER" <<'PY' +import json, sys +path, ver = sys.argv[1], sys.argv[2] +json.dump({"workspace": {}, + "subos_info": {"schema_version": 1, "runtime": "glibc@" + ver, + "host_glibc": ver, "envs": {}}}, + open(path, "w", encoding="utf-8")) +PY rm -rf target -"$MCPP" build > allow.log 2>&1 || { - echo "FAIL: even with allow_host_libs, an undescribed default SubOS blocks" - echo " the build. That is the escape hatch being unreachable." - tail -20 allow.log | sed 's/^/ /' +"$MCPP" build > described.log 2>&1 || { + echo "FAIL: the SAME home builds nothing even once the SubOS describes" + echo " itself (runtime glibc@$GLIBC_VER), so part 1 above proved" + echo " nothing — this environment cannot build either way." + tail -20 described.log | sed 's/^/ /' exit 1; } -echo " absence + allow_host_libs: builds" +echo " described (glibc@$GLIBC_VER): builds" + +# ...and restore the absent state for part 3, so the install path is exercised +# against the condition this test is about. +printf '{"workspace":{}}' > "$MCPP_HOME/registry/subos/default/.xlings.json" # ── 3. `mcpp toolchain install` survives it too ───────────────────────────── # The path the fix could most easily have BROKEN: it carries no RuntimeBinding From c2108eab8a021c4d9d82a65ba82d73418496193c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:31:36 +0800 Subject: [PATCH 3/4] docs: record the CI-only failure of 237's green half (I5) --- .../2026-08-15-issues-426-427-analysis.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.agents/docs/2026-08-15-issues-426-427-analysis.md b/.agents/docs/2026-08-15-issues-426-427-analysis.md index 6fa69e39..b01f325e 100644 --- a/.agents/docs/2026-08-15-issues-426-427-analysis.md +++ b/.agents/docs/2026-08-15-issues-426-427-analysis.md @@ -605,6 +605,26 @@ R6 断定 journal 不含参照臂的版本、只能另写 `meta.json`。核实 对象会进入该包每一个链接单元 —— `libpurec.so` 拿到了 `mixed_cxx.o`,于是「纯 C 单元」 在这个布局下根本不存在。改成三个独立工程。 +## I5 ⚠️ 237 的绿色一半断言了**机器的属性**,不是 mcpp 的行为 + +初版的绿色一半是「加上 `allow_host_libs = true` 必须能构建」。本机绿,**CI 红**: + +``` +ld: cannot find crt1.o / crti.o / -lm +``` + +落回宿主需要宿主真有一份 C 运行时,而 runner 上一个都没有。方向与 +`shared-gcc-payload-specs-poisoning` 记的那次相反 —— 那次是本机有历史污染,这次是 +**本机有 runner 没有的宿主开发文件**。两者共同点:断言依赖了运行机器。 + +改成不依赖机器的对照:**同一个 home、同一批载荷、同一个工程,只给 SubOS 补上 +`subos_info` 块**,必须构建成功。这同时更强 —— 它证明第 1 部分的失败确实只是降级, +而不是这个环境本来就编不出东西。 + +绑定哪个 glibc 也不能猜:向真实 home 的 `subos_info.runtime` 要,取不到才回落到目录 +扫描。装了两个 glibc 载荷的机器上,`ls | head -1` 会按字母序挑中工具链没有针对它打过 +补丁的那个,让对照因为与被控变量无关的原因失败。 + ## I4 降级时**不写** marker 方案 A2 原说「降级写出的 marker 其 `glibcLib` 为空,身份可知后会自动重跑」。实现时 From 716a1c0306630c1458f3e3e2ecc797b5a1c49155 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:43:14 +0800 Subject: [PATCH 4/4] refactor(ui): the toolchain degradation line says only what the Runtime note did not --- src/build/prepare.cppm | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 8ebc05d7..d6189877 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -989,12 +989,12 @@ prepare_build(bool print_fingerprint, const std::filesystem::path& payloadRoot) { if (outcome.skippedReason.empty()) return; if (!fixupNoticed->insert(payloadRoot.generic_string()).second) return; + // Only the fact this line ADDS. The `Runtime` note above already gave + // the cause and the remedy for the same absence; repeating them here + // would be the second copy of one message, which is the habit mcpp#417 + // exists to break. mcpp::ui::info("Toolchain", std::format( - "not bound to a C runtime: {}.\n" - " The toolchain is used as installed. If a compile later " - "reports a missing libc header, or the hermeticity check refuses " - "the link, this is why — `xlings self update` writes the SubOS " - "description that supplies it.", + "used as installed — not patched against a C runtime ({})", outcome.skippedReason)); };