Skip to content

Latest commit

 

History

History
633 lines (457 loc) · 30.6 KB

File metadata and controls

633 lines (457 loc) · 30.6 KB

#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 buildlifecycle.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 取路径:

// 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 的兜底则硬编码:

// 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 的注释:

// 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 保护的函数本来就正确处理空值:

// 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 读的是硬编码的 <xlings home>/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),把 runtimeIdlibraryDirs.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 返回「做了什么 / 跳过了什么」,而不是自行决定生死:

struct FixupOutcome { bool applied; std::string skippedReason; };
std::expected<FixupOutcome, std::string> 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":{}} —— 即沙箱现场的字面复制。⚠️ 必须是 <home>/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 buildmcpp 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<path> objects

改动分三处,换驱动只是其中一处 —— 只换驱动会半途而废,见 (3)。

(1) 判定谓词,照 unit_needs_std 的形状写。

ninja_backend.cppm:1169-1179 已经有一个完全同形的先例:按 cu.object 建一张表, 再对 lu.objects 查表。照抄结构即可,不需要给 LinkUnit 加字段(加字段会牵动 Plan 的序列化与缓存键,而这个决定只有发射时才用得到):

std::unordered_map<std::string, bool> 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-1710switch 按谓词选 c_link / c_shared (用 $cc,该变量已存在)或 cxx_link / cxx_sharedcxx_archivear, 与驱动无关,不变。

⚠️ 同一个 switchstd.compat.o 没有收窄(自我 review 发现,见 R4):

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.ldunit_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_ldflagsld 的计算路径一个字符不动,所以「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:模块接口单元的 kindModuleInterface,必然 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.jsonreference_mcpp 留在 2026.8.11.3。守卫是 #423 加的,由 tests/e2e/233_bench_matrix.sh:269 触发。

二、⚠️ 守卫本身是错的

它断言的危险 —— 「the old column silently becomes some other release」—— 已经被 run-standard.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 用同一个解析器,把 runtimeIdlibraryDirs.front() 传下去。

教训:「删掉重复推导」只在所有调用方都能提供那个事实时成立。删之前必须逐个 调用方核对谁能提供 —— 五个调用点里有一个不能。

R2 降级面比担心的窄(核实结论,方案不变)

初稿的隐忧:健康机器上 runtimeId 若也可能为空,删掉兜底就成了回归。

核实:resolve_runtime_binding 在缺 subos_info不早退 (runtime_binding.cppm:277「CONTRADICTION vs ABSENCE」那段),后面还会用 <subos>/lib*/libc.so.6 的物理链接反推身份(managed_glibc_identity,:352)。 所以只有既无声明、又无物理 libc 的 SubOS 才会得到空身份。

沙箱现场正是如此 —— 那个 subos 的 lib/没有 libc.so.6,物理推导也无从下手。 即降级路径只覆盖真正一无所知的情形,不会波及正常机器。

R3 降级时的幂等标记(补充,免费的正确性)

降级写出的 .mcpp-fixup.jsonglibcLib 为空,与打过补丁的指纹不同 ⇒ 用户后来跑 了 xlings self update、身份可知之后,fixup 会自动重跑。这条性质由现有的内容 指纹机制免费提供,但必须在测试里钉住,否则将来有人「优化」成布尔标记就会退化成 「补好了 xlings 却仍不打补丁」。

R4 ⚠️ std.compat.o 仍是无条件的 —— #416 只修了一半

ninja_backend.cppm:1697 / :1707:

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 buildensure_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 单元」 在这个布局下根本不存在。改成三个独立工程。

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 为空,身份可知后会自动重跑」。实现时 改为根本不写 marker:让「什么都没做」有机会被读成「已经做过」是不必要的风险, 而不写 marker 的代价只是每次构建重新判断一次(纯内存)。单测 ASkippedFixupLeavesNoMarkerBehind 钉住这一点。