Skip to content

Latest commit

 

History

History
359 lines (255 loc) · 17.3 KB

File metadata and controls

359 lines (255 loc) · 17.3 KB

#412 #415 #416 #417 #418 #421 #422 —— 逐条核实与修复方案(2026-08-15)

状态:分析 + 方案,待 review。全部对 HEAD 重新核实过,不是照抄 issue。

七条都是 2026-08-11/12 写的,分支此后动过。下面每一条先给核实结论(还在? 行号变了没?issue 的推断成立吗?),再给方案。三处 issue 自身需要修正,标了 ⚠️

# 核实 性质 改动面 建议
416 仍在,但 ⚠️ issue 的因果与症状都不成立(实测) 正确性(后果远小于所述) 先复现
422 仍在,且真因比 issue 说的更好修 正确性(MSVC 不可用) 先做
412 三条全在 ⚠️ 编号冲突 文档/覆盖 先做
418 两条全在,但字段有同名的活字段 ⚠️ 死代码
415 仍在(OriginArtifact 档) 可观测性
417 仍在,但真因未定位 首次体验 先探针
421 仍在;⚠️ 文档有一句是错的 能力边界 拆成三档

#416 — obj/std.o 无条件链进每个单元

核实

仍在。行号已从 issue 写的 1362-1381 变到 ninja_backend.cppm:1648 / :1658:

case LinkUnit::Binary:
case LinkUnit::TestBinary:
    if (has_std_artifacts) ins += " " + escape_ninja_path(std_o_dst);
case LinkUnit::SharedLibrary:
    if (has_std_artifacts) ins += " " + escape_ninja_path(std_o_dst);

has_std_artifacts 的含义是「这条工具链有预建的 std 模块」,与这个链接单元是否 需要无关 —— 这一点确实成立。但 issue 声称的后果不成立,见下面两处修正。

方案

把条件从「工具链有 std」收窄到「这个单元的闭包里真的有人 import std」。

判定数据已经在手,不需要新的扫描:模块图里每个 TU 的 requires_ 是扫描器 填的,import std 会出现在里面。所以:

needs_std(link_unit) = 该单元的任一 TU 的 requires_ 含 "std" / "std.compat"
                     ∪ 该单元链接的任一【本工程】静态库/对象的同一判定(传递闭包)

⚠️ 传递性必须递归,不能只看本单元的源码。 issue 自己点了这条,而它有前科: 依赖的 BMI 跨版本毒化那次(#405)就是「边存在但没有任何人依赖它」。一个单元自己 不写 import std,但它 import 的模块接口写了,链接时同样需要 std.o

⚠️ 两处对 issue 的修正(实测,不是推理)

修正 1:std.o 不可能是 libstdc++.so.6 的来源。

$ nm --defined-only  obj/std.o   → 1 个符号:_ZGIW3std(模块 std 的全局初始化器)
$ nm --undefined-only obj/std.o  → 0 个
$ ls -l obj/std.o                → 1280 字节

零个未定义符号,所以它拖不动任何共享库。issue 把 「纯 C 的 compat 包多一条 NEEDED libstdc++.so.6」归因给 std.o,这条因果不成立。

真正的嫌疑是链接驱动:ninja_backend.cppm 一律用 $cxx(g++)链接,而 g++ 总是加 -lstdc++;全仓 --as-needed 用在 -latomic 那一处 (flags.cppm:240),所以 -lstdc++ 会无条件成为 NEEDED,与 std.o 无关。

修正 2:头号症状在本机产物上复现不出来。

$ readelf -d ~/.mcpp/registry/subos/default/lib/libXau.so.6 | grep NEEDED
  libc.so.6                    ← 只有这一条,没有 libstdc++.so.6
$ nm -D libXau.so.6 | grep -c _ZGIW    → 0

这份产物的来源版本不明(可能是 #414 之后重建的)。实施前必须先用当前 mcpp 重新构建一个纯 C 的 compat 包并 readelf -d,确认症状是否还在 —— 否则可能在修一个已经不存在的问题,而真正的 -lstdc++ 通道没人动。

这两处修正如何改变方案

  • 仍然该做:std.o 无条件链进纯 C 单元本身就是错的(一个不该在那里的对象), 即使它的后果比 issue 说的小得多。
  • 但判据要拆开:「readelf -d 没有 libstdc++.so.6」这条不是收窄 std.o 就能达成的,它属于 -lstdc++ / --as-needed / 链接驱动那条线。两件事要分成 两个 issue,否则会出现「改完了,判据仍然不满足」。
  • 风险比想象中低:std.o 唯一的符号是模块初始化器,而 import std 的 TU 会 引用它。所以谓词若漏判(该链没链),结果是链接期 undefined _ZGIW3std —— 响亮的失败,不是静默错误。这让递归传递闭包不必一次写到完美。

#422 — MSVC:cxx_runtime 到不了 std 模块

核实

仍在,而且真因比 issue 推测的更具体src/toolchain/msvc.cppm:529 构建 std 的 命令是:

cl /nologo <std-flag> /EHsc /O2 /W0 <extraRef> /c <src> /ifcOutput <ifc> /Fo:<obj>

一个 /MT/MD 都没有 —— 用的是 cl 的默认(/MT)。工程在 cxx_runtime = "host-coupled" 下编 /MD,于是 _MSVC_MT / _MSVC_MD 对不上, C5050 之后是真正的 C2375。

⚠️ issue 给了两条路,其实做第一条就白送第二条

issue 说「要么 std 构建遵守 cxx_runtime,要么 std 缓存按它分键」。核实发现 std 的缓存身份键里已经含 std_build_commands(实际命令行) (src/toolchain/stdmod.cppm:136,且 metadata_matches 的 14 个键里也有它)。

所以只要把 runtime flag 加进那条命令,缓存目录会自动随之分叉,两者不可能再 静默背离。不需要单独设计缓存键。

方案

  1. msvc.cppm 的 std 命令构造函数接收「有效 runtime 契约」,发 /MT/MD (以及 debug profile 下的 /MTd / /MDd)。
  2. 契约来源必须与工程 TU 同一处推导,否则就是又一个「同一决策两处推导」。 工程侧的推导在 src/build/flags.cppm(dist::parse_contract / contractByRole), std 侧要读同一个结果。
  3. 顺带:GCC / Clang 侧不需要动 —— 它们的 std 模块不带 runtime ABI 开关。 但注释要写明为什么只有 MSVC 需要,否则下一个人会以为漏了。

判据

  • cxx_runtime = "host-coupled" 的工程在 MSVC 上能构建并运行(现在是失败)
  • 同一台机器上,host-coupledself-contained 两个工程各自拿到不同的 std 缓存目录 —— 用 ls <cache_root>/std/ 直接看,两个 key
  • ⚠️ 反向也要验:cxx_runtime 不改别的,std 必须重建。否则说明 flag 没进 std_build_commands,缓存键没分叉,问题只是被当前的冷缓存掩盖了

#412 — 三处过期的 MSVC 陈述

核实

三条全在:

位置 现状
1. 劝退用户的假 note src/toolchain/lifecycle.cppm:664 仍在
2. 与自身断言矛盾的头注释 tests/e2e/95_msvc_system_toolchain.sh:9 仍在
3. 217 在两个平台整条被跳过 tests/e2e/217_module_extensions.sh:2 # requires: gcc 仍在

⚠️ issue 的方案里有一个编号冲突

issue 建议「补一条 219」。219 已被 219_runtime_search_farm_is_last.sh 占用。 新测试要另取编号(当前最大是 234,故用 235)。

方案

  1. lifecycle.cppm:664 那句 note。它的实际效果是劝退一个能用的功能。 替换成陈述现状还是直接删掉,取决于是否有已知限制要说——若无,直接删, 「没有消息」比「过期的消息」好。
  2. 95 头注释第 9 行。
  3. 新增 tests/e2e/235_module_extensions_msvc_llvm.sh:不依赖 gcc 能力, 按平台选默认工具链(Windows→msvc,macOS→llvm),验证 .ixx 走完 build→link→run。

⚠️ 第 3 条是唯一有实质价值的:前两条是删字,第三条是补一条从未被走过的路.ixx + module_extensions + MSVC 这条组合推理上必然通,但推理不是测量 —— 这正是 #411 里指出的同一形状。


#418 — 两处只写不读的死字段

核实

两条都仍在,但 ⚠️ cxxRuntimeTests 这个名字下有两个字段,只有一个是死的:

字段 位置 状态
BuildConfig::cxxRuntimeTests types.cppm:450 活的 —— toml.cppm:967 解析,flags.cppm:760/848 读
TargetEntry::cxxRuntimeTests types.cppm:653 死的 —— 解析处(toml.cppm:1379)只读标量 cxx_runtime,应用处(prepare.cppm:1154)只应用 cxxRuntime

contractByRole 全仓两处命中(flags.cppm:62 声明、:875 写),确认无读取方

实施时不要 grep cxxRuntimeTests 后一把删 —— 会删掉活的那个。

方案

  • TargetEntry::cxxRuntimeTests:删。 理由:per-target 通道目前只支持标量, 而标量已覆盖所有角色。补全表形式解析是扩表面积,而这个通道还没有用户要求它。 一个不生效的配置项比没有更糟 —— 但补全它等于新增一个需要长期维护的语义。
  • contractByRole:接出去,不删。 它记录「每个角色实际拿到的契约(降级之后)」, 而 #414 之后共享库的契约按格式分档,「我这个 .so 到底拿到了哪一档」是用户会 真的问的问题,现在只能靠 readelf 自己看。写进 resolution.json,让 mcpp why / mcpp doctor 能回答。

判据

  • TargetEntry::cxxRuntimeTests 不存在,且 [target.<triple>].cxx_runtime_tests 写在 manifest 里会报未知键(而不是被静默忽略)
  • contractByRole 有真实消费方并被测试覆盖:一个共享库工程的 resolution.json 里能读到它拿到的档位,且与 readelf 的实际观测一致

#415 — $ORIGIN 不在 runtime 闭包里

核实

仍在。src/platform/runtime_search.cppmOrigin 枚举只有 Payload / Package / SubosFarm / HostDefault,没有 Artifact(全文件 0 处 命中 "Artifact")。$ORIGINsrc/build/plan.cppm:475 在 per-unit flags 通道 单独发出。

模块自称「Search order = decreasing immutability. This is the one invariant in this module」,而最关键的那个目录不在这个排序里 —— #414 修的正是它排错了位置。

方案(采纳 issue 的轻档,不做重档)

  • runtime_search.cppm:OriginArtifact,rank() 置于 PackageSubosFarm 之间,补 to_string(); ⚠️ is_machine_local() 返回 false —— $ORIGIN 随产物走,不是机器局部的。 这一条写错会让 pack 的判断反过来(虽然 pack 当前不 import 这个模块, 但那是巧合,不是契约)。
  • plan.cppm:679 runtime_search_closure():把产物输出目录加进去。
  • 显示侧:prepare.cppm 写记录、doctor.cppm 打标签。

重档(让闭包成为唯一的 rpath 生产者)不做:$ORIGIN 本质是 per-unit 的 (只有消费共享库的单元才需要),挪进全局闭包要给闭包引入 per-unit 概念, 改动量与风险明显更大,而收益只是消灭「两个生产者」这个洁癖。

判据

e2e 219 能把「记录的闭包」与「产物的 DT_RPATH」逐项比对并通过, 不对 $ORIGIN 做任何过滤或例外 —— 现在它必须过滤,那个过滤就是缺口的证据。


#417 — 全新 MCPP_HOME 首次构建,rule B 对每个产物报 inconclusive

核实

仍在。两条消息在 src/platform/elf_runtime.cppm:773 / :791,按产物逐条发, 所以一个图形工程刷 13 条。

⚠️ 真因未定位,不要照着 issue 的推理直接改

issue 自己写了「精确接缝还需要一次探针确认 —— 不要照着这个推理直接改」。 这条要照办。当前证据只支持这个描述:

  • 首次运行:binding.loaderbinding.library_dirs 为空,而 binding.search_dirs 值(已记下 <subos>/lib)
  • 第二次运行:两者都有值,警告消失
  • 磁盘上二者首次运行时都存在

「binding 求值早于 farm 落盘」是自然读法,不是已证事实。search_dirs 有值而 另外两个没有,说明填充它们的不是同一段代码或同一时刻 —— 这一点本身就值得先查。

方案:先探针,再修

  1. 探针:在 runtime_binding.cppm:337-380 的填充点打一条 verbose,记录 「此刻 <subos>/lib64<subos>/lib 是否存在、里面有没有 libc.so.6ld-linux-*」。全新 MCPP_HOME 跑一次,把真实接缝钉死。
  2. 按探针结果二选一:
    • 若确是时序:把 binding 的求值推迟到 farm 落盘之后(或在首次构建时重求值一次)。
    • 若是查找逻辑在首次运行时的目录形态下失效(例如那时还是符号链接的符号链接): 修查找,而不是改时序。
  3. 无论哪一种,诊断都要改:同一个原因对 13 个产物刷 26 条,是噪声不是信息。 改成 binding 层面只说一次「此刻无法求值,原因 X」。

⚠️ 必须写进代码注释的一条

即使 rule B 完全正常,它也抓不到 #414 那个崩溃。 elf_runtime.cppm:761-801 只比对 libc / PT_INTERP 的同一性,不管其它 SONAME 解析到谁。修这个 issue 不能替代 #414 的顺序修复,也不要当成它的兜底。

判据

全新 MCPP_HOME 的第一次构建:要么 rule B 给出真实判决(pass/mismatch), 要么 binding 只说一次「此刻还无法求值」,而不是对每个产物各刷两条。


#421 — 扫描器 M1:条件 import 与头单元(来自 XRGUI 的真实适配)

核实

两条限制都仍在:scanner.cppm:660(条件 import)、:666(头单元)。扫描器是 纯词法的,用 if_depth > 0 判定,不看条件是否可判定

⚠️ 文档里有一句是错的,这是最该先修的

docs/05-mcpp-toml.md:414(中文版 §2.3 同):

defines … also reaches the P1689 module scan, which is what makes a macro-guarded import resolvable

这句话对 mcpp 不成立。 defines 确实会进编译器的 P1689 扫描(.ddi), 但 mcpp 自己的前置扫描器在看到条件块里有 import 时就直接报错,根本走不到 宏求值。用户按文档写一个宏保护的 import,拿到的是 forbidden in M1

这一条不需要设计,只需要把文档改对 —— 而且它是七条里唯一一个「文档承诺了一个 不存在的能力」,优先级应当最高。

三档方案(按代价递增,建议只做前两档)

档 A(必做,零风险):把文档改对。 说明 defines 到达的是编译器的扫描, 而 mcpp 的前置扫描器目前拒绝一切条件块内的 import

档 B(建议做):诊断可操作化。 现在只说 "forbidden in M1"。改成:

  • 指出是哪个宏在保护它;
  • 若该宏出现在 [build].defines / [targets.*].defines 里,直接说 「这个条件是可判定的,当前未实现求值(见 #421)」;
  • 若不在(例如 __cpp_lib_stacktrace 这种工具链内建宏),说 「依赖工具链内建宏,不可判定」。

代价很小,而它把「适配一个真实工程」的排查成本从「逐个文件试」降到「读一条消息」。

档 C(不建议现在做):真正求值可判定的条件。 若条件只依赖 defines 里出现过的宏,扫描器信息是足够的。但这意味着扫描器要引入 一个最小预处理器(#if 表达式求值、defined()、嵌套、#elif),而 「实现一个不完整的预处理器」的失败形态是静默扫错依赖图,不是报错。 M1 的取舍(纯词法 + 硬拒)正是为了避免这个。要做也应当先把边界写死: 只支持 #ifdef X / #ifndef X / #if defined(X) 三种字面形态,别的一律仍然拒。

头单元:维持现状(拒绝)。头单元本身是泥潭,而 issue 的报告者也说 「不是说 M1 的取舍不对」。但档 B 的诊断应当同时指出替代写法 (把 import <h>; 改成 GMF 里的 #include <h>),这是他实际采用的做法。

附:一处诊断透传(优先级最低)

module : private; 在 GCC 16 的 P1689 扫描路径下报 module already declared (编译路径报的是 sorry, unimplemented: private module fragment)。这是 GCC 的 消息差异,不是 mcpp 的缺陷。扫描器既然已经在做词法解析,识别出这一句并附一句 「当前工具链未实现私有模块片段」能省掉一轮误导性排查。可做可不做。


建议的顺序

  1. #421 档 A —— 改一句文档,消除「承诺了不存在的能力」。零风险,最高性价比。
  2. #422 —— MSVC 上 host-coupled 现在根本不能用,而修法很小且缓存键白送。
  3. #416 —— ⚠️ 先复现:用当前 mcpp 重建一个纯 C compat 包,确认 readelf -d 里还有没有 libstdc++.so.6。症状若已不在,这条降级为 「清理一个不该在那里的对象」,并把 -lstdc++ / --as-needed 另开一条。
  4. #412 —— 两条删字 + 一条真正的覆盖补齐(注意编号用 235)。
  5. #418 —— 死字段;⚠️ 注意两个同名字段只有一个该删。
  6. #415 —— 可观测性,改动中等,判据明确(219 不再需要过滤 $ORIGIN)。
  7. #417 —— 先探针再修,不要照 issue 的推理直接动时序。
  8. #421 档 B —— 诊断可操作化。
  9. #421 档 C / 私有片段透传 —— 暂不做。

一条贯穿的观察

七条里有四条(412、418、421 档 A、415)本质是同一件事: 写下来的状态与实际行为脱节 —— 过期的 note、不生效的字段、承诺了不存在能力的 文档、自称唯一排序却漏了最关键一项的模型。它们都不是「功能缺失」,而是 「代码与它自己的说明书不一致」,而这类问题的共同代价是:读的人据此做决定, 然后浪费掉的时间不会归因到这里。