diff --git a/.agents/docs/2026-08-09-issue-triage-full-sweep.md b/.agents/docs/2026-08-09-issue-triage-full-sweep.md new file mode 100644 index 00000000..216c593b --- /dev/null +++ b/.agents/docs/2026-08-09-issue-triage-full-sweep.md @@ -0,0 +1,676 @@ +# mcpp Open Issue 全量深度核验报告 + +> 盘点基线:mcpp `main@80291ca`(v2026.8.8.4)· xlings `openxlings/xlings@2913a09`(2026.8.9.2) +> xim-pkgindex `@c0aded29` · mcpp-index `@b86fc7c` · mcpp-vscode `@56594db` +> 日期:2026-08-09 · 方法:26 个 open issue,23 条逐条落到当前代码核验 + 对「可关闭」结论做双路对抗验证 + +--- + +## 0. 一句话结论 + +**23 条全部保持 open,本轮 0 关闭。** 两条曾被判为「可关闭」的(#313、#177)在对抗验证中被 2:0 推翻——推翻的理由不是「结论不够保守」,而是**拟发的关闭留言里有会被读者当场证伪的错误**(见 §3)。 + +真正的收获不在关闭数,而在:**核验过程中撞出 30 条 issue 一个字没提的缺陷,其中 6 条比它们所属的 issue 本身更该先修**,包括一条今天就在破坏已发布的 mcpp-vscode 扩展的 10 行缺陷(§5.1)。 + +--- + +## 1. 判定总表 + +| # | 判定 | 置信 | 优先级 | 工作量 | 一句话 | +|---|---|---|---|---|---| +| [#396](https://github.com/mcpp-community/mcpp/issues/396) | VALID_OPEN | high | P2 | L | form-X 二进制的链接期物理检查(xlings 闭环执行点 3),mcpp 侧零实现 | +| [#393](https://github.com/mcpp-community/mcpp/issues/393) | VALID_OPEN | high | P3 | M | `${mcpp.*}` 展开拼写没有单一规则,词表内部就自相矛盾 | +| [#392](https://github.com/mcpp-community/mcpp/issues/392) | PARTIAL | high | P1 | M | 上游已修默认 runtime,但 mcpp 的 xlings pin 没跟;fixup 仍按目录序选 glibc | +| [#386](https://github.com/mcpp-community/mcpp/issues/386) | VALID_OPEN | high | P2 | M | 维护者承诺的「`sources = []` 真正生效」一行未落地 | +| [#382](https://github.com/mcpp-community/mcpp/issues/382) | PARTIAL | high | P2 | M | 包侧已修;mcpp 把 subos 的 `op="set"` 读成无条件覆盖,与 xlings 契约相反 | +| [#380](https://github.com/mcpp-community/mcpp/issues/380) | VALID_OPEN | high | P2 | M | `mcpp new` 名字契约缺失:死循环 / 路径逃逸 / 假成功,四段代码原封不动 | +| [#379](https://github.com/mcpp-community/mcpp/issues/379) | PARTIAL | high | P2 | L | 契约层已由 PR #385 冻结为 wire v1;阶段 3b/3c 与 7 条纪律项未动 | +| [#374](https://github.com/mcpp-community/mcpp/issues/374) | VALID_OPEN | high | P2 | S | 把命名**约定**当成**要求**,非模块 lib 被误报缺模块根 | +| [#373](https://github.com/mcpp-community/mcpp/issues/373) | VALID_OPEN | high | **P1** | M | 扫描器不剥块注释——但真正的坑是它连**顺序**都是错的(§5.2) | +| [#371](https://github.com/mcpp-community/mcpp/issues/371) | PARTIAL | high | **P1** | M | 前提被证伪(CDB 早就在 spawn ninja 前写);真需求收窄到「不付全量构建」 | +| [#370](https://github.com/mcpp-community/mcpp/issues/370) | VALID_OPEN | high | P2 | S | 「范围永远够不着」被劈成两条路径,只有一条带修法提示 | +| [#313](https://github.com/mcpp-community/mcpp/issues/313) | PARTIAL | high | P3 | S | 7 条建议:3 条已交付、1 条重复于 #144、3 条无落地物 | +| [#304](https://github.com/mcpp-community/mcpp/issues/304) | VALID_OPEN | high | P2 | M | `[runtime] library_dirs` 进 `-L`;正解是第三条路(`-rpath-link`),不是 issue 给的二选一 | +| [#293](https://github.com/mcpp-community/mcpp/issues/293) | PARTIAL | high | **P1** | M | `ln -sf` 原样还在;mcpp 自己的改写器已围栏,xlings 侧写穿仍无遮挡 | +| [#290](https://github.com/mcpp-community/mcpp/issues/290) | VALID_OPEN | high | P2 | M | xpkg 描述符缺版本条件轴;但成本被 issue 高估一个数量级 | +| [#289](https://github.com/mcpp-community/mcpp/issues/289) | PARTIAL | high | P2 | M | 三条主张全被 PR #378 推翻;残留两洞,其一是 Windows 上整套机制空转 | +| [#284](https://github.com/mcpp-community/mcpp/issues/284) | VALID_OPEN | high | P2 | S | `toolchain remove` 少一个位置参数——而且比 issue 以为的还多缺 partial 解析 | +| [#283](https://github.com/mcpp-community/mcpp/issues/283) | VALID_OPEN | high | P2 | M | target pin 静默压过显式默认;更硬的一半是连 `[toolchain]` 也保不住 | +| [#276](https://github.com/mcpp-community/mcpp/issues/276) | VALID_OPEN | high | P2 | XL | 嵌入式 SDK RFC:不缺机制,缺输入通道;最小切入点是一个 `sysroot` 字段 | +| [#259](https://github.com/mcpp-community/mcpp/issues/259) | VALID_OPEN | high | P2 | M | 正文归因已被自己的评论推翻;mcpp 侧三层兜底全是空转,一行未改 | +| [#256](https://github.com/mcpp-community/mcpp/issues/256) | PARTIAL | high | P2 | S | 文档扎实;canary 在三处偏离目的,且守的不是默认工具链版本 | +| [#215](https://github.com/mcpp-community/mcpp/issues/215) | UPSTREAM_BLOCKED | high | P3 | S | 触发条件未满足(实机核过);但「谁告诉你该动了」没有机制 | +| [#177](https://github.com/mcpp-community/mcpp/issues/177)+[#144](https://github.com/mcpp-community/mcpp/issues/144) | PARTIAL | high | P2 | L | 同一缺口的两个面:mcpp 没有「申报外部已存在之物」的入口 | + +**保留不动**(用户指定):[#43](https://github.com/mcpp-community/mcpp/issues/43)(NULL,历史盘点存档)、[#260](https://github.com/mcpp-community/mcpp/issues/260)(项目留言板)。 + +分布:`VALID_OPEN` 15 · `PARTIAL` 7 · `UPSTREAM_BLOCKED` 1 · `FIXED/OBSOLETE/INVALID` **0**。 + +--- + +## 2. 横向发现:五个反复出现的形状 + +这轮 23 条里,同一批形状出现了不止一次。它们比单条 issue 更值得记。 + +### 2.1 「同一决策多处推导」——本轮出现 7 次 + +| 决策 | 推导点数 | 后果 | +|---|---|---| +| 「用户没写 sources」 | 5 处(`toml.cppm:226/229/1475`、`prepare.cppm:3209`、`xpkg.cppm:1864`) | #386 修一处不够;且 `prepare.cppm:3210` 的默认 glob 已与 `toml.cppm:1476` 漂移(少了 `.S/.s/.asm`) | +| 「双写法 + partial 版本」 | 3 层 × 3 命令,remove 那份只推导了一半 | #284 | +| 限定包名 | 3 处算法不一致(`scanner.cppm:789`、`plan.cppm:264`、#374 将新增的第三处) | `namespace` + `name` 组合下 `plan.cppm:1054` 静默失配 | +| 目标路径 `current_path() / name` | 2 处(`create.cppm:183`、`:217`) | #380 | +| `${mcpp.*}` 展开拼写 | 词表内部两种(`.string()` vs `.generic_string()`) | #393 | +| glibc 载荷选择 | 构建路径已收敛到权威制,fixup 路径仍按目录序 | #392 / #396 | +| 「默认工具链是什么」 | `prepare.cppm` 与 `lifecycle.cppm` 两套算法 | #283 | + +**判据**:新增一条语义时如果要改 ≥3 处,那不是工作量问题,是这个决策没有单一真源。 + +### 2.2 「修补放在控制流到不了的地方」——本轮出现 5 次 + +- **#289**:版本探针把 `2>/dev/null` 硬编码进一条经 `cmd.exe /c` 执行的命令(`xlings_binary.cppm:160`)⇒ Windows 上返回空串 ⇒ 两个消费点都退化成「读不出来,不判断」。而 issue 点名的受害平台**正是 Windows**。仓库里已有 `mcpp::platform::null_redirect`(`common.cppm:37-41`)就是为这件事准备的。 +- **#256**:canary 的 control 步骤产出 `ctl.pcm` 之后再没引用过它;注释声称它守「导入侧必定能编过」,实际一个断言都没有。实测该断言今天在 22.1.8 上成立——可写、为真、就是没写。 +- **#256**:`EXPECTED=unknown ⇒ exit 0`。索引一旦把 llvm `latest` 推到 23,canary 在最该响的时候静音。 +- **#293/#259**:`{"xim:glibc","xim:linux-headers"}` 预装循环传的是无版本的 `"xim:glibc"`,被 `package_fetcher.cppm:932` 的 `@` 门当场拒掉——**自引入起一次都没装成过任何东西**,一处 `(void)` 丢弃、一处只进 `log::debug`。 +- **#313**:`--no-color` 在 TTY 下是空操作(§5.3)。 + +### 2.3 「断言镜像了实现,而不是校验实现」 + +- **#215**:`test_cppfly.cpp:80` 断的是 mcpp 自己那张表的输出,输入还是手搓的 `Toolchain` 结构体,连编译器进程都不启动;`101_cppfly_llvm_soft.sh:49` grep 的是 mcpp 自己打印的 summary,而那行内容正由那张表决定。**上游 clang 明天落地反射,CI 一根红线都不会亮。** +- **#374**:`conventional lib root` 这条 warning 全仓零测试覆盖;7 个 `TEST(Validate,…)` 里只有一个传了非空 `projectRoot`,on-disk 检查在其余单测里被 `validate.cppm:139` 整段跳过。 +- **#313**:`01_help_and_version.sh` 走管道,`detect_color()` 天然返回 false,所以「无色」这个结果永远对、原因永远测不出来。 + +### 2.4 「包侧绕过 ≠ 引擎缺陷消失」 + +三条 issue 的症状在生态里已经看不见了,但缺陷一动没动: + +- **#304**:`compat.vulkan-runtime.lua:126-171` 已经把「只采集带版本号的 soname」固化成注释。下一个写 symlink farm 的人会原样踩进去,而报错里没有任何线索指向 `runtime.library_dirs`。 +- **#396**:唯一挡住 libc 跟着 `[runtime] library_dirs` 进消费者 RUNPATH 的,是 `compat.glx-runtime.lua:203-213` 里一段 Lua 黑名单,注释自己写着「the failure it prevents has no diagnostic of its own」。**引擎级守卫缺位,被一个包用 Lua 补上了。** +- **#382**:`#565` 改的是配方 `config()` 的行为,只在配方重新执行时生效;已经写进某个 subos `.xlings.json` 的 `GALLIUM_DRIVER=d3d12` 声明不会因 `xlings update` 消失。在那之前,mcpp 侧的无条件 `set` 意味着这些用户**连 `export GALLIUM_DRIVER=llvmpipe` 自救都做不到**。 + +### 2.5 「issue 的归因经常是错的,包括维护者自己写的」 + +本轮有 **8 条** issue 的核心事实主张被推翻或修正: + +| issue | 被推翻的主张 | 实际 | +|---|---|---| +| #289 | 「pin 只有一个消费点(打印)」「获取点按存在性早退」 | PR #378 已全面重写,现有 5 个消费点 | +| #371 | 「CDB 只在构建成功后才写」 | `ninja_backend.cppm:1549` 写 CDB,`:1664` 才 spawn ninja | +| #259 | 「llvm xim 包漏声明 glibc 依赖」 | `llvm.lua:23-40` 一直声明着(作者自己在评论里已retract) | +| #393 | 「`make_preferred` 归一化替换值即可」 | `outputDir.string()` 本来就是纯原生,按此修法写完测试会「过」而 bug 不动 | +| #380 | 「包模板渲染器有同样的死循环风险」 | `template.cppm:139` 是 `pos += to.size()`,不会重扫 | +| #284 | 「partial 版本在 remove 里已经能解析」 | `resolve_version_match` 在 remove 全函数体零调用 | +| #290 | 「版本维度在解析时未知,需要延迟合并」 | `packageVersion` 早就是 `synthesize_from_xpkg_lua` 的形参 | +| #396 | 「规则 A 可以从第一天硬失败」 | `our_glibc >= host_glibc` 是上界代理,会误杀能跑的二进制(实测:host=2.43 上用 2.39 载荷链 `libz.so.1`(只要 GLIBC_2.14)完全正常) | + +**这直接决定了「已修复 ⇒ 关闭」这个动作的成本**:issue 正文的行号平均漂移了两到三个版本,而其中相当一部分主张在写下时就不准确。 + +--- + +## 3. 关闭判断:为什么本轮 0 关闭 + +流程上,每条判为「可关闭」的结论都会派两个独立反驳者:一个查代码事实,一个站在原报告人视角问「这样关掉他会不会觉得没解决」。两条候选各被 2:0 推翻。 + +### 3.1 #313(7 条产品建议合集) + +- **落地为真的 3 条**已复核:Homebrew tap(`homebrew-mcpp` 活跃,formula 已跟到 `2026.8.8.4`,`bin.mkpath` 首装 ENOENT 已修)、模板机制(`--template` + e2e + docs)、短命令(仅 xlings 侧)。 +- **推翻理由 1**:关闭留言给的两条「现在就能用」的方案**在报告人自己的 Mac 上都跑不通**。 + - 短命令(建议 4)是 xim 包 `mcpp-short-cmd`,靠 `xvm.add()` 注册,**必须先装 xlings**。而 brew formula 的 `def install` 只写 `bin/mcpp`;install.sh 与 AUR 同样只把 `$PREFIX/bin` 进 PATH,xlings 躺在 `$PREFIX/registry/bin/`。四条安装通道里三条拿不到这个能力,报告人恰好在其中一条上。(顺带:`install.sh:13` 的自述 `$PREFIX/bin/{mcpp,xlings}` 是过时的。) + - 「复用本机 LLVM」(建议 2)的逃生门 `[toolchain] default = "system"`,在没有 `$CXX` 时兜底到**字面量 `g++`**(`probe.cppm:249-254`)。macOS 上 `which g++` = Apple Clang;Homebrew 的 llvm 是 keg-only 且 bin 下没有 `g++` 这个名字。而 mcpp 默认模板用 `import std`,Apple CLT 给不出可用的 std 模块清单。**这条路在报告人平台上大概率直接失败。** + - 而且这条逃生门**从未被验证过能构建成功**——`14_toolchain_fallback.sh:39-40` 自己写着「we don't verify success, only that the hard-error path doesn't fire」。 +- **推翻理由 2**:三项「归位」承诺全无落地物。help 着色的小 issue 还没开;#260 是留言板不是特性追踪器;承诺的 cmake Agent Skill 在 `.agents/skills/` 里不存在(只有 `mcpp-usage` / `mcpp-contributing` / `mcpp-release`)。**先关合集再承诺开新的,顺序反了。** + +**关闭前置条件**:(a) help 着色单开并落地;(b) 建议 7 要么落地 Skill 要么明确写 WONTFIX 并指向 cmake2mcpp;(c) 建议 2 明确 dedupe 到 #144 并在 #144 里写清逃生门的真实兜底;(d) 建议 5 若折进 #260,#260 里得留下记录。 + +### 3.2 #177(支持 cmake/make/xmake 项目) + +- **推翻理由 1**:复现步骤今天原样失败(实测,mcpp 2026.8.8.4):`error: path dependency 'cjson' (at '...') has no mcpp.toml`。git 与 path 两种形态走同一分支(`prepare.cppm:3861-3866`),`git log -S"has no mcpp.toml"` 找不到任何把这条硬拒改成设计决策的 commit。 +- **推翻理由 2**:「维护者已明确否决」是误读。2026-06-27 08:18 否决的是「把构建工具融入 mcpp」;11:02 报告人主动把提案收窄成「只在没有 mcpp.toml 时代跑一次上游构建工具」;11:19 维护者对收窄后提案的回复是「可能要看能不能导出标准布局的目录结构」+ xmake `package/manager` 链接——**这是可行性探讨,不是否决**。 +- **推翻理由 3(最要命)**:拟发留言里有三处会被当场证伪的错误,其中一条是「`[runtime] library_dirs` 烤的是 RUNPATH,不是 `-L`」——而**同一 tracker 里 #304 是 OPEN 的,标题就是它进 `-L`**。贴出去等于和一条 open issue 打架。 +- **推翻理由 4**:「给它写 12 行描述符」差一个数量级。同段引用的两个先例是 `compat.mysql-connector-cpp.lua`(328 行)与 `compat.openssl.lua`(466 行);最简单的 `compat.cjson.lua` 也有 64 行,还要用户自算 sha256、写双镜像 URL、写三套 OS 的 xpm 表。而 `docs/10-publishing-a-library.md` 里 `compat.` / `Form B` / `xpm` 命中数 **0**——让用户去写一个 mcpp 自己不文档化的 Lua schema。 + +**结论**:#177 要关只能以 **WONTFIX** 的名义关(它是 `enhancement` 标签),不能包装成「能力已经有了,只是文档没跟上」;且必须先补一章「接入非 mcpp 的 C/C++ 库」的文档。#144 应改标题重新立题为「外部/自定义工具链声明」,与 #276 归到同一条 roadmap。 + +--- + +## 4. 逐条分析 + +> 格式:**根因**(可能与 issue 归因不同)→ **当前代码**(file:line,全部为本轮实读)→ **修法** → **处置** + +### #396 · form-X 二进制的链接期物理检查 · VALID_OPEN · P2/L + +**根因**:不是「mcpp 有个 bug」,而是「mcpp 处在唯一能看见这件事的位置上,却什么都不看」。mcpp 把每个用户二进制放进形态 X(自带 loader + 自带 libc,且我们的 `ld.so` 编译进去的 cache 路径在任何机器上都不存在 ⇒ 无宿主回退)。链接期是唯一一个 PT_INTERP、RUNPATH、DT_NEEDED 闭包三者同时在场的时刻。 + +**当前代码**:唯一的物理检查是 `hermetic.cppm:103 verify_hermetic_link`(由 `ninja_backend.cppm:1579` 在跑 ninja **之前**调用)。它 `-###` 干跑,只看 CRT 目标文件(`:187`)和最后一个 `--dynamic-linker`(`:189`);允许前缀是**整个 xpkgs 根**(`:118`),所以 2.39 的 loader 配 2.44 的 libc 原样通过。不看 `-rpath`、不看 `-l/-L`、不读产物 ELF。`src/` 下 `getconf|host_glibc|GLIBC_2` 零命中。 + +**两条对 issue 原设计的修正(决定实施顺序)**: + +1. **规则 A 不能用 `our_glibc >= host_glibc` 硬失败**——那是上界代理不是物理。实测(host glibc 2.39):`libz.so.1` 最高只要 `GLIBC_2.14`、`libtinfo.so.6` 要 `2.33`、`libexpat.so.1` 要 `2.38`。在 host=2.43 的机器上用 2.39 载荷链 `libz.so.1`,按 issue 字面规则会被硬拒,而那个二进制跑得好好的。精确判据是 `our_glibc >= max(闭包里每个宿主对象 .gnu.version_r 的最高 GLIBC_x.y)`,可精确算出、永不误杀。 +2. **不要把规则 A 建在 `subos_info.host_glibc` 上**——今天一台机器上都没有这个键。xlings 侧确实实现了(`manifest.cppm:87/398/442`、`platform.cppm:195-212`),但 (a) mcpp 内嵌 pin 是 `2026.8.8.1` < 2026.8.9.1,沙箱 xlings 根本不会写;(b) 即使 bump,xlings **不回填**(`subos.cppm:161` 块有效即原样返回)。本机实测 `~/.mcpp/registry/subos/*/.xlings.json` 与 `~/.xlings/subos/*/.xlings.json` **全部无此键**。写了就是死代码。 + +**规则 B 在今天是构造性成立的**:`linkmodel.cppm:346-357` 的 `crtDir`/`libDirs`/`loader` 同取一个 `pp.glibcLib`,而 `pp` 由唯一权威解析(`probe.cppm:399` 明确拒绝按目录序猜 libc)。能打破它的三条都在 flags 层之外:用户 `ldflags` 自带 `-rpath`、依赖包的 `[runtime] library_dirs` 直接进 RUNPATH(`flags.cppm:719-722`)、以及 `post_install.cppm:380-388` 仍按目录序选 glibc 写进 clang.cfg。所以规则 B 值得作为**回归护栏**,但不是今天正在流血的伤口。 + +**修法**:阶段 0(读 `host_glibc` + mcpp 自己的 live probe,绝对路径 `/usr/bin/getconf`)→ 阶段 1(新模块 `src/build/elf_physics.cppm`,规则 B,用 patchelf 而非 readelf——`xim-x-gcc` 载荷**没有** readelf,`doctor.cppm:365` 那句「always present in our sandbox」不成立)→ 阶段 2(规则 A 精确式)→ 阶段 3(硬失败开关)。**不要复用 `[build] allow_host_libs`**:那个键名比它管的范围(CRT + loader 两项)大得多,绑在一起会把两件事混为一谈。 + +--- + +### #393 · `${mcpp.*}` 混合分隔符 · VALID_OPEN · P3/M + +**根因**:引擎变量的展开拼写没有单一规则。`prepare.cppm:5045-5047` 三个 token 用 `.string()`(原生),`prepare.cppm:5063` 的 `${mcpp.target_file:}` 用 `.generic_string()`。**同一个封闭词表两种拼写。** + +**issue 的建议是空操作**:`ctx.plan.outputDir.string()` 本来就是纯原生(`prepare.cppm:209` 全程 `operator/`),`make_preferred` 什么都不改。按此修法写完测试会「过」,bug 一动不动。 + +**真正活着的泄漏通道是 `a.command`**,不是 issue 猜的 file 字段:outputs/inputs/objects 三条路都被 `escape_ninja_path` 的 `generic_string()`(`ninja_backend.cppm:76`)压平,CDB 被 #391 的 `native_string` 兜住;而 command token 在 `ninja_backend.cppm:1391` 逐字输出。文档旗舰示例 `docs/05-mcpp-toml.md:1252` 的 `.arg("${mcpp.out_dir}/blob.o")` 就是这条路。 + +**同一形状在 env 通道完整复制了一份**:`build_program.cppm:230-231` 把 `MCPP_OUT_DIR`/`MCPP_MANIFEST_DIR` 设为 `.string()`,文档和 6 个 e2e(188/124/144/187/193/194)都写 `std::string(mcpp::out_dir()) + "/gen.cpp"`。`directives.cppm:668-670` 只绝对化 inputs/outputs,`a.command` 从不过那一层。**一个从不写 `${mcpp.…}` 的 build.mcpp,照样会在 Windows 上把混合路径塞进命令行。** + +**修法**:统一到 **generic**(`/`)——三条现有先例:`${mcpp.target_file:}` 本来就是、ninja 节点名一律 generic(`ninja_backend.cppm:65-77` 已论证过)、`escape_flag_path` 同理由。改 `prepare.cppm:5045-5047` 三行为 `.generic_string()`,`build_program.cppm:230-231` 同改。**不要对 `a.command` 的整个 token 做 path 归一化**——token 可能是 `--cpp_out=${mcpp.out_dir}/gen` 这种 flag。同时把 `substitute` 从 `prepare_build` 体内的 lambda 提成可单测的具名函数(它是 lambda 正是这类缺陷能潜伏三个月的直接原因),不变量锁「四个 token 展开成同一拼写」而不是「out_dir 别混」。 + +--- + +### #392 · WSL2 私有 glibc 2.39 撑不起系统库 · PARTIAL · P1/M + +**根因分两层,issue 把它们混在一起了**: + +**(A) 报告人机器上「产物绑 2.39」的直接原因不是 clang.cfg**,而是他的 `~/.xlings/subos/default/.xlings.json` 里 `subos_info.runtime` 本来就写着 `glibc@2.39`(来自 xlings ≤2026.8.8.x 的 `DEFAULT_RUNTIME`)。上游 2026.8.9.1 已把默认与 `latest` 改成 2.44 并在 commit message 里点名本 issue;**但 mcpp 的 `kXlingsVersion` 仍 pin 在 `2026.8.8.1`,这个修复还没到达 mcpp 用户。** + +**(B) mcpp 自己的两处真缺陷**: + +1. **fixup 仍在猜 glibc**——`post_install.cppm:380-388 find_sandbox_glibc_lib` 注释写「the newest installed version」,实现是 `directory_iterator` 取第一个带 loader 的目录,**没有任何排序**。它决定写进 `clang.cfg` 的 `-B/-L/--dynamic-linker/-rpath`(`:314-321`)。构建路径已在 PR #378 改成权威制(`probe.cppm:399` 拒绝按目录序猜),cfg 这条没跟上——**「mcpp build 出来的产物」和「人直接敲 clang++ 出来的产物」可以指向不同的 glibc**。本机正是这个状态(xpkgs 下 2.39 与 2.44 并存)。 + > 报告人观察到的「把 2.39 改名 2.39.off 仍被选中」正是目录序而非语义序的直接证据——`"2.39.off" < "2.44"`。 +2. `mcpp run` 把 toolchain 库路径塞进子进程 `LD_LIBRARY_PATH`,打死应用内所有 bash 调用(xdg-open / notify-send / gio trash)。 + +**修法**:【1】抬 `src/xlings.cppm:47` 的 pin 到 ≥2026.8.9.1(`check_version_pins.sh` 会自动校验其余 pin 点,别手维护列表)。验证判据是「新建 subos 的 `.xlings.json` 里 `runtime == glibc@2.44`」,**不是「命令退 0」**;且 xlings 明确只影响新建 subos,老 subos 不迁移。【2】`ensure_post_install_fixup` 加 `runtimeBinding` 形参,binding 非空 ⇒ 直接定位;为空 ⇒ **按语义化版本取最新**(复用 `lifecycle.cppm:213` 的 `version_greater`),并把 `:378` 那句撒谎的注释改成实际行为。 + +--- + +### #386 · `sources = []` 被静默忽略 · VALID_OPEN · P2/M + +**根因**:数据模型里缺一位「是否声明过」。`types.cppm:170` 的 `sources` 是裸 `std::vector`,消费方只能拿 `.empty()` 当「用户没写」的代理——而 `sources = []` 恰好也是空。`libs/toml.cppm:480-490` 的 `get_string_array` 对 `[]` 返回 engaged 的空 vector(不是 `nullopt`),信息在这一层就丢了。同一 `.empty()` 代理在 **5 处**各推导一遍。 + +叠加第二层:「自动扫描」是两条互不知情的通道——源 glob 走 manifest 的 `sources`,目标推导走 `toml.cppm:1501/1506` 对文件系统的直接探测,推导出的 entry 又在 `plan.cppm:1197-1198` 被合成为绕过 scanner 的编译单元。**关掉源 glob 也关不掉目标推导。** + +第三层(用户真正卡住的地方):「可被依赖」与「不认领脚下文件」今天是互斥的。有 `[package]` 才能被依赖,有 `[package]` 就必然认领 `src/**`。 + +**今天就能用、且比维护者给的方案 A 更贴合用户需求的形状**(实测跑通,应先回帖): + +```toml +[package] +name = 'spdlog-workspace' +version = '0.1.0' + +[build] +sources = ["!src/**"] # 未文档化的偏方,但今天就生效 + +[dependencies.spdlog] +path = 'cmake2mcpp_generated/spdlog' + +[workspace] +members = ['cmake2mcpp_generated/spdlog'] +``` + +根建出空产物、成员正常编译、别的项目依赖它也通过。**唯一前提**:根 `src/` 下不能有 `main.cpp`、不能有任何 `.cppm`(否则 `toml.cppm:1499` 的目标推导照样开火)。spdlog 恰好满足。 +> 维护者的方案 A(纯虚拟工作区)满足「能构建」但不满足用户明确提出的「能被其他项目依赖」——用户的反驳是对的,实测:`error: dependency 'mylib' resolved to package '' (mismatch with declared name 'mylib')`。 + +**修法(窄规则,零回归)**:在 `Manifest`(不是 `BuildInputs`,避免波及 `:195` 的 `merge_inputs` 追加语义)加 `bool sourcesDeclared`;`toml.cppm:1475` 的 `.empty()` 改 `!m.sourcesDeclared`;`toml.cppm:1499` 的目标推导加同一道门;`xpkg.cppm:1864` 的 Form B 也要区分「没声明」(仍报错)与「声明为空」(放行)。顺带收敛 `prepare.cppm:3209` 那份已漂移的默认 glob(4 项 vs 7 项,**走多版本 mangling staging 的包今天就会漏掉汇编源**)。 + +--- + +### #382 · subos `op="set"` 的语义 · PARTIAL · P2/M + +**主症状(包侧)已修**(xim-pkgindex #563 mesa 25.0.7.2 带 d3d12+iris、#565 驱动缺失时不强制)。**残留在 mcpp 侧**: + +**根因**:`op="set"` 在 xlings 的语义是**声明默认值**(set-if-unset),mcpp 读成了**命令**(无条件覆盖)。而且这个误读被 `c1e9360` 用一条断言 + 一大段注释固化下来,理由是「op 词汇表是 xlings 的契约,消费方不得擅改」。**这个理由本身是对的,错的是它对契约的认定**——xlings 的四个后端(`xlings/src/core/subos.cppm:1012/1120/1143/1166`)恰恰全是条件赋值: + +| 后端 | 代码 | +|---|---| +| POSIX | `: "${VAR:=value}"; export VAR;` | +| fish | `if not set -q VAR; set -gx VAR …; end` | +| pwsh | `if (-not $env:VAR) { … }` | +| in-process | `else if (existing.empty()) { set_env_variable(...) }` | + +所以现在的代码不是「守住了契约」,而是**以守契约之名违反了契约**:同一个 subos,`xlings subos use` 进去用户的 export 保留,`mcpp run` 进去被覆盖——正是那段注释自己声称要避免的分歧。 + +**代码里为「不修」给的两条理由,一条错、一条反过来支持修**:(a)「subos 需要 `set` 表达用户不得覆盖」在当前生态**零实例**——整个 xim-pkgindex 里 `op="set"` 只有 `wsl-gl-host-link.lua:298` 一处,其余全是 `prepend`,而唯一那处恰恰是用户需要能覆盖的那个;(b)「消费方不得给 op 加第二种含义」是对的,但它指向的是**现在**的代码。 + +**修法**:改 `subos_info.cppm::resolve_env`(`:250-299`)为 xlings 的两段式:先纯 manifest 折叠(`set` 存在则 `set` 赢、prepends 全丢;否则 prepends 按 provider 逆序去重拼接),再对着 ambient 应用(`prepend` 接在前;`set` **ambient 存在就整个让位**)。判据用「**有没有**」而不是「**空不空**」——两个消费点的 lambda 正好是 `getenv` 的 `optional`(`execute.cppm:369-372/891-894`),对齐 xlings 2026.8.8.2+ 的 `env_is_set`。**`73ab179` 那版用的是 `amb && !amb->empty()`,不要照抄**(那对应 xlings 2026.8.6.1 的旧语义)。 + +> 这个误读来回了三次:`73ab179`(两半都修)→ `1cc1052`(撤回 set)→ `c1e9360` 合入(只剩 prepend),而推翻撤回理由的第 3 条评论比 `c1e9360` 晚 33 分钟。**修的时候把 xlings 那四个行号写进注释和测试**——这是唯一能阻止第四次的东西,光写「set 是默认值」没用,上一版就是这么写的。 + +--- + +### #380 · `mcpp new` 名字契约缺失 · VALID_OPEN · P2/M + +**根因**:同一个字符串被两个互不相识的消费者直接吃掉,中间没有任何归一化或校验层。`cmd_new.cppm:24` 拿到 `positional(0)` 后除了 `name.empty()` 一个字符都不看,就同时交给文件系统路径(`current_path() / name`,`create.cppm:183` 与 `:217` 两处推导)和模板数据(`create.cppm:266-269` 手写 find/replace)。 + +死循环是**独立的实现缺陷**:那段循环每轮 `find` 都从位置 0 重新开始,替换值含 needle 时不是幂等而是自我再生。**仓库里已经有一个写对了的替换器**(`template.cppm:139` 的 `pos += to.size()`),builtin 骨架却手抄了一个写错的版本。 + +**issue 有一条主张不成立**:包模板渲染器**不会**挂死。但它有另一个真 bug——`template.cppm:142-144` 是三遍独立全文扫描,后一遍会命中前一遍刚插进去的内容;实测 `projectName = "{{self.name}}"` 时 `name = "{{project.name}}"` 渲染成 `name = "imgui"`(静默错值)。 + +**修法**:在 `mcpp.scaffold` 导出单一校验器 `validate_project_name`,在 `cmd_new.cppm:28` 之后、第 40 行分流之前**唯一一次**调用(这样 builtin 与包模板天然共用同一契约)。正向文法 `[A-Za-z_][A-Za-z0-9_-]*`,错误消息里把文法和被拒字符都打出来。**含 `.` 直接拒**并引用 `docs/spec/package-identity.md §3.2`——实测 `mcpp new foo.bar` 今天 rc=0 且 `mcpp build` 成功,即 mcpp 会给用户生成一个**本地能编、一发布就不合规**的 manifest,这比死循环更可能被真实用户撞到。删掉手抄的错替换器,builtin 改用 `template.cppm` 的那份(并顺手修串扰)。四处 `std::ofstream` 全部检查流状态。 + +> 零测试覆盖:`tests/unit` 下无任何 scaffold 测试,`02_new_build_run.sh:11` 只跑 `mcpp new hello`。**这些不是回归,是从来没被测过。** + +--- + +### #379 · 机器可读输出协议 RFC · PARTIAL · P2/L + +**已落地**(PR #385 / `55a39d9` / 2026.8.8.4):阶段 0(stdout 归属)、阶段 1(`src/wire.cppm` 信封 + `--protocol-version`)、阶段 2(`--format` 归一)、阶段 3a(`self env` / `xpkg parse` / `cache list` 三个 kind)。 + +**三处提案被有意改掉,而且每一处都改对了**: + +1. 未知 format 从「stdout + envelope」改成 **stderr + rc 2 + stdout 一字不写**。理由(`wire.cppm:251-254`):「一个还不知道会被给什么格式的请求,不该往协议拥有的通道里写东西;客户端在 stdout 上找不到 JSON 就已经得到答案了」。客户端判据相应改为**正向识别**(解析 stdout,要求 `schemaVersion` + `kind`)——因为 `--protocol-version` 在所有早于它的 mcpp 上**恰恰就是**「spawn 一个可能失败的命令」那种情况(实测:打印 `Error: unknown option` 到 **stdout** 且 rc=1)。 +2. `destructive` 布尔 → 命名 **effects 集合**(`InitMcppHome/ReadProject/WriteProject/WriteGlobalCache/Network/ExecBuildScript`)+ 放进 `--protocol-version` 的静态表。bool 既做不了执行前 gate,也分不开四种边界。 +3. `--json` **不做拼写归一**,永久保留 legacy 裸 payload——拼写兼容 ≠ payload 兼容。 + +**缺口不是惯性,是实施计划自己划的范围线**(`2026-08-08-wire-protocol-implementation-plan.md:19-21` 明写阶段 3b/3c 不在本次范围)。剩下 7 条,建议各开独立 issue、全部立项后再关 #379: + +1. `self env --format json` 补齐字段(W4-Step2 **计划内漏做**:`env_data_readonly()` 没有 index repos / default toolchain,而 vscode 侧正是基于「覆盖全部需求」才同意删掉自己的 home 推算逻辑)。约束:不能改用 `config::load_or_init`(会创建 6 项)。 +2. `mcpp build --configure-only [--format json]` + `invalidatedBy`。**前置**:必须先做第 5 条,否则 xlings 的索引刷新输出会污染这条命令的 stdout。 +3. `mcpp metadata [--resolved] --format json`。 +4. **CDB 原子写**(`compile_commands.cppm:306` 仍是 `std::ofstream os(path); os << content;`,非原子、不检查失败)。 +5. stdout 归属收敛(`xlings.cppm:414-421` 的 `print_status`、`package_fetcher.cppm:1036-1037` 硬编码的 `/*quiet=*/false`)。判据不能写「e2e 绿」——现有 e2e 用 `_inherit_toolchain.sh` 预置工具链,索引永远命中,**结构性覆盖不到这条路径**。 +6. 每个 kind 的 `data` golden 测试(`tests/fixtures/wire/` 是**空目录**;今天把 `mcppHome` 改名不会让任何测试变红,而 `docs/11-machine-output.md:196-204` 已把它写成公开承诺——这正是 RFC §4 拿 xlings 20/20 空 outputSchema 当反例要防的东西)。 +7. manifest supported-key 词汇表出口。 + +**关键窗口**:mcpp-vscode 侧全仓 grep `--format json` / `--protocol-version` / `xpkg parse` / `self env` **零命中**。wire v1 还没有真实客户端压过,**现在改字段是零成本**。 + +--- + +### #374 · 非模块 lib 误报缺模块根 · VALID_OPEN · P2/S + +**根因**:`has_lib_target` 与「有模块根」被当成了同一件事,而它们正交——`kind="lib"` 回答**产物形态**,`src/.cppm` 回答**接口形态**。`docs/05-mcpp-toml.md:369` 自己写的是 "Default convention",代码执行的是义务。这条判据引入时(`1ffd275`, 2026-05-09)mcpp 还没有 compat/Form-B 包,所有 lib 都是模块库,前提当时恰好为真;compat 生态进来后前提塌了,判据没跟着走。 + +**当前代码**:`validate.cppm:133-156`,行号未漂移。 + +**影响面比 issue 写的更宽,但触发条件更窄**:更宽——`xpkg.cppm:1870-1878` 在 `targets` 为空时自动补 Library target,所以**所有** Form-B compat 包都命中(不只显式写 `kind="lib"` 的 protobuf);更窄——普通消费者构建**不会**看到它(唯一调用点 `prepare.cppm:4896` 只验主 manifest,已实测),真正的泄漏口是 host 工具子构建(`prepare.cppm:4441/:4471`)与包自身当主工程构建(mcpp-index CI)。**写 PR 描述时别沿用「每个消费者每次构建」的说法。** + +**修法有一个必踩的陷阱**:新谓词必须用 **限定名** 比对。`scanner.cppm:789-791` 给 `u.packageName` 填的是 `namespace.name`;照抄 `u.packageName == manifest.package.name` 会对所有 namespaced 包判成「没有模块接口」,把 warning 全局静默——症状变成「issue 修好了」,实际是又踩了同一个坑。**这个坑今天已经吞掉了另一条检查**(§5.4)。 + +--- + +### #373 · 扫描器不剥块注释 · VALID_OPEN · **P1**/M + +**根因不是「少了一个 `strip_block_comments`」**,而是扫描器用「一行一行、一个 pass 一种语法」去近似 C++ 的词法状态机。raw-string / 块注释 / 行注释 / 普通字符串字面量是**同一层的互斥词法状态**,谁先出现谁生效;拆成串联的独立 pass,每加一种就必然引入一批新误判。 + +**「仅告警」这个定级是错的,实测两种硬后果**: + +- 把旧实现整段用 `/* */` 注释掉、里面留着 `export module X;`(而 `X` 真由同包另一文件提供)⇒ `mcpp build` **直接拒绝启动**:`error: scanner errors: … module 'mylib' already provided by …`(`scanner.cppm:938-946`)。触发它的是「注释掉一段旧代码」这种最日常的动作。 +- 块注释里孤零零一句 `export module future;` ⇒ 构建成功,但 build.ninja 里该 TU 变成 `build obj/main.o | gcm.cache/future.gcm : cxx_object`,声明了一个永不产出的 BMI。实测 `ninja -n` 仍要重跑 OBJ+LINK(对照组是 `no work to do`)——**该 TU 及其下游永久增量失效**,而 mcpp 层的工程级 fast path 会把这个症状盖住。 + +**修复必须连带解决三件事**(单独修块注释会更糟): + +1. **顺序已经是错的,而且是假阴性**:`scanner.cppm:588-589` 先 `strip_raw_strings` 后 `strip_line_comment`。只要 `//` 注释里出现一个未闭合的 `R"(`(写文档解释 raw-string、贴示例都会),`in_raw` 悬挂到文件末尾,**后面所有 `import` / `export module` 全部消失**。实测两例:真 `export module mylib;` 被吞 ⇒ 消费者收到 `module 'mylib' imported but not provided`(一个明明存在的模块);`import totally.real.module;` 被吞 ⇒ 一路到 g++ 才报 `failed to read compiled module`。 +2. **普通 `"..."` 字面量里的 `/*` 是必踩的地雷**:mcpp 自己的树里就有——`distribution.cppm:417` 的 `"/* Generated by mcpp. …"`,配对 `" */\n"` 在 `:426`。一个不认识字符串字面量的块注释 pass 会吞掉 417-426。安全阀:**普通字符串状态必须行内即抛、绝不跨行**(它本来就不能无转义跨行)。 +3. `scan_overrides` 本该是包侧逃生口,但被 `toml.cppm:422-425` 的「既不 provides 也不 imports 就报错」挡住(mcpp-index `compat.abseil.lua:114-132` 已把这件事写成 `-- KNOWN WARNING` 注释,结论与本 issue 一致)。**允许 `scan_overrides` 显式声明空结果**是一个低成本的独立改进,能给所有第三方源码包一个立刻可用的出口。 + +**修法**:把 `:137-189` 两个 helper 折叠成一个单遍词法状态机 `strip_noncode(line, in_raw, raw_close, in_block)`,状态优先级严格照 C++ 词法自左向右(in_raw > in_block > 代码态;代码态里 `//` 立即返回,**绝不能让本行后面的 `R"` 再改 `in_raw`**)。**方法论提示**:所有变体都能在 `tests/unit` 层用 `scan_file()` 直接断言,不需要真编译——先把 9 条单测写进去看红成什么样(至少两条今天就红),再动 helper。 + +**当前可用的绕过(可回复报告者)**:`MCPP_SCANNER=p1689`(`prepare.cppm:4876-4886`)切到 g++ 驱动的 P1689 扫描,实测告警消失。但它是实验开关,代码注释指向的 `docs/27` **在 docs/ 下并不存在**(docs/ 只到 `11-machine-output.md`)——这个悬空引用也该顺手清掉。 + +--- + +### #371 · IDE 模型与预构建 CDB · PARTIAL · **P1**/M + +**issue 把三件事捆成一个 RFC,而这三件事后来各自走散了**: + +1. **一个不成立的前提**——「CDB 只在构建成功后才写」。实际 `ninja_backend.cppm:1549` 写 CDB,`:1664` 才 spawn ninja,自 #24 起就是。#379 §1.6 后来实测推翻了它。 +2. **一个真实但被误述的成本问题**——不是「编不过就拿不到 CDB」,而是「为了拿 CDB 要付一次完整的依赖构建 + 编译 + 链接」,以及「prepare 阶段失败就一份 CDB 都没有」。**这才是唯一还站得住的核心诉求。** +3. **一个协议层**——已在 #379/PR #385 以 `src/wire.cppm` 落地,只是没姓 `ide`、没用 NDJSON 事件流。 + +更深一层:**CDB 是 `NinjaBackend::build()` 的副产品,而不是一个具名 configure 阶段的产出**。生命周期被绑在「跑一次完整构建」上,才同时长出三个症状。 + +**修法(裁到一条主线,按性价比排序)**: + +- **第 0 步(P0,~10 行,应立刻单独发)**:修 fast path 不重建 CDB(§5.1)。 +- 第 1 步:`write_compile_commands` 改成可失败 + 原子写(与 #379 第 4 条同一件事)。 +- 第 2 步:`--configure-only`。**`BuildOptions::dryRun` 是死代码**(`backend.cppm:14` 声明、`ninja_backend.cppm:1567` 读取——写完 CDB 就 return——全仓无任何写入方)。也就是说执行路径已经存在且在正确位置,**缺的只是一根从 CLI 到它的线**。这一点在 #379 的实施计划里没被指出,会让人高估阶段 3b 的工作量。 +- 第 3 步:CDB 覆盖 `tests/**` + dev-dependency 上下文。 +- **不做**:`mcpp ide` 命名空间、NDJSON 事件流、独立 ID 体系——已被 wire v1 覆盖或不需要。 + +--- + +### #370 · 全预发布键缺精确 pin 提示 · VALID_OPEN · P2/S + +**根因**:`pin_hint` 的触发条件绑在一个**语法事实**(「这个键 `parse_version` 解不动」),而真正该触发它的是**语义事实**(「任何范围都够不到任何候选」)。预发布键语法合法且可排序 ⇒ 落进 `literals` 分支;同时 SemVer 的预发布可见性规则(`version_req.cppm:313-318/341`)让不含预发布的范围对它恒不可见。**错误信息的分支结构照抄了解析器的分类,而不是用户的实际处境。** + +**当前代码**:`resolver.cppm:187-193` 三分类;`:202-207` 全仓唯一一份提示,文案还写死「These keys are not ordered versions」;`:230-239` 的 `best.empty()` 分支只在 `!unorderable.empty()` 时附加提示。 + +**三个包的键均已复核**:`compat.glad` = `0.0.0-651a425`、`compat.tray` = `0.0.0-8dd1358`、`compat.re2` = `2022-04-01`(issue 写的预发布标识 `04.01` 是笔误,实际是单个标识符 `04-01`——照抄进测试断言会对不上)。 + +**修法**:新增一条**并列**的 `prerelease_pin_hint`,**不要复用 `pin_hint` 的文案**(「不是有序版本」对预发布是错的:它们恰恰有序,只是被可见性规则挡住)。同时把 `unorderable` 那条的 `keys.front()` 改成取最高可用键——两条腿可以同时成立。 + +**顺手修上游入口**:`mcpp add @'*'` 会毫无提示地写进一个永不可解析的依赖(`commands.cppm:55` 让 `warn_unpublished_version` 对任何范围直接早退)。实测 `mcpp add 'acme:glad@*'` 输出 `Adding acme:glad v*` 并写入 manifest,要到下一条 `mcpp build` 才炸。**用户最可能的操作序列正是这个**,加同一条告警成本近乎为零。 + +--- + +### #313 · 7 条产品建议合集 · PARTIAL · P3/S + +逐条状态: + +| # | 建议 | 状态 | +|---|---|---| +| 1 | Homebrew 分发 | ✅ 已交付(`homebrew-publish.yml` / `f4a7568` / tap 跟到 2026.8.8.4) | +| 2 | 复用本机已装 LLVM/GCC | ❌ 只有未文档化的逃生门,无自动识别 → 应 dedupe 到 #144 | +| 3 | help 一级标题着色 | ❌ 未做(S 级,唯一便宜且无争议的一条) | +| 4 | 命令别名 | ⚠️ 仅 xlings 用户可用;**brew / install.sh / AUR 三条通道拿不到** | +| 5 | #260 做成命令 | ❌ 未做,且 #260 是留言板不是特性追踪器 | +| 6 | 模板项目 | ✅ `--template` / `--list-templates` + e2e + docs(imgui-m 0.0.6 带 `templates/{docking,window}`) | +| 7 | CMake ↔ mcpp 互转译 | ❌ 承诺的 Agent Skill 不存在 | + +**技术根因只有建议 2 有**:mcpp 的工具链身份是 `(family ∈ gcc|llvm|msvc, version, target)` 两轴闭词表(`compat.cppm:110-151 normalize_spec`),「系统已装工具链」被塞成 family 上的特例(`registry.cppm:369-371` 的 `is_system_toolchain` 只认 MSVC)。**「本机已有一份可用编译器」在当前模型里没有可命名的身份。** + +处置见 §3.1。 + +--- + +### #304 · `runtime.library_dirs` 落到链接行 · VALID_OPEN · P2/M + +**根因**:一个键被同时用来表达两件不同的事,而只有一件写进了名字和文档。`flags.cppm:720-723` 在发 `-Wl,-rpath,` 的同时顺手发了 `-L`。三者本来可分: + +| flag | 作用 | 谁需要 | +|---|---|---| +| `-Wl,-rpath,` | 运行期 | ✅ 唯一被需要的那一半 | +| `-Wl,-rpath-link,` | 解析直接链接的 `.so` 的传递 DT_NEEDED | ✅ 链接期唯一正当需求 | +| `-L` | 暴露给显式 `-lfoo` | ❌ 无人需要,全部危害来源 | + +**实测三方对照**(binutils 2.42 与 ld.lld 22.1.8 结果一致):只给 `-rpath-link` ⇒ 传递依赖解析成功;只给 `-rpath-link` 而写 `-lbar` ⇒ `cannot find -lbar`(即 rpath-link **不**参与 `-l` 解析);只给 `-rpath` ⇒ 传递依赖找不到。 + +**所以「保留链接期能力」和「不遮蔽别人的库」不是取舍,是一行 flag 拼错了。** issue 给的两个选项之外存在第三个更好的答案。 + +**比 issue 说的更严重**:`flags.cppm:884-885` 的拼接顺序把 `runtime_dirs` 放在 `user_ldflags` **之前**,所以即使用户在 `[build] ldflags` 里显式写 `-Lvendor`,runtime 目录仍然赢。**连用户自己指定的路径也遮蔽。** + +**修法**:`flags.cppm:720-723` 的 dep 目录那段把 `-L` 换成 `-rpath-link`;`:712-715` 的 `plan.toolchain.linkRuntimeDirs` **保持 `-L` 不动**(那是工具链私有 libc/libgcc 目录,`-lc`/`-lm`/`-latomic` 真的靠它解析)。**两段必须区别对待,别一把改。** + +--- + +### #293 · e2e 写穿符号链接损坏真实工具链 · PARTIAL · **P1**/M + +**根因**:测试沙箱的边界是「名字」而不是「物理位置」。所有下游代码拿到的都是**拼写上落在沙箱里、物理上落在用户家目录**的路径,它们各自都没有理由怀疑这一点。这是**乘法成本**的缺陷:每新增一个会写 payload 的动作,就多一条写穿路径。 + +**没变的部分(缺陷主体)**:`tests/e2e/_inherit_toolchain.sh:22-37`,`:33` 仍是 `ln -sf`;整个文件自 `75cf6c0`(2026-06-01)未动过。**48 个 e2e 脚本 source 它。** + +**已经关掉的部分(mcpp 自己的改写器)**:`post_install.cppm:26-59` 的 `containment_root`/`escapes_containment`、`:95-102` 的 patchelf 逐文件围栏、`:278-284` 的 cfg 围栏、`:485-497` 的入口所有权守卫(来自 `dea5f1f`, PR #275)。`fixup_gcc_specs` 已整个删除。 + +**时序更正(影响优先级排序)**:#273 的围栏(2026-07-24)**早于** #293 提交(2026-07-27)。所以 issue 里那次 24 个可执行文件被改 PT_INTERP 的事故,要么用的是围栏之前编出来的 mcpp,要么根本不是 mcpp 的 `patchelf_walk` 干的(**更可能是 xim 的 elfpatch**)。**mcpp 侧再加围栏收益有限;真正能挡住的是改 helper + 加完整性校验。** + +**修法**: + +- **A**(必做,S):`link_xpkg_payloads()` 从「整包软链」改成「硬链接农场(`cp -al`)+ 真拷可变小集(`bin/`、`libexec/`、`specs`、`*.cfg`)」。硬链接对 mcpp 自己的 patchelf **已经安全**(`post_install.cppm:113-147` 已改成 copy+rename 会断链)——**这是本次核验里最有价值的一个变化,它让 A 方案从不可行变成可行**。`|| cp -r` 降级分支必须保留。 +- **B**(必做,S):`run_all.sh` 跑套件前后各采一次 payload 指纹(只看 PT_INTERP + RUNPATH)并 diff,失败时指名哪个 payload。**损坏是静默的、跨运行的**,这是它能长期存在的原因。 +- **C**:注意别把 `install_integrity.cppm:189-195` 的 `looks_complete_legacy()` 一起改掉——`doctor.cppm:613` 的 `clean_all_incomplete` 在符号链接沙箱里本该是灾难,是这层保护让那 42 个无 marker 目录幸免。 + +--- + +### #290 · xpkg 描述符缺版本条件轴 · VALID_OPEN · P2/M + +**根因**:条件化构建输入有两条通道,求值时机决定各自能表达什么——平台轴是**解析前的文本拼接**(`xpkg.cppm:1143-1148`),target triple 轴是**解析后的结构化合并**(`prepare.cppm:462-472`)。版本轴两条都没接上。**但这不是「时机上做不到」**:`packageVersion` 早就是 `synthesize_from_xpkg_lua` 的形参(`xpkg.cppm:1130`),构建路径上永远是 `resolve_semver` 之后的具体值。根因是当年加平台块时只把 os 接进了拼接点,没有把它一般化。 + +**issue 把成本估高了一个数量级**:不需要 Manifest 暂存、不需要新的延迟合并阶段、连 `append(BuildInputs&,…)` 都用不上——**十几行文本拼接就够**。真正的成本在 lint 闭包(`--all-versions`)和索引 floor 抬升的发布流程上,issue 一个字没提。 + +**举例已作废,建议换掉**:`ggml-org.llamacpp` 现在是单版本 Form A,历史上从没有过 mcpp 块。换成 `compat.catch2`(2.13.10 vs 3.15.2,两套完全不同的源码布局,注释自己写着「THIS DISJOINTNESS IS THE LOAD-BEARING PREMISE」)+ mcpp-index#187 那个 `undefined symbol: Catch::Session::Session()` 的真实故障。 + +**受众可以收窄**:自己能控制打包的库(tinyhttps 9 版、xpkg 15 版、imgui 6 版)走 Form A,把 mcpp.toml 塞进每个 tag 的 tarball,版本差异天然随源码树走。真正卡住的是 `compat.*`——sha256 pin 的原始上游 tarball,塞不进任何东西。**本 issue 的受众是「compat 层的多版本包」这一个明确子集(今天 8 个描述符)。** + +**修法**:`kKnownXpkgKeys` 加 `"version"`,在平台拼接**之后**追加版本拼接(标量键靠后写覆盖,版本应赢过平台),解析循环加 skip 分支。**匹配语义必须以字面键相等为主形态**——索引里活着的键包括 `b10069`、`1.92.8-docking`、`2026.08.08`、`latest`,`version_req::matches` 对它们一律失败。 + +--- + +### #289 · 沙箱 xlings 永不刷新 · PARTIAL · P2/M + +**issue 的三条核心主张全部被 PR #378(`fdad165`)推翻**:`acquire_xlings_binary` 现在接收 pin、读实际版本、严格落后才替换,且替换前先给候选源定价(避免用系统的 0.4.51 覆盖 2026.8.2.1);`doctor.cppm:405-418` 加了同一项检查;pin 漂移由 `.github/tools/check_version_pins.sh` 在 CI 强制(本地实跑 `OK: xlings pins all at 2026.8.8.1`, exit 0)。 + +> 讽刺的是,**issue 里最有价值的那半条(「Adjacent, same shape」的 pin 一致性)反而先被修好了**,而且长成了比原提议更强的东西:除了 xlings pin,还管住 mcpp 自身版本的四处一致性,并把「bootstrap pin 不得超前于在建版本」写成可执行判据。 + +**残留两洞,都落在「修补放在控制流到不了的地方」这个形状上**: + +1. **Windows 上整套机制空转**:`xlings_binary.cppm:160` 把 `2>/dev/null` 硬编码进一条经 `cmd.exe /c` 执行的命令 ⇒ 命令不执行 ⇒ 返回空串 ⇒ acquire 退回旧的 early return、doctor 退化成一条 warn。**issue 点名的受害平台恰恰是修复不生效的平台。** 仓库里已有 `mcpp::platform::null_redirect` 就是为这件事准备的。 +2. **自带副本这条最可靠的来源没接进来**:候选源只有 `MCPP_VENDORED_XLINGS` 和 `which xlings`(`:214-223`)。而正是「机器上的 xlings 很旧」这个前提,决定了 `which xlings` 在最需要自愈的场景里也是旧的 ⇒ 打一行 Note 就放弃,尽管一份与 pin 逐字一致的 xlings 就躺在 `/registry/bin/xlings`(实测 2026.8.8.4 包内为 2026.8.8.1)。 + +**副作用**:`doctor.cppm:411-417` 的「It is replaced automatically on the next `mcpp self init`」在上面那个分支里是错的。仓库里遗留的 `tests/e2e/doctor.log` 就是现成复现——同一次运行第 16 行说「no newer source is available (keeping it)」、第 25 行说「会自动替换」。 + +**守卫必须在本平台真跑一次探针**,而不是再写一遍字符串比较(后者在 Linux 上照样绿)。 + +--- + +### #284 · `toolchain remove` 拒绝空格分隔写法 · VALID_OPEN · P2/S + +**根因**:同一个「双写法 + partial 版本」的用户契约在三个子命令里各自独立推导了一遍,remove 那份只推导了一半——CLI 声明层(`cli.cppm:421-425` 只一个位置参数)、路由层(`cmd_toolchain.cppm:48` 只传 `positional(0)`,而 `positional(1)` **里其实已经有值**)、领域层(`lifecycle.cppm:703-705` 签名少一个参数,且**全函数体零调用** `resolve_version_match`)。 + +**issue 有一条归因是错的,会带偏修复**:「since partial versions already resolve here」不成立。实测两个 15.x payload 都在场时 `remove gcc@15` 直接 `not installed`。**按 issue 字面只做「加位置参数 + 抄 default 的 help 文案」,会得到一个文案承诺 partial、行为不支持 partial 的命令,比现状更糟。** 修复必须包含 partial 解析。 + +--- + +### #283 · target pin 静默压过显式默认 · VALID_OPEN · P2/M + +**根因不是「判据写窄了」**,而是全局 config 的 `[toolchain] default` 是一个**双写键**:用户(`mcpp toolchain default`)和 mcpp 自己(首次运行持久化,`prepare.cppm:1697/1717/1835`)写的是同一个格子,**配置层没有 provenance 字段能把二者分开**。#332 为了保住「mcpp 可以修正自己写下的旧默认」,只能把 `GlobalDefault` 判为非用户显式(`prepare.cppm:651-653`),并用 `test_windows_defaults.cpp:75` 钉死。`prepare.cppm:1769-1774` 的注释已经亲口承认过这个缺口。 + +**更硬的一半(issue 没提)**:在 `mcpp.toml` 里写死 `[toolchain] linux = "gcc@15.1.0"`,然后 `mcpp build --target x86_64-linux-musl` —— `tcOrigin = ManifestToolchain`(用户显式)但 `targetFromGlobalDefault = false`(target 是命令行给的)⇒ `pinWouldOverruleUser = false` ⇒ `prepare.cppm:1441` 照样覆盖。**连写进 mcpp.toml 的显式工具链都保不住**,只要 target 是显式给的。issue 的 workaround(改用 `[target.X]`)还能救,但用户完全没有理由预期 `[toolchain]` 会输给一个 pin。 + +**修法(必须先补 provenance,不能直接让全局默认与 `[target.X]` 同权)**:`GlobalConfig` 加 `defaultOrigin`(缺省 `"auto"`,老配置向后兼容),`lifecycle.cppm:687` 传 `"user"`、三处首次运行传 `"auto"`;`TcOrigin` 新增 `UserGlobalDefault` 并算作用户显式。**同时补一条诊断**:pin 真的覆盖了用户设置时必须说话,而不是静默。 + +--- + +### #276 · 嵌入式 Linux SDK 集成 RFC · VALID_OPEN · P2/XL + +**根因**:不是缺机制,是**缺输入通道**——而通道之所以缺,是因为 mcpp 的工具链模型只承认一个权威:**编译器二进制自己**。`detect()`(`detect.cppm:37-125`)把 triple(`:81`)、sysroot(`:112`)、std 模块源(`:105-109`)全部从同一个二进制推导。这在「mcpp 自带整套工具链」的世界里正确且优雅,但嵌入式场景要求解耦「用什么编译器生成机器码」(应来自 mcpp)和「产物将运行在哪个世界里」(必须来自设备 rootfs)。 + +**地基已经全在**:`linkmodel.cppm:29-30` 的 `CLibMode::Sysroot`、`:81-83` 编译侧 `--sysroot=`、`:106-109` 链接侧 `--sysroot=` + `--dynamic-linker=` + `-L/-rpath`、`:405-408` 的 `else if (!tc.sysroot.empty()) sysroot_mode(...)`;`hermetic.cppm:130` 外部 sysroot 自动放行(**不需要 `allow_host_libs`**)。**只要 `tc.sysroot` 有值,整条链路自动就绪。** + +**最小切入点 = P0「sysroot 缝」,一个字段贯穿三个文件**: + +1. `TargetSection` 加 `std::filesystem::path sysroot`,`toml.cppm:1155-1160` 的 `[target.]` 循环加一行解析,并在 `:1010` 的合法键白名单登记(否则未知键让整份 manifest 报错)。 +2. `--sysroot ` 写进 `BuildOverrides`,供 CI / Buildroot 的 `.mk` 直接注入。 +3. **唯一写入点必须放在 `detect` 之后**(`prepare.cppm:1729` 与 `:1841` 两次 detect 之后)——`detect.cppm:112` 会无条件覆盖它。 +4. **⚠️ fingerprint 必须同时加轴,不加就是回归**。 + +**今天就能试的路径(issue 与评论都没提)**:`[toolchain] = "system"` + `$CXX` 已经能注入外部交叉编译器,并连带采纳它自己的 sysroot(`prepare.cppm:1556-1558` → `probe.cppm:249-253` → `probe.cppm:330-336`「foreign but usable sysroot beats no sysroot」→ `linkmodel.cppm:405-408` → `hermetic.cppm:112-116` 整体早退)。**厂商 SDK 的 gcc ≥ 15(带 `bits/std.cc`)今天就能试。** + +--- + +### #259 · 裸机 `toolchain install llvm` 后 clang exec 127 · VALID_OPEN · P2/M + +**三层根因,和 issue 正文的归因完全不同**: + +- **表层(索引侧,已排除)**:`llvm.lua:23-40` 一直声明着 `xim:glibc`,现在还是 `>=2.39` 并新增 `xim:gcc-runtime@15.1.0`。「包漏声明」不成立(作者自己已 retract)。 +- **中层(xlings 侧,仍在)**:`installer.cppm` 里 dep 节点的两类失败(`:2266-2269` load_package 失败、`:2313-2316` 下载资源解析失败)是 `log::warn` + `continue`,不进 `plannedDownloads`、不 emit `InstallPhase::Failed`;`commands.cppm:645` 的退出码只看 `failedCount` ⇒ **依赖被丢掉、主包照装、elfpatch 照样把不存在的 glibc loader 烙进 PT_INTERP、退出码 0**。 +- **深层(mcpp 侧,这才是它该留在 mcpp 仓的理由)**:mcpp 对「刚装完的工具链能不能跑」一个断言都没有,而它自己写的三层兜底全是空转: + 1. `lifecycle.cppm:559-564` / `prepare.cppm:1668-1670` 传无版本的 `"xim:glibc"`,被 `package_fetcher.cppm:932` 的 `@` 门当场拒掉,**自引入至今一次都没装成过任何东西**,返回值一处 `(void)` 一处只 `log::debug`; + 2. `post_install.cppm:441` 找不到 glibc 时静默跳过 patchelf(gcc 分支 `:422-427` 会 warn,**llvm 分支连 warn 都没有**); + 3. `lifecycle.cppm:576-582` 安装后只 `exists(bin)`,不 exec。 + +**修法**:F1 删掉两个空转循环(评论已证明 mcpp 没有绕过 xlings 的依赖解析,即使能跑也是重复劳动;而在 mcpp 里钉死 glibc 版本常量会立刻和上游打架——`latest` 正在 2.39→2.44 动);F2 在 `lifecycle.cppm:582` 之后、打印 `Installed` 之前对 `bin` 跑一次 `detect()`,失败时用 `read_elf_interp(bin)`(`post_install.cppm:534`)读 PT_INTERP 给出可执行的诊断,而不是把 `probe.cppm:107` 的裸文案透出去。 + +**CI 缓存复刻了 issue 正文点名的那个坑**:`ci-linux-e2e.yml:104-166` 的 `hermetic` job 环境是对的(`debian:stable-slim`,显式断言 `! command -v gcc`),但它 `actions/cache` 了 `~/.mcpp` 且带 `restore-keys: mcpp-hermetic-`。**一旦某次跑绿并存了缓存,之后每次 llvm 都是「已经装好」的,冷装路径再也不被覆盖**——和「本机永远注意不到,因为之前装 gcc 已经 park 了 glibc」是同一失效模式,只是从开发机搬到了 CI。 + +--- + +### #256 · clang 20/22 模块 BMI 毒化名字查找 · PARTIAL · P2/S + +**归因正确,mcpp 不背这个锅。** ask 1(文档)扎实:`docs/03-toolchains.md:348-399` 的 "Known Toolchain Hazard" 段含毒性形状、安全形状、workaround、回链与 canary 指路,中文镜像在 `docs/zh/03-toolchains.md:335`。 + +**ask 3(canary)存在但有三个洞**,而且都在「修补放在控制流到不了的地方」上: + +1. **control 步骤只 precompile 不 import**(`150_...sh:92-95`)。`ctl.pcm` 产出后再没被引用,而注释明确声称它守「导入侧必定能编过」。实测该断言今天在 22.1.8 上 rc=0——**可写、为真、就是没写**。 +2. **ACTUAL 仅由退出码推导**:上游把 SIGSEGV 换成一条正当诊断时,canary 会继续全绿。应改成崩溃指纹(`PLEASE submit a bug report` / `Stack dump`),非零退出但无指纹应当报第三态并 FAIL。 +3. **未登记的大版本 `exit 0`**:索引一旦把 llvm `latest` 推到 23,canary 在 200 个测试的输出流里以 OK 通过。**ask 3 的原话是「a future clang bump that fixes (or re-breaks) this is visible」,这条路径恰好把它变成不可见。** + +**再叠加**:`ci-linux-e2e.yml:90/93` 只装 gcc,缓存血统里唯一的 llvm 是旧版——**这个 canary 在 CI 里守的不是默认工具链版本**。 + +**ask 2(上游 LLVM 报告)至今没有任何记录。** 建议:mcpp 侧三处小改(A/B/C)可以一个 PR 收掉;上游追踪转成 issue 正文的一行链接,不必因此保持 open——但**在 canary 修好之前不要关**,否则「未来 clang 修好了」这件事没有任何人会知道。 + +--- + +### #215 · cppfly 加 Clang 反射行 · UPSTREAM_BLOCKED · P3/S + +**触发条件未满足,实机核过**:`xim-x-llvm/22.1.8/bin/clang++ -freflection -std=c++2c -fsyntax-only` → `unknown argument: '-freflection'`。表 `cppfly.cppm:94-99` 只有 GCC 16 一行,未动过。 + +**真正的问题不是「该不该加行」,而是「没有任何机制会告诉你表已经过期了」**:今天所有声称「clang 没有反射」的测试断的都是 mcpp 自己表的输出——`test_cppfly.cpp:80` 的输入是手搓的 `Toolchain` 结构体(连编译器进程都不启动),`101_cppfly_llvm_soft.sh:49` grep 的是 mcpp 自己打印的 summary(而那行内容正由那张表决定)。**两条断言都是同义反复。** + +**修法**:(A) 把 issue 正文的触发条件改成机器可判的谓词(「最新 llvm payload 的 clang++ 在 `-std=c++2c` 下定义 `__cpp_impl_reflection`」);(B) 加 `102_cppfly_reflection_canary.sh`,**反转断言极性**——探到就 FAIL 并指出该改哪张表。 + +**加行之前必须先修一处**:`cppfly.cppm:161-164` 的 gate 循环只比 `rule.family` 就 `break`(版本判断在循环外),而 `latest_std_canonical`(`:142-148`)是 `continue` + 循环内比较、注释还写着「rows per family ordered newest-first」。**给 Clang 加第二行(上游拼写 vs fork 拼写)的那一刻,第二行会静默不可达**,而单测因为只有单行 fixture 也照样绿。 + +**同一形状的当下真风险**:`100_cppfly_reflection.sh:16-19` 用「最新已装 gcc payload 是否接受 `-freflection`」做前置探测,不通过就静默 `SKIP-INLINE`。**GCC 17 若把 `-freflection` 改名或默认开启,唯一那条硬路径反射 e2e 会静默停跑。** + +--- + +### #177 + #144 · 外部构建系统 / 本机工具链 · PARTIAL · P2/L + +**共同缺口**:mcpp 只有「自己下载并完全掌控」这一种资源获取模式,**凡是「外部已经存在的东西」都缺少一个申报入口**。 + +- 工具链轴(#144):`Family` 是封闭 enum(`registry.cppm:30`),`is_system_toolchain` 只认 MSVC,manifest `[toolchain]` 只收 `family@version` 不收路径。唯一外部入口是那个没写进文档、也进不了全局默认的 `"system"` 字面量。 +- 库/sysroot 轴(#177 + #276):`CLibMode::Sysroot` 机制已在,但来源写死在 `probe_sysroot`;没有 pkg-config;git/path 依赖必须自带 mcpp.toml。 + +**#177 今天的三条可用路径**:`[build] include_dirs` + `ldflags`(**注意:没有 `library_dirs`/`link_libs` 键**)、`build.mcpp`(可 `std::system()` 起 cmake/make/xmake,再用 `mcpp:include-dir=`/`link-search=`/`link-lib=` 喂回构建图,配 `rerun-if-changed` 做增量——**这就是报告人要的形状,已实测端到端跑通**)、compat 描述符 + 项目本地索引 `[indices]`。 + +**但这三条都不构成关闭理由**(见 §3.2):字面复现今天原样失败;「维护者已否决」是误读;`docs/10-publishing-a-library.md` 里 `compat.`/`Form B`/`xpm` 命中数为 0。 + +**处置**:#177 → 先补一章「接入非 mcpp 的 C/C++ 库」的文档(写清三条路线的选择判据),再以 WONTFIX 关闭字面需求;#144 → 改标题为「外部/自定义工具链声明」,与 #276 归入同一 roadmap,P0 是把 `"system"` 逃生阀从「事实存在」变成「产品特性」(文档化 + `mcpp toolchain default system` 放行 + 外部工具链身份进 fingerprint)。 + +--- + +## 5. 顺带发现:issue 一个字没提的 30 条 + +按「是否比所属 issue 更该先修」排序。**前 6 条建议脱离原 issue 单独立项。** + +### 5.1 🔴 P0 · fast path 命中时 CDB 永不重建 —— 今天就在破坏 mcpp-vscode + +`execute.cppm:705` 的 `try_fast_build` 新鲜度门只比对 `build.ninja` 的 mtime 与 mcpp.toml / 源码(`:757-764`),**不检查任何产物是否存在**;命中后 `:768-774` 直接 `run_ninja_fast` + `return 0`,`NinjaBackend::build()` 整个不执行,`write_compile_commands` 一次都不跑。 + +``` +$ mcpp new demo3 && cd demo3 && mcpp build # 成功,CDB 生成 +$ rm compile_commands.json +$ mcpp build ; echo $? # "Finished dev in 0.00s" ; 0 +$ ls compile_commands.json # No such file ← 且此后每次都如此 +``` + +**触发条件很日常**:`mcpp new` 写的 `.gitignore` 只有 `target/` 和 `.mcpp/`,`compile_commands.json` 未被忽略 ⇒ `git clean -fd` 删掉它、保留 `target/` ⇒ 下一次 `mcpp build` 命中 fast path ⇒ **CDB 再也回不来**。 + +而 mcpp-vscode(`src/extension.ts:105,128-136`)整个流程是「CDB 不存在 → 提示 Run mcpp build → 用户点 Build → 再检查」。**这个组合让扩展陷入死循环:构建每次都成功,提示每次都还在。** + +**修法**:`execute.cppm:764` 那串 freshness 检查后加一条产物存在性检查。同一形状在 `try_fast_run`(`:788` 起)也在。**~10 行。** + +### 5.2 🔴 P1 · 扫描器的 strip 顺序本身就是一个正在跑的假阴性 + +见 #373 §。`//` 注释里一个未闭合的 `R"(` 会让 `in_raw` 悬挂到文件末尾,**后面所有 `import`/`export module` 全部消失**。写文档解释 raw-string、贴一段带 raw-string 的示例都会触发。**这比 #373 报的块注释问题严重:那个是假阳性(多报),这个是假阴性(漏报)。** + +### 5.3 🟠 P1 · `--no-color` 在 TTY 下是空操作 + +- `cli.cppm:107`:`--no-color` → `ui::disable_color()`(argv 预扫描,早于任何输出) +- `ui.cppm:239`:`disable_color() { g_color = false; }` —— **只改 `g_color`,不置 `g_inited`** +- `ui.cppm:233-237`:`init() { if (g_inited) return; g_color = detect_color(); g_inited = true; }` +- 每个输出入口都先调 `init()` + +时序:`--no-color` 置 false → 第一条 `ui::status()` 调 `init()` 发现 `g_inited` 仍 false → 重新 `detect_color()` → **在 TTY 上又变回 true**。 + +**没人发现的原因**:CI 和所有 e2e 都通过管道捕获,`detect_color()` 在非 TTY 下本来就返回 false —— **「无色」这个结果是对的,但原因是错的**。`MCPP_NO_COLOR` / `NO_COLOR` 走 `detect_color()` 内部,不受影响;只有 `--no-color` 这一条路径坏了。修法一行:`{ g_color = false; g_inited = true; }`。 + +### 5.4 🟠 P1 · `[modules].exports` 检查对任何 namespaced 包是静默空操作 + +`validate.cppm:98` 比的是 `u.packageName == manifest.package.name`,而 `scanner.cppm:789-791` 填的是**限定名**。于是 `namespace="acme" / name="lib1"` 时 `"acme.lib1" != "lib1"`,`actual` 永远是空集,**整个 exports 校验(含 `strict`)一条都不触发**。 + +实测:`[package] name="lib1", namespace="acme"` + `exports = ["totally.not.this"]` + `export module acme.lib1;` → `mcpp build` **通过,零诊断**;删掉 `namespace` 那行,同一工程立刻报 `module 'acme.lib1' is exported by code but not listed in [modules].exports`。 + +成因可追:`scanner.cppm:786-788` 的注释说这个限定名是为了让「模块名必须以包名为前缀」的检查工作,而那条检查在 `0b8b81b` 里被删了,留下的 `:98` 就悬空了。**修 #374 之前必须先看懂这条**,否则会原样再踩一次。 + +### 5.5 🟠 P1 · 两个「看起来在做事」的空转循环 + +`lifecycle.cppm:559-564` 与 `prepare.cppm:1668-1670` 的 `{"xim:glibc","xim:linux-headers"}` 预装循环传的是无版本 spec,被 `package_fetcher.cppm:930-935` 一进门就拒。**自引入至今一次都没装成过任何东西**,一处 `(void)` 丢弃、一处只 `log::debug`。glibc 实际是靠 gcc 的 xim 依赖顺带装上的,所以症状被遮住了。 + +### 5.6 🟠 P1 · Windows 上 xlings 版本探针整套空转 + +见 #289 §。`2>/dev/null` 经 `cmd.exe /c` 不成立 ⇒ 返回空串 ⇒ 两个消费点都退化。仓库里已有 `mcpp::platform::null_redirect` 就是为这件事准备的。 + +### 5.7 其余 24 条(按 issue 归属) + +| 归属 | 发现 | 级别 | +|---|---|---| +| #393 | `MCPP_OUT_DIR`/`MCPP_MANIFEST_DIR` 用 `.string()`,`a.command` 从不过归一化层 ⇒ **不写 `${mcpp.…}` 的 build.mcpp 照样在 Windows 上产出混合路径** | P2 | +| #393 | 含 `${mcpp.` 的 `role="source"` 输出被 `prepare.cppm:2969` 整体排除在源集外 ⇒ **产物永远不会被编译,零诊断**,而 `docs/07-build-mcpp.md:222-224` 承诺「畸形 action 是硬错误,绝不静默跳过」 | P2 | +| #386 | `prepare.cppm:3210` 的默认 glob(4 项)已与 `toml.cppm:1476`(7 项)漂移 ⇒ **走多版本 mangling staging 的包今天就漏掉汇编源** | P2 | +| #380 | `template.cppm:142-144` 三遍独立全文扫描 ⇒ 占位符串扰,静默错值 | P2 | +| #380 | `template.cppm:156` 的 `recursive_directory_iterator(…, ec)` 接住 `ec` 从不判读 ⇒ 模板目录读不了时零迭代、返回成功、打印 "Created" | P2 | +| #380 | `mcpp new foo.bar` rc=0 且能构建,但违反 `docs/spec/package-identity.md §3.2` ⇒ 生成**本地能编、一发布就不合规**的 manifest | P2 | +| #304 | `flags.cppm:884-885` 把 `runtime_dirs` 拼在 `user_ldflags` **之前** ⇒ 用户显式 `-Lvendor` 也被压过 | P2 | +| #304 | macOS 上 `[runtime] library_dirs` **完全是死的**(`flags.cppm:873-874` 既不发 `-L` 也不发 `-rpath`),而 `execute.cppm:1370-1377` 还在警告「dependencies must be reachable through the binary's rpath」——mcpp 自己从没写过那个 rpath | P2 | +| #304 | 同一个键在四个后端有四种链接行含义(ELF `-L`+`-rpath` / MSVC `/LIBPATH:` / clang-MSVC-ABI 与 MinGW 什么都不发 / macOS 什么都不发),文档只有一句「进 RUNPATH」 | P3 | +| #370 | `mcpp add @'*'` 静默写进永不可解析的依赖 | P2 | +| #370 | `best.empty()` 的消息完全丢掉 `aliases` ⇒ 「只有 alias + 预发布键」的包不会被告知 `= "latest"` 可行 | P3 | +| #374 | 限定包名三处独立推导且算法不一致 ⇒ `namespace="acme"`+`name="acme.lib1"` 时 `plan.cppm:1054` 静默失配(gtest_main.cc 这类 entry object 该丢不丢) | P2 | +| #290 | `target_cfg` 里写 `cfg(version=…)` 能过解析但 `match_kv` 不认 ⇒ **永远不生效且不报错** | P2 | +| #290 | mcpp-index CI 只跑 `mcpp xpkg parse` 不带 `--all-os` ⇒ windows 段的 typo 在索引 CI 上隐形 | P3 | +| #289 | `load_or_init()` 现在每条命令都 spawn 一次 `xlings --version`(实测 134ms,无缓存)⇒ 对 `self env` 这类瞬时命令是纯加价 | P3 | +| #289 | `doctor.cppm:411-417` 的补救文案在「无更新源」分支里是错的(`tests/e2e/doctor.log` 第 16 vs 25 行自相矛盾) | P3 | +| #293 | 别把 `install_integrity.cppm:189-195` 的 `looks_complete_legacy()` 一起改掉——`doctor.cppm:613` 的 `clean_all_incomplete` 在符号链接沙箱里靠它才没酿成灾难 | ⚠️ | +| #396 | `xim-x-gcc` 载荷**没有** readelf,`doctor.cppm:365` 的「always present in our sandbox」不成立;patchelf 才是两边都保证有的 | P3 | +| #396 | `[build] allow_host_libs` 的名字比它管的范围(CRT+loader 两项)大得多,做 #396 时**别复用这个键** | ⚠️ | +| #379 | `xpkg parse --format json` 失败契约自相矛盾:坏 descriptor 时 stdout 是合法信封但错误在 `data.error` 里是人类文本、`diagnostics` 为 `[]`;另外三个失败分支 stdout **一字不写** ⇒ 客户端把「descriptor 写错」误判成「mcpp 太老」 | P2 | +| #379 | 静态效应表与实际信封对不上(`self env` 声明 `["init-mcpp-home"]`,实际信封 `[]`、创建项数 0) | P3 | +| #379 | 第四种机器输出拼写:`mcpp test --message-format json`,不带信封、不走 wire,而契约文档一个字没提——**它恰恰是 CI 最可能消费的出口** | P2 | +| #379 | `--protocol-version` 是裸 argv 扫描(`cli.cppm:629-635`),`mcpp new --template --protocol-version` 会被劫持 | P3 | +| #379 | 未知命令时 `print_usage()` 进 **stdout**(rc=127),与协议 stdout 归属冲突 | P3 | +| #215 | `cppfly.cppm:161-164` 的 gate 循环不支持同族多行(`break` 而非 `continue`),与 `latest_std_canonical` 语义不一致 ⇒ **加 Clang 第二行的那一刻它会静默不可达** | ⚠️ | +| #215 | `100_cppfly_reflection.sh:16-19` 是会自我关闭的守卫:GCC 17 改名或默认开启 `-freflection` ⇒ **唯一那条硬路径反射 e2e 静默停跑** | P2 | +| #256 | `ci-linux-e2e.yml` 只装 gcc,canary 守的不是默认工具链版本 | P2 | +| #259 | `ci-linux-e2e.yml:104-166` 的 hermetic job 缓存 `~/.mcpp` 且带 `restore-keys` ⇒ **冷装路径跑绿一次后再也不被覆盖** | P2 | +| #373 | `prepare.cppm:4876-4886` 的注释指向 `docs/27`,**docs/ 下不存在**(只到 `11-machine-output.md`) | P3 | +| #373 | `scan_overrides` 被 `toml.cppm:422-425` 的「既不 provides 也不 imports 就报错」挡住,包侧没有逃生口 | P2 | + +--- + +## 6. 建议执行顺序 + +### 批次 A · 独立小修(总计约 60 行,可一个 PR 收掉) + +1. fast path 产物存在性检查(§5.1)—— **10 行,今天就在破坏已发布扩展** +2. `disable_color()` 置 `g_inited`(§5.3)—— 1 行 + 一个直连 `ui::` 的单测(**不能靠现有 e2e,无色也过 = 假绿**) +3. `xlings_binary.cppm:160` 换 `null_redirect`(§5.6)—— 1 行 + 一个**在本平台真跑探针**的单测 +4. 删掉两个空转的 sysroot 预装循环(§5.5) +5. `toolchain remove` 补位置参数 + partial 解析(#284) +6. `resolver.cppm` 加 `prerelease_pin_hint` + `mcpp add` 侧同一告警(#370) +7. `validate.cppm` 加「一个模块接口都没有 ⇒ 不告警」谓词(#374)—— **必须先修 §5.4 的限定名比对** + +### 批次 B · 需要设计的单点 + +8. **scanner 单遍词法状态机**(#373 + §5.2)—— 先写 9 条单测看它们红成什么样,再动 helper +9. `sourcesDeclared` 一位 + 两条自动扫描一起关(#386)—— 顺带收敛 `prepare.cppm:3210` 的漂移 +10. `subos_info::resolve_env` 两段式重写(#382)—— **把 xlings 那四个行号写进注释和测试** +11. `flags.cppm:720-723` 的 `-L` → `-rpath-link`(#304)—— `linkRuntimeDirs` 那段保持不动 +12. `mcpp new` 单一校验器(#380) +13. `e2e/_inherit_toolchain.sh` 改硬链接农场 + `run_all.sh` 加 payload 指纹校验(#293) + +### 批次 C · 需要新字段/新契约 + +14. xlings pin → ≥2026.8.9.1 + fixup 接权威(#392)—— 验证判据是新 subos 的 `runtime`,**不是命令退 0** +15. `[toolchain] default` 加 provenance(#283) +16. `[target.X].sysroot` 字段 + `--sysroot`(#276 的 P0 切入点)—— **fingerprint 必须同时加轴** +17. xpkg 描述符版本条件块(#290)—— 十几行拼接,成本在 lint 闭包和发布流程 +18. wire 阶段 3b/3c(#379 / #371)—— `dryRun` 执行路径已存在,缺的只是从 CLI 到它的一根线 + +### 批次 D · 关闭前置动作 + +19. #313:help 着色单开并落地 → 建议 7 落 Skill 或明确 WONTFIX → 建议 2 dedupe 到 #144 → 再关 +20. #177:补「接入非 mcpp 的 C/C++ 库」文档章 → 以 WONTFIX 关字面需求;#144 改标题重新立题 +21. #256:canary 三处修好 → 上游追踪转成正文链接 → 可关 +22. #379:7 条各开独立 issue → 全部立项后关,或就地降为「wire v1 已冻结」跟踪 issue + +--- + +## 7. 方法论与证据边界 + +**做了什么**:26 个 open issue,除去用户指定保留的 #43 / #260,其余 23 条(#177 与 #144 合并分析)各派一个 agent 对照当前代码逐条核验;对判为「可关闭」的结论各派两个独立反驳者(一个查代码事实、一个站原报告人视角),**不确定时默认推翻**。累计 27 个 agent、1594 次工具调用。 + +**证据规矩**:每条事实主张必须落到当前 file:line 并引用真实代码;issue 引的行号平均漂移两到三个版本,一律重新定位。「已修复」必须能指出具体 commit / 现有代码,并说明「按 issue 的复现步骤现在会发生什么」。多条结论有实机复现(#380 的死循环与路径逃逸、#284 的能力矩阵、#304 的 `-rpath-link` 三方对照、#370 的 local index 复现、#371 的 CDB 消失、#215 的 `-freflection` 探测、#256 的 clang 22.1.8 SIGSEGV、#396 的 `.gnu.version_r` 读取)。 + +**全程只读**:所有临时工程写在 scratchpad,mcpp 仓库 `git status --porcelain` 全程为空。 + +**未做/边界**: +- 部分复现用的是本机已装的 mcpp 2026.8.8.2 而非 main 2026.8.8.4;每处都用 `git log -S` / blame 确认了涉及代码在两版之间未变动,结论对 main 成立。 +- #290 的 `check_llamacpp_snapshot.py` 主张未核(未 checkout `mcpplibs/llama.cpp-m`),标 UNVERIFIABLE。 +- #382 的 GPU 加速路径(mesa 25.0.7.2 的 d3d12/iris)本机无 WSL2 无 Intel GPU,payload 里有、依赖闭包干净但**从未被执行过**——只能由报告者验证。但这不构成保持 open 的理由。 +- 所有 xlings 侧结论基于 `openxlings/xlings@2913a09` 本地检出,未跑 xlings 自身的测试套件。 + +--- + +*本报告由 mcpp 全量 issue 核验流程生成 · 基线 `main@80291ca` / v2026.8.8.4 · 2026-08-09* diff --git a/.agents/docs/2026-08-10-graphics-closure-and-distribution-tiers-design.md b/.agents/docs/2026-08-10-graphics-closure-and-distribution-tiers-design.md new file mode 100644 index 00000000..53719ffa --- /dev/null +++ b/.agents/docs/2026-08-10-graphics-closure-and-distribution-tiers-design.md @@ -0,0 +1,754 @@ +# 图形栈打通:一个标签、一条没人依赖的边、一个被钉住的 pin + +> 日期:2026-08-10 +> 基线:mcpp `main` `3f237ed`(版本 `2026.8.10.1`)、xlings `2026.8.10.4`、 +> mcpp-index `main` `b4e28f2`(xpkg 0.0.57)、xim-pkgindex `main` `df01c872` +> 本机:x86_64-linux-gnu / gcc 16.1.0 / NVIDIA + X11 +> 前置阅读(结论,不必重读过程):`2026-08-10-graphics-stack-usability-design.md` +> (三层故障的分层)、xlings `2026-08-10-graphics-stack-design.md`(标签契约与 E1–E5) +> +> 本文所有"已验"结论都在本机跑过,命令与输出在正文里。凡未实测的一律标 **未验**。 + +--- + +## 0. 结论先行 + +上一份文档把图形栈的不可用分成三层(装 / 建 / 跑)。这一轮把每一层追到**代码或数据的 +那一行**,结果是:**三层里有两层的既有结论是错的**,而按错的结论去修,修完还是坏的。 + +| 层 | 上一份的结论 | 核实后 | 证据 | +|---|---|---|---| +| **L1 装** | xim-pkgindex 数据缺陷:四段版本不可范围比较 | ❌ **已被 xlings `2026.8.9.2` 修好**;真因是 mcpp-index CI 的 `MCPP_VERSION` 钉在 `2026.8.8.2`(内带 xlings `2026.8.8.1`) | §1.2 | +| **L2 建** | #405:缓存命中丢 std 次序 | ✅ 方向对,但 **issue 里写的根因是错的**,按它改修不好 | §1.1 | +| **L3 跑** | provider 有名无物 | ✅ 成立,但**上游还有一层**:mcpp 链出来的可执行文件全是 DT_RUNPATH,拿不到 GPU 与 provider 声不声明无关 | §1.3 | + +一句话: + +> **图形栈拿不到 GPU 的直接原因,是 mcpp 自己链接时用了 DT_RUNPATH。** +> 这一条与索引、与 provider 声明、与 xlings 全都无关 —— 它在 mcpp 的链接命令行里。 + +--- + +## 1. 事实核验:三条被推翻的既有结论 + +### 1.1 #405 的 issue 根因是错的(我自己写的那条) + +**实测复现**(本机,mcpp `2026.8.8.2`,缓存已有 `mcpplibs.cmdline@0.0.2`): + +```console +$ mcpp new b && cd b && mcpp add mcpplibs.cmdline@0.0.2 +$ cat > src/main.cpp <<'EOF' +import mcpplibs.cmdline; # 刻意不 import std +int main() { mcpplibs::cmdline::App app("b"); return 0; } +EOF +$ mcpp build + Cached mcpplibs.cmdline v0.0.2 (3 units) +error: build failed +std: error: failed to read compiled module: No such file or directory +std: note: compiled module file is 'gcm.cache/std.gcm' +mcpplibs.cmdline: error: failed to read compiled module: Bad import dependency +``` + +生成的 `build.ninja` 三行,把根因定死: + +```ninja +65: build gcm.cache/std.gcm : stage_file /std/50c5b8df12f1bf98/gcm.cache/std.gcm +83: build _mcpp_staged_cache : phony <3 个 dep .o> <3 个 dep .gcm> +90: build obj/main.o : cxx_object …/src/main.cpp | obj/main.cpp.ddi.dd || _mcpp_staged_cache +93: build bin/b : cxx_link … obj/main.o obj/std.o +``` + +- **第 65 行:边存在。** issue 评论断言"`needsStdModule=false` ⇒ `plan.stdBmiPath` 为空 + ⇒ 不发 std 的 `stage_file` 边"—— **不成立**。 + `graph_or_targets_import_std`(`src/build/prepare.cppm:651`)看的是 `scan.graph`, + 而 `scan_packages(packages)` 的 `packages` **包含依赖包根** + (`prepare.cppm:3784` / `:4183` 各 `push_back` 一次),依赖单元本来就在图里, + 谓词恒为真。`servedFromCache` 要到 `:6060` 才置位,在谓词之后。 +- **第 83 行:聚合里没有它。** `_mcpp_staged_cache`(`ninja_backend.cppm:1053–1083`) + 只收 `plan.compileUnits` 里 `servedFromCache` 的 `.o` 与 BMI; + std 的两条 `stage_file` 边在 `:956–983` 更早发出,**不在那个循环里**。 +- **第 90 行:没有任何东西依赖 `gcm.cache/std.gcm`。** 它作为 implicit input 只出现在 + `cu.imports` 含 `"std"` 的编译边上(`:1215`/`:1222`)。消费方自己不 import std ⇒ + 零引用 ⇒ **ninja 永不执行第 65 行**。 +- **第 93 行:`obj/std.o` 反而是可达的** —— 它是链接边的输入(`:1295`)。 + 所以坏的只有 BMI 那一半,而且要到编译 `main.o` 时才炸。 + +> **真因:一条正确的边,没有任何人依赖它。** +> issue 里的修法(把 `imports_std` 写进 `entry.json`,hit 时 OR 进 `needsStdModule`) +> 作用于一个**本来就已经为真**的谓词 —— **改完仍然坏**。 +> +> 这是同一个 issue 上第二次根因写错。第一次是"跨版本缓存投毒",被自己的测量否掉; +> 这一次是"谓词漏判",被 `build.ninja` 否掉。**issue 里的根因和代码一样需要证据**, +> 而这一条的证据只要 `grep std.gcm build.ninja`,三秒。 + +### 1.2 L1 不是数据缺陷,是 pin 落后 + +**实测**(本机 xlings `2026.8.10.2`): + +```console +$ xlings info "xim:libglvnd@>=1.7.0.1" --agent + available: 1.7.0.1, 1.7.0 + selected version: 1.7.0.1 ← 解析成功 +``` + +四段版本的范围比较由 xlings **`2026.8.9.2`**(`f203b6b`,"generalize the version grammar +— N segments")修好,原因写在它自己的 commit 里:`fontconfig 2.15.0.1` 撞过同一个洞。 + +而 mcpp-index 的 CI: + +```yaml +# .github/workflows/validate.yml:152 +MCPP_VERSION: "2026.8.8.2" +``` + +日志实证(run `31379222823`,`workspace (linux default 0/4)`): + +``` +MCPP_VENDORED_XLINGS: …/mcpp-2026.8.8.2-linux-x86_64/registry/bin/xlings +error: xlings install_packages failed … 'compat.glx-runtime@2026.08.08' + xlings reported: E_INVALID_INPUT: package 'xim:libglvnd@>=1.7.0.1' not found +``` + +`2026.8.8.2` 内带 xlings `2026.8.8.1` —— **正好落在修复之前**。 + +> **判据:`xim-pkgindex` 一个字都不用改。** 上一份文档提的"数据面止血:改成裸名" +> 是对一个已经修好的缺陷的补丁,做了反而把 mesa/libglvnd 的版本约束一起降级。 +> 需要改的只有 `MCPP_VERSION`,而它要等 mcpp 发一个 pin 了新 xlings 的版本。 + +诊断面那条仍然成立且仍然欠着:`E_INVALID_INPUT: … not found` 在约束不可解析时也这么说, +**这一轮它又把我送去查一个不缺的包**。归属 xlings,不在本设计范围,单独提。 + +### 1.3 mcpp 链出来的可执行文件全是 DT_RUNPATH + +**实测**(本机默认工具链): + +```console +$ gcc a.c -o a.out -Wl,-rpath,/tmp/xyz && readelf -d a.out | grep PATH + 0x…1d (RUNPATH) Library runpath: [/tmp/xyz] ← 默认 +$ gcc a.c -o b.out -Wl,--disable-new-dtags,-rpath,/tmp/xyz && readelf -d b.out | grep PATH + 0x…0f (RPATH) Library rpath: [/tmp/xyz] +``` + +全仓搜索:`--disable-new-dtags` **零处**;唯一相关的一处是 +`src/toolchain/post_install.cppm:321`,它显式写入 **`--enable-new-dtags`**。 + +xlings 那份设计的实测表(同一路径内容、只改标签类型): + +| 可执行文件标签 | egl | gles2 | egl-surfaceless | glx | +|---|---|---|---|---| +| **DT_RPATH** | **NVIDIA** | **NVIDIA** | **NVIDIA** | **NVIDIA** | +| DT_RUNPATH | llvmpipe | llvmpipe | llvmpipe | NVIDIA | + +机制:DT_RUNPATH **只对携带它的那个对象自己的查找生效**;DT_RPATH 对进程内**任意深度的 +`dlopen`** 生效。GL 程序到驱动要经三到四层 `dlopen`(glvnd → vendor → EGL 外部平台模块 → +它的依赖),而这些 `dlopen` **都不是应用发起的**,是 `libGLX.so.0` / `libEGL.so.1` 发起的。 +所以应用二进制的 `dlopen` 引用数是 **0**,"这个程序需不需要传递标签"在二进制上看不出来。 + +`src/build/plan.cppm:913` 的注释写着: + +> Putting the directory in the artifact's RUNPATH instead is: DT_RUNPATH reaches the +> object that carries it **and the dlopen() it performs**, and nothing else. + +**这句话对 glibc 那个场景是对的**(私有 libc 由产物自己 `dlopen`), +**但它被当成通则用了** —— 图形链上 `dlopen` 的发起者是**别的对象**, +"and nothing else" 正是失败本身。 + +顺带,E1c 已经点名过 mcpp:"我原以为 mcpp 那种刻意链宿主的产物会因非 form-X 自动豁免 —— +实测不成立:mcpp 构建出的 xlings 二进制,INTERP 指向 mcpp 自己 store 里的 glibc, +**是 form-X**。" 也就是说 mcpp 的产物**在 rule E 的管辖范围内**,只是 mcpp 没有履约。 + +### 1.3.1 归属:E2 要拆成两半看,不是"归 mcpp"也不是"归 xlings" + +xlings 的 E2b 是**一个 ld 包装器**,一次追加三样东西: + +```sh +exec "$@" -rpath "$XLINGS_SUBOS_LIB" -rpath-link "$XLINGS_SUBOS_LIB" --disable-new-dtags +``` + +**标签**和**路径**是两件不同的事,mcpp 对它们的答案相反。 + +**实测一:mcpp 确实链过 xlings 的 `ld`** —— 所以包装器会作用到 mcpp。 + +```console +$ /bin/g++ -print-prog-name=ld +ld ← 载荷里没有自带 ld,从 PATH 解析 ⇒ 就是 xim binutils 那个 +``` + +**实测二:`/lib` 是一个带 libc 的目录** —— 199 个条目里包含: + +``` +ld-linux-x86-64.so.2 libc.so.6 libm.so.6 libpthread.so.0 crt1.o crti.o crtn.o +``` + +于是: + +| 半边 | mcpp 该怎么办 | 理由 | +|---|---|---| +| **`--disable-new-dtags`(标签)** | **mcpp 自己发** | ① mcpp 有大量**不经过那个 ld** 的链路:交叉 musl / mingw、`-fuse-ld=lld`、`gcc@system` / `msvc@system`、host 工具子构建;② mcpp 要能在自己的 e2e 里断言它;③ 与包装器**同向**,后出现者胜出,不冲突 | +| **`-rpath /lib`(路径)** | **mcpp 必须不继承** | 那是**第二个带 libc 的目录**。mcpp 的 Rule A/B 闭包校验(#396/#400)要求解释器 + 直接/传递 libc 与**同一个** RuntimeBinding 一致 —— 一条未经审查的 libc 路径要么触发它自己的守卫,要么在运行期静默选错 libc。pack 档更是必须把它剥掉 | + +**实测三(意外收获,独立佐证 §3.1)**:这台机器上共享 gcc 载荷的搜索路径里,sysroot 指向的是 +**另一个工程的 subos**: + +``` +libraries: … /home/speak/workspace/github/openxlings/xim-pkgindex-fromsource/.xlings/subos/default/lib/ … +``` + +—— 与本工程毫无关系。这正是 `mcpp-clean-link.specs` 存在的原因("that file has been patched +by every home that ever installed against this shared payload"),也再一次说明 +**共享载荷是共享可变状态**,不能承载契约。 + +> **结论:E2 仍然需要 xlings 做,mcpp 做自己那一份不是替代。** +> 两者覆盖的是**不同人群**,而 mcpp 覆盖不到的那部分恰恰是 xlings 的定位承诺: +> +> - 用户手敲 `gcc -lGL`(**E2 自己的验收判据就是"用户零 flag"**) +> - 从源码构建的 xim recipe(它们不经 mcpp) +> - subos 里的 cmake / meson / xmake / cargo / go 工程 +> +> mcpp 只能保证"mcpp 链出来的产物"。**"用户态 Linux 发行版"的那一半只有 xlings 能给。** + +**因此本设计对 xlings 提两条接口要求**(不在 mcpp 侧实现,但必须在 E2b 落地**之前**谈妥): + +1. **E2b 必须给出一条声明式的退出**,让 mcpp 能只要标签、不要路径。 + E1c 已经立过规矩:**退出必须是声明出来的,不能靠推断**,也不能靠"某个工具重写 spec + 去对抗另一个工具的默认值"。若 E2b 无退出即落地,mcpp 的 pack 产物会被烙进一条 + store 绝对路径,且 mcpp 自己的 libc 闭包守卫会开始对自己的产物报警。 +2. **E2a(`XLINGS_SUBOS_LIB` 契约声明)对 mcpp 有独立价值** —— 有它,mcpp 读声明; + 没有它,mcpp 只能硬编码 `/lib`,那又是一处"同一决策两处推导"。 + +### 1.4 附带发现(不在本设计范围,单独记) + +本机 `xim-x-binutils` 的 `as` / `readelf` shim 指向一个**已删除的旧会话 scratchpad home**: + +``` +[error] xlings: executable 'as' not found +[error] path: /tmp/claude-1000/…/3eae0253-…/accept-run1/home/.mcpp/registry/data/xpkgs/xim-x-binutils/2.42/bin +``` + +与记忆里 #293(e2e 写穿符号链接损坏真实工具链)同族。**是本机环境损坏,不是本轮要修的缺陷**, +但它会让"用 mcpp 工具链验标签"这类验证静默失败,所以本文所有 ELF 验证一律用**原生解析** +或系统 `readelf`,不依赖 xim binutils。 + +--- + +## 2. 架构:一条脊柱,三个档位 + +上一份文档把 dev / pack / static 三档并列。核实之后可以更紧: + +> **三个档位是同一个"运行期能力"模型的三次求值,不是三套机制。** + +``` + ┌──────────────────────────────────────────┐ + │ [runtime] 能力模型(已存在,未被使用) │ + │ RuntimeRequirement → RuntimeArtifact │ + └───────────────┬──────────────────────────┘ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌──────────┐ ┌──────────┐ + │ dev │ │ pack │ │ static │ + │ 吃 store│ │ 自包含 │ │ musl │ + ├─────────┤ ├──────────┤ ├──────────┤ + │需求必须落│ │不可打包者 │ │能力需求 │ + │到物,且 │ │进宿主清单 │ │= plan 期 │ + │身份一致 │ │产物零 store│ │ 硬拒 │ + └─────────┘ └──────────┘ └──────────┘ + └─────────────────────┴─────────────────────┘ + │ + ┌────────▼────────┐ + │ 标签契约(横切) │ + │ 可执行 DT_RPATH │ + │ 库 DT_RUNPATH │ + └─────────────────┘ +``` + +模型**全都已经在代码里**,只是没人填也没人验: + +| 类型 | 位置 | 现状 | +|---|---|---| +| `RuntimeRequirement{kind,value,phase,requester,required}` | `src/manifest/types.cppm:504` | 已定义 | +| `RuntimeArtifact{role,provider,path,provenance,abi,digest,hostFingerprint}` | `src/manifest/types.cppm:512` | 已定义 | +| `subos_info.contract.artifacts` 读取 | `src/xlings/subos_info.cppm:236` | 已实现 | +| `mcpp why runtime` 打印 | `src/doctor.cppm:585` | 已实现,本机输出 `(none declared)` | + +本机 `~/.xlings/subos/default/.xlings.json` 的 `subos_info` 块: + +```json +{"created_by": "xlings 2026.8.5.1", "envs": {}, "runtime": "glibc@2.39", "schema_version": 1} +``` + +—— 没有 `contract`。**所以 dev 档的校验必须是三值的**:`OK` / `MISMATCH` / `NOT_DECLARED`, +把"验过了是对的"和"根本没得验"分开。二值会让 `NOT_DECLARED` 被读成 `pass`, +这正是这一轮反复踩的那个形状。 + +### 2.1 mcpp 自己算,`.wiring` 只做增强 + +xlings `2026.8.10.4` 在 **libglvnd 载荷的 vendor 目录**下写了一份纯文本记录 +(`src/core/subos/graphics.cppm`,`kRecordName = ".wiring"`),逐 vendor 一行: + +``` +dispatch= + state=ok|native|broken|unverified reason=<…> +``` + +**第一版设计把它当作机制,那是错的。** 拆开看,mcpp 要的两件事来源不同: + +| mcpp 要判什么 | 数据从哪来 | 依赖 `.wiring` 吗 | +|---|---|---| +| **物在不在、身份对不对**(声明 0.1.2 却解析进 0.1.1) | **纯路径事实**,mcpp 自己走一遍就有 | ❌ **不依赖** | +| **这个 vendor 到底能不能打开** | xlings 在接线时拿着 `patchelf` 逐入口点探过 | ✅ 只在这里 | + +mcpp 自己那一遍**用已有的 ELF 解析器就能走完**,和 `read_graphics_wiring` 前三步一样: + +``` +libGLX.so.0(dispatch,mcpp 本来就知道它) + → 解析 DT_RPATH → vendor 目录 + → 枚举 vendor SONAME、解符号链接 → 真实载荷路径 + → 与声明的 provider 身份比对 → OK / MISMATCH +``` + +**而这正是 §3.E 唯一的判据**(本机现状:声明 `0.1.2`、解析进 `0.1.1`)。它是纯路径事实, +不需要探测、不需要懂 GL、**不需要任何一方的记录**。 + +`.wiring` 只在"想额外报告 vendor 能不能打开"时才读,而那恰好是条件语义有缺陷的那一半: + +| 消费者标签 | `libEGL_nvidia` | 真实渲染 | +|---|---|---| +| DT_RUNPATH | 打不开 | llvmpipe | +| DT_RPATH | LOADED | NVIDIA | + +`state=broken` **以消费者标签为条件,而格式没有表达这个条件**(xlings#537,低报)。 + +> **所以 mcpp 的读法是:主判据自算,`.wiring` 降级为可选增强,且永不单独定生死。** +> - 缺失 / 读不动 / 不认识的 `state` ⇒ 一律按 `unverified` 读,**绝不读成 pass** +> (未知状态读成通过,正是 xlings 自己在 `parse_wiring_record` 里写下的防线)。 +> - 显示 `state` 时**标注 mcpp 是按自己产物的标签重新求值的**: +> `broken` + `reason=runpath-not-transitive` 对 **DT_RPATH** 的消费方不适用。 +> - 未知 key 一律忽略 ⇒ xlings 按 #537 补上条件字段之后,mcpp **不需要跟着改**。 +> +> **收益:E 不再等 #537,也不再和 xlings 的记录格式耦合。** +> 第一版把别人一份"条件未表达"的记录放在承重位上 —— 那等于把自己的正确性 +> 挂在别人的格式缺陷上。**能自己算的事实,不要去读别人的结论。** + +--- + +### 2.2 构建期与运行期必须分开,而且 mcpp 不自适应硬件 + +两个问题分开答。 + +**分开吗 —— 必须。** 而且判据不是"看起来更整洁",是: + +> **mcpp 的构建期结论,必须在"构建机 ≠ 运行机"时仍然正确。** + +dev 档上两台机器是同一台,分不分看不出来;pack / static / xpkg 上它是决定性的。 +把这条当硬约束,就自动排除掉一整类错误做法: + +| 时期 | mcpp 该产出什么 | 明确不产出 | +|---|---|---| +| **构建期** | **声明事实**:物在不在、身份对不对、标签对不对、什么必须来自宿主 | 任何关于**这台机器的 GPU** 的结论 | +| **运行期** | **什么都不产出** —— 报 `NOT_EXERCISED` 并指向 `xlings doctor` / `gl-doctor` | 不启动被测程序去"试试看" | + +违反它的典型形态就是 autoconf `AC_RUN_IFELSE` 三十年的教训:**拿构建机跑一下, +去决定目标机上的事**。今天的图形产物 RUNPATH 里那三条构建机 store 绝对路径, +是同一个错误的另一种拼写 —— 产物只在构建它的那台机器上成立。 + +**自适应硬件吗 —— 不。而且这不是保守,是这套架构的本意。** + +libglvnd 的派发、EGL vendor JSON、Vulkan ICD JSON —— **这一整套的存在理由就是 +"适配发生在加载期、由加载器、在目标机器上完成"**。在构建工具里再实现一遍, +既是重复,又必然更差:构建工具只能看见构建机。 + +所以 mcpp 的职责是**不要挡住它**,共三条,全在本设计里: + +| mcpp 要做的 | 对应 | +|---|---| +| 标签对 —— 让深层 `dlopen` 能穿透 | §D | +| 路径通 —— dispatch 找得到 vendor,且身份一致 | §E | +| 不打包不该打包的 —— 驱动留给宿主,自带 libc 的档直接拒 | §F / §G | + +> **一句话:DT_RPATH 那一条修的不是"适配",是"不阻断适配"。** +> 这也解释了为什么它一条就能把 egl / gles2 / egl-surfaceless 三格从 llvmpipe 翻成 NVIDIA —— +> 加载器一直有能力找对,是标签把它挡在门外。 + +**唯一合法的"看硬件"**:**测试**的能力轴(有没有 display / 有没有驱动), +用来决定一条 e2e 是 `PASS` / `FAIL` / **`NOT_EXERCISED`**(§I4)。 +**适配测试的运行条件,和适配产物的内容,是两件事** —— 前者必须做,后者绝不做。 + +--- + +## 3. 变更清单 + +九项。每项给:改哪、为什么是那里、判据(**先证伪再采信**)。 + +### A. xlings pin `2026.8.9.2` → `2026.8.10.4` + +- **改**:`src/xlings.cppm:47` `kXlingsVersion`(唯一真源,16 个 pin 点由 + `.github/tools/check_version_pins.sh` 机器校验)。 +- **为什么是 `.4` 不是 `.2`**:`2026.8.10.4` 是 rule E + `--force-rpath` 落地的那一版 + (`cadcf77`)。装机时会校验标签契约,与 §D 同向。 +- **可获得性已验**:`xim-pkgindex` `origin/main` 的 `pkgs/x/xlings.lua` + `["latest"] = { ref = "2026.8.10.4" }`。(本机索引缓存陈旧只到 `.2`,是缓存不是索引。) +- **判据**:bump 后在**干净 home** 里 `xlings info "xim:libglvnd@>=1.7.0.1"` 必须解析到 + `1.7.0.1`;**证伪**:回退 pin 到 `2026.8.8.1`,必须重现 `not found`。 + +> ⚠️ 跟着 pin 走的**不是** `.xlings.json` 的 bootstrap pin —— 那是自举起点, +> 按发布流程在**发布之后**单独收尾。见 `.agents/skills/mcpp-release/SKILL.md`。 + +### B. #405 —— 把 std BMI 放进 staged 聚合 + +- **改**:`src/build/ninja_backend.cppm:1053–1083`。`staged` 非空时,把 + `std_bmi_dst`(以及 `has_std_compat` 时的 `compat_bmi_dst`)一并 `push_back` 进 + `staged`,再发 `_mcpp_staged_cache` phony。 +- **为什么是那里**:那个聚合**就是为"stage 边丢掉了编译边携带的次序"而发明的** + (`:1035–1052` 的 ORDERING 注释写得很清楚,它当时解决的是模块分区)。 + std BMI 是同一类问题的另一个实例,只是它的 stage 边发得更早、不在那个循环里。 + **在别处修等于在同一个决策上再推导一次。** +- **不改**:cache key、`entry.json` schema、`kCacheEpoch`。**现有缓存全部继续有效。** +- **为什么不按 issue 的修法**:见 §1.1,那个谓词已经是真的。 + → **issue #405 的根因段必须重写**,否则下一个人按它改,改完仍然坏。 +- **判据(e2e `212_cached_dep_std_is_ordered.sh`)**: + 清空某个 `import std` 的包的 pkg 缓存 → 建项目 A(miss,必须 OK)→ + 建项目 B(hit,必须也 OK),两个消费方**都刻意不写 `import std`**。 + 再加一条**图断言**:`_mcpp_staged_cache : phony` 那一行必须包含 `std.gcm`。 + - ⚠️ **不得先删产物**。删了会让 ninja 以"图过期"的样子失败,快路径回退到完整 + prepare,**未修的二进制也会绿**(#407 的复现里已经踩过一次)。 + - ⚠️ 行为断言单独不够:一台恰好已有 `std.gcm` 的机器上它会假绿,所以要有图断言。 + +### C. #407 —— build.ninja 自己说明它是哪张图 + +- **改**: + 1. `emit_ninja_string` 在文件头写一行 `# mcpp:graph=`; + 形态由 plan 携带(`includeDevDeps || !extraTargets.empty()` ⇒ `test`)。 + 2. `try_fast_build` / `try_fast_run`(`src/build/execute.cppm`)读这一行, + **只有 `normal` 才允许走快路径**;缺失(旧文件)或不匹配 ⇒ 当作 miss,回退完整 prepare。 +- **为什么不是"`mcpp test` 也调 `forget_build_cache_entry`"**:那是**写入侧修补** —— + 每一个未来的图重写者都必须记得调它。#387 已经为 `--configure-only` 调了一次, + `mcpp test` 是第二次,下一个模式是第三次。**读取侧不变式只需要成立一次**: + 快路径校验它即将重放的那张图。 +- **顺带删掉**:`configure.cppm:101` 的 `forget_build_cache_entry` 调用与 + `execute.cppm:288` 的函数本身 —— 由 header 检查取代,`211_configure_only_cdb.sh` + 的可观察行为不变(它断言的是"下一次 `mcpp build` 必须重新链 target")。 + **保留两套 = 同一决策两处推导**,正是要消掉的东西。 +- **`tests/` 不需要进 `sources_newer_than`**:两张图分开之后,普通构建的图里根本没有 + 测试文件,改坏一个测试不可能让 `mcpp build` 失败。**不加第二个机制。** +- **判据(e2e `213_build_after_test_is_not_the_test_graph.sh`)**: + `mcpp build` → `mcpp test` → `mcpp build`,第三步之后 `bin/` 必须存在且被重链; + 把 `tests/*.cpp` 改成非法 C++ 之后 `mcpp build` 必须**仍然成功**。 + **不删任何产物。** **证伪**:去掉 header 检查,这条必须变红。 + +### D. DT_RPATH 契约 —— xlings E2 的 mcpp 那一半 + +- **规则**(与 xlings 逐字一致): + - **ELF 可执行文件**(`LinkUnit::Binary` / `TestBinary`)→ **DT_RPATH** + - **共享库**(`LinkUnit::SharedLibrary`)→ **保持 DT_RUNPATH** + - Mach-O / PE / musl 全静态 → 不适用 +- **为什么库不能一起翻**:xlings `#593` 已实测 —— 在**库**(interposer)上强制 RPATH + **有害**,传递性会把搜索路径推进那个库往下的每一次查找,`eglInitialize` 直接失败。 + 这是"翻一半"而不是"翻全部"的**实测理由**,不是保守。 +- **怎么改**:按 link unit 的 kind 追加 `-Wl,--disable-new-dtags` —— 与 + `shared_library_link_flags`(`src/build/plan.cppm:391`)同层,**不是** + `flags.cppm:797` 那个 `supports_rpath` 块(那里是整条链接规则共用的,分不出 kind)。 + ⚠️ **必须排在任何 `--enable-new-dtags` 之后**(后出现者胜出)。 + `post_install.cppm:321` 把 `--enable-new-dtags` 写进 clang 的 `.cfg`, + 配置文件参数在命令行参数之前,所以命令行会赢 —— **但这一条必须被验证,不能被推理**: + gcc 与 clang 两条路径都要有断言。 + (xlings 的第一版包装器就是把参数放在前面,标签仍是 RUNPATH,而当时只测了 GLX —— + GLX 本来就通,所以没暴露。) +- **验证 —— mcpp 侧的 rule E**:链接后解析产物 dynamic section,断言标签符合上表。 + - 读法:**原生解析**,`src/platform/elf_runtime.cppm` 已经在读 + `DT_RPATH`/`DT_RUNPATH`(`:395`/`:400`),**不 shell out**(本机 binutils shim 已损坏,§1.4)。 + - 必须**读完整个 dynamic section**:两个标签同时存在时加载器忽略 DT_RPATH, + 而 DT_RPATH-first 是常见布局 —— 只读第一个命中会读反。 + - 节奏:**warn-first**,与 closure_check rule A/D 的既定推进方式一致。 +- **判据(e2e `214_executable_carries_dt_rpath.sh`)**: + gcc 与 clang 各建一个 bin + 一个 shared lib;bin 必须 `(RPATH)`,lib 必须 `(RUNPATH)`。 + **证伪**:去掉那个 flag,bin 那条必须变红 —— 而且**测试自己要先断言默认值是 RUNPATH**, + 否则将来链接器默认一变它就静默空转(xlings 的钻机踩过这个)。 + +### E. dev 档 —— 需求必须落到物,且物的身份一致 + +- **改**: + 1. **原生解析求解**(§2.1):dispatch 的 DT_RPATH → vendor 目录 → 解符号链接 → + 真实载荷路径,与声明的 provider 身份比对。**不读任何一方的记录。** + 2. `mcpp why runtime` 的 `artifacts:` 段:把 `(none declared)` 分成 + **`(not declared by the environment)`** 与 **`(declared, N artifacts)`** 两种, + 并对每个 artifact 校验上面那条身份一致性。 + 3. 结论**三值**:`OK` / `MISMATCH` / `NOT_DECLARED`。 + 4. `.wiring` 存在时**附加**显示每个 vendor 的 `state`,标注按本产物标签重新求值; + 缺失 / 未知 state ⇒ `unverified`,未知 key ⇒ 忽略。**它永不单独定生死。** +- **为什么这条不需要懂 GL**:本机现状是 `xlings install graphics` 声明 rebind 到 + `nvidia-gl-host-link@0.1.2`,而 `subos/default/lib/libGLX_nvidia.so.0` 仍解析进 + **0.1.1** 的载荷。"声明 0.1.2,解析到 0.1.1" 是**纯路径事实**。 +- **一致性而非新机制**:mcpp **已经对 glibc 严格执行这条** —— `glibc@2.44` 只解析那一个 + 载荷,陈旧即错误,绝不从多个已装版本里挑"看起来能用的第一个"(#400)。 + 套到 runtime artifact 上是把同一条规则用第二次。 +- **判据**:本机当前状态必须报 `MISMATCH` 并指出 vendor 绑定陈旧; + **不得报 `pass`,也不得报 `NOT_DECLARED`**(`.wiring` 是有的)。 + 在一个没有图形栈的 home 上必须报 `NOT_DECLARED`。 + +### F. pack 档 —— 四个模式已经有了,问题是没有一个对图形应用成立 + +`mcpp pack` **已经是四档**(`src/pack/pack.cppm:30`),不缺模式: + +| `--mode` | 内部名 | libc / 普通依赖 | 目标机器要有什么 | +|---|---|---|---| +| `system` | `None` | 全部来自宿主 | 一个足够新的发行版 | +| `vendored`(默认) | `BundleProject` | 第三方 `.so` 随产物,libc 来自宿主 | 一个足够新的 libc | +| `self-contained` | `BundleAll` | **连 libc / ld.so 一起自带** | 几乎什么都不要 | +| `static` | `Static` | musl 全静态,无 PT_INTERP | 什么都不要 | + +**把"图形应用"这一列填上,四个格子里有两个是结构性矛盾:** + +| `--mode` | 普通应用 | **图形应用** | +|---|---|---| +| `system` | ✅ | ✅ 驱动来自宿主,本来就该这样 | +| `vendored` | ✅ | ✅ **这是图形应用的正确默认档** | +| `self-contained` | ✅ | ❌ **结构性矛盾**(见下) | +| `static` | ✅ | ❌ **结构性矛盾**(§G) | + +> **`self-contained` + 图形 = #392 的镜像。** +> `BundleAll` 让 PT_INTERP 指向**自带的 ld.so**、进程里跑**自带的 libc**。 +> 而宿主的 vendor(`libGLX_nvidia.so.0` 等)是**必须来自宿主**的 —— 它与内核模块锁步、 +> 且 EULA 禁止再分发。当 `libGLX.so.0` `dlopen` 它时,那个宿主 `.so` 的 DT_NEEDED +> (`libc.so.6` / `libm` / `libdl`)会解析到**自带的那份**。 +> 私有 libc 与宿主二进制相遇即死 —— #401/#392 已实测过这一族崩溃,方向相反、机制相同。 +> +> 所以 `self-contained` 对图形应用的实际含义是:**"自包含,除了决定你能不能看见东西的那一部分"**。 +> 今天它链得过去,然后静默走软件渲染或直接死。**这一档必须在 pack 期被诊断,不能静默产出。** + +在这个基础上,现状**已经有一半**:`src/pack/pack.cppm:653` 对**主二进制**设了 +`$ORIGIN/../lib`。缺四件: + +| # | 缺什么 | 后果 | 改哪 | +|---|---|---|---| +| **F1** | `patchelf --set-rpath` **默认写 DT_RUNPATH** | 打出来的 GL 程序拿不到 GPU —— 与 §D 同一个缺陷,在分发侧再犯一次 | `pack.cppm:350` 加 `--force-rpath`(仅可执行文件) | +| **F2** | **只有主二进制被重写**;bundle 进来的 `.so` 保留构建机 RPATH | 实测图形产物 RUNPATH 里三条 `/…` 绝对路径,换机器即死 | 对 `toBundle` 里每个 ELF 同样重写为 `$ORIGIN` | +| **F3** | 闭包按 **DT_NEEDED** 求 | vendor / EGL vendor JSON / Vulkan ICD 全是 `dlopen` 来的,**`ldd` 永远看不见** | 见下 | +| **F4** | 没有宿主要求清单 | 用户拿到一个"缺了什么、缺在哪"说不出来的包 | 产出 `HOST-REQUIREMENTS`(见下) | + +**F3/F4 的设计要点 —— 驱动不能打包,也不该打包。** +NVIDIA 用户态与内核模块锁步且 EULA 禁止再分发;宿主 Mesa 同理绑宿主内核/DRM。 +所以 pack 的正确行为**不是**把它们抓进来,而是: + +``` +bundle: dispatch(libglvnd)+ DT_NEEDED 闭包 - 宿主耦合者 +declare: 宿主必须提供的能力清单 → /HOST-REQUIREMENTS +``` + +清单每行 = 一条 `RuntimeRequirement`,加上"加载器靠什么找到它"。四个入口点是**独立的加载链根**, +发现方式各不相同,所以清单必须逐入口点写,不能合并成一句"需要 OpenGL": + +``` +# HOST-REQUIREMENTS — 这些必须由宿主提供,不能打包(内核锁步 / 许可) +capability=opengl.glx.driver discovery=rpath-of-dispatch soname=libGLX_.so.0 +capability=opengl.egl.driver discovery=json-dir env=__EGL_VENDOR_LIBRARY_DIRS +capability=opengl.gles.driver discovery=glvnd-dispatch soname=libGLESv2.so.2 +capability=vulkan.icd discovery=json-dir env=VK_ICD_FILENAMES +``` + +`discovery` 那一列不是装饰:EGL 的 JSON 里 `library_path` 是**绝对路径**, +所以"把目录搬过去"对 EGL 无效而对 GLX 有效 —— 用户拿到清单才知道该怎么补。 + +- **判据(e2e `215_pack_has_no_build_machine_paths.sh`)**: + `mcpp pack` 之后,遍历 bundle 里**每一个** ELF,原生解析 RPATH/RUNPATH, + **不得出现任何以 xlings store 前缀开头的路径**;可执行文件必须 `(RPATH)`; + bundle 的 `.so` 必须 `(RUNPATH)`;`HOST-REQUIREMENTS` 必须存在且非空(当有能力需求时)。 + **证伪**:去掉 F2 的重写,这条必须变红。 +- **未验**:packed 图形程序在一台**没有 xlings** 的机器上真的能跑 —— 需要第二台机器, + 归 §6 风险。判据只能验到"产物里没有构建机路径",这一条**必须在文档里说出来**, + 不能让"e2e 绿了"被读成"分发验证过了"。 + +### G. 自带 libc 的档 —— 遇到能力型需求必须拒绝 + +原先这一条只写 `static`。核完 §F 之后规则要放大一格,因为 `self-contained` 与 `static` +坏在**同一件事**上: + +> **一旦产物自带 libc,它就不能再消费一条"必须由宿主在运行期满足"的需求。** + +- **规则**:当产物自带 libc(`--mode static` 或 `--mode self-contained`,以及 + `--target *-musl` 静态链接)且解析后的 plan 里存在 `phase == "run"` 的能力型 + `RuntimeRequirement` 时,**在 plan / pack 期失败并说明理由**。 +- **为什么用这个判据而不是"驱动名单"**:名单要维护、会漏、且"是不是驱动"是个含糊问题。 + "**这条需求要由宿主在运行期满足**"是**已声明的数据**,不是猜测 —— 也不需要 mcpp 认识 GL。 +- **为什么两个模式一条规则**:`static` 是"没有 libc",`self-contained` 是"自己的 libc", + 对**宿主提供的 `.so`** 而言后果相同 —— 它带着自己对宿主 libc 的要求进来,而进程里没有那份。 +- **今天的行为**:两档都链得过去,运行时崩或静默降级 —— 最坏的一种失败。 +- **降级出路**:错误信息里给出可行档 + (`--mode vendored` 保留第三方 `.so` 自带、libc 与驱动都来自宿主)。 + **拒绝必须给出下一步,否则用户只会去关掉这个检查。** +- **判据(e2e `216_selfcontained_refuses_host_capability.sh`)**: + `--mode static` 与 `--mode self-contained` 各拉一个带 `[runtime] capabilities` 的依赖, + 两条都必须红,且错误里要说**是哪一条能力、为什么自带 libc 与它不相容、改用哪一档**。 + **证伪**:换成 `--mode vendored`,两条都必须绿。 + +### J. xlings 包格式:要,但它不是第五个 pack 模式 + +**问题**:普通应用有四档就够了(§F);**图形应用连 `self-contained` 都不能真正自包含** —— +驱动那一格永远只能来自宿主。tarball 能做到的极限是**描述**这个要求(F4 的 `HOST-REQUIREMENTS`), +做不到的是**满足**它:目标机器上 libglvnd、vendor 桥、EGL vendor JSON 目录该怎么摆, +tarball 说不了也管不了。 + +**能满足它的只有一种东西:一个能声明依赖、由目标机器解析的包。** 那就是 xpkg。 + +**而这几乎不是新工作 —— 三样东西都已经在**: + +| 已有 | 位置 | +|---|---| +| xpkg 产出路径(`mcpp publish` / `mcpp emit xpkg`) | `src/publish/pipeline.cppm` | +| xpkg **已能表达应用**(`kind = bin`) | `src/manifest/xpkg.cppm:1438` | +| xpkg **已能表达 `[runtime].requirements` / `.artifacts`** | `src/manifest/xpkg.cppm:1818` / `:1892` | + +> **F4 的产出就是 xpkg 描述符的输入。** `HOST-REQUIREMENTS` 里的每一行 +> (`capability=` / `discovery=` / `soname=`)本来就是一条 `RuntimeRequirement`。 +> 做完 F4,xpkg-app 只剩把同一份数据投影到描述符的 `[runtime].requirements`, +> 外加一条 `deps = { "xim:graphics" }` 之类的声明 —— **由目标机器上的 xlings 去装配那条链**。 + +**为什么不做成 `mcpp pack --mode xpkg`**:`pack` 的四档全都产出**自足的 tarball** +(拿到就能解压运行,程度不同而已)。xpkg 不是"更自包含",它是**把解析推迟到目标机器**, +前提是那台机器**装了 xlings**。把它塞进 `pack` 会让 `--mode` 这一维同时表达两件事, +而用户看到的是同一个词。**它属于 `publish` —— 那一维本来就是"交给索引去解析"。** + +| 分发目标 | 命令 | 目标机器要有 | 图形应用可用? | +|---|---|---|---| +| 通用 host | `mcpp pack --mode system` / `vendored` | 足够新的发行版 / libc | ✅ | +| 自包含 | `mcpp pack --mode self-contained` / `static` | 几乎什么都不要 | ❌ §F/§G 硬拒 | +| **xlings 生态** | **`mcpp publish`(应用 role)** | **装了 xlings** | ✅ **唯一能自己装配驱动链的** | + +**本轮范围**:做 F3/F4(数据),**并把 xpkg-app 的 role 打通到能产出描述符**; +不做索引侧的应用分发策略(命名空间、`xlings install ` 的 shim 归属)—— +那牵到"裸名 shim 归属"这条已经弄坏过用户环境的旧账,单独设计。 + +- **判据**:同一个图形工程,`mcpp publish` 产出的描述符里 + `[runtime].requirements` 必须**逐条等于** `mcpp pack` 产出的 `HOST-REQUIREMENTS`。 + **两处不能各推导一次** —— 它们是同一份数据的两种投影。 + **证伪**:改掉其中一处,一致性检查必须变红。 + +### H. #392 收口 + +`2026.8.10.1`(PR #400)已落地三条:私有 glibc 按精确身份解析、Rule A/B 链接期闭包校验、 +`mcpp run` 不再把私有 glibc 泄漏给子进程。issue 报告的"fixup 按字典序扫描"因此消失。 + +剩下的物理事实(私有 glibc 与宿主 loader 相遇即死)归 openxlings/xlings#525。 + +- **本轮动作**:发布后在报告者的原始场景上复核,由**报告者**确认后再关。 + **不替他宣布修好。** + +### I. mcpp-index 侧 + +| # | 动作 | 说明 | +|---|---|---| +| **I1** | `MCPP_VERSION` → 新版本 | **这一条单独就会让 8 个图形成员由 FAIL 转 ok**,以及让 `graphics install: no side effects` 那个 job 转绿(它红在同一句) | +| **I2** | `min_mcpp` / `latest_mcpp` 按 `validate.yml:62–87` 已写明的 lock-step 规则同步 | ⚠️ 索引是数据、mcpp 是程序:**发布数据不得让已发布的程序失效**(记忆:index floor 把旧客户端变砖) | +| **I3** | cron 全量跑红了要**被看见** | 现在 cron 已跑全量但**红了不拦任何东西**;至少要自动开 issue 或发通知。否则全量跑和不跑没有区别 | +| **I4** | 一条真的创建 GL context 的 e2e,**三值** | `PASS` / `FAIL` / `NOT_EXERCISED`(无 display 或无驱动)。没有三值,它只能在"跳过"和"失败"之间二选一,而**跳过会被读成没问题** | + +- **判据(I1)**:bump 后的全量 `validate` 里,`gui-stack` / `imgui-window` / + `eui-neo` ×5 八个成员全部 ok。**证伪**:把 `MCPP_VERSION` 退回 `2026.8.8.2`,必须重现 8 红。 +- **⚠️ 顺序**:I1 必须等 mcpp 发布**并进入索引**之后。判据是**索引 main 的 latest 指向它**, + 不是"release 页面有了"。 + +--- + +### 版本号 + +一档全发 ⇒ 一个版本承载 A–I。按 `YYYY.M.D.N`(月日不补零、`.0` 保留给正式版): +当日落地则 **`2026.8.10.2`**,跨日则顺延为当日的 `.1`。 +`.xlings.json` 的 bootstrap pin **不跟着这次改** —— 它在发布并进入索引之后单独收尾。 + +--- + +## 4. 顺序与依赖 + +``` +A(pin)───┬────────────────────────────────────→ 发布 ──→ I1 ──→ I2/I3/I4 +B(#405)──┤ ↑ +C(#407)──┤ │ +D(标签)──┤ │ +E(dev)───┤ │ +G(拒绝)──┤ │ +F(pack)──┴──→ J(xpkg-app,投影 F4 的同一份数据)───┘ + +H(#392 复核) 发布之后,由报告者确认 +``` + +- **A / B / C / D / E / F / G 七项两两独立**,可并行实现,一次发布。 +- **E 不再依赖 D**(第一版依赖):主判据改成 mcpp 原生解析路径身份(§2.1), + 只有可选的 `.wiring` 标注用到本产物的标签,而那一段不承重。 +- **J 依赖 F4**:xpkg 描述符的 `[runtime].requirements` 与 `HOST-REQUIREMENTS` + 是同一份数据的两种投影,**必须一处推导**。 +- **I1 依赖发布**;I2–I4 依赖 I1。 +- **B 与 C 都碰 `build.ninja` 的生成/消费**,但改的是不同段落,无冲突; + 各自的 e2e 互相不遮蔽(B 断言 phony 内容,C 断言 header 与图形态)。 +- **D 与 F1 是同一条规则的两次施工**(链接期 / patchelf 期), + 实现时共用一个"这是可执行文件吗"的谓词,**不要各写一个**。 + +--- + +## 5. 判据总表(每条都必须先证伪) + +| 项 | 判据 | 证伪方式 | +|---|---|---| +| A | 干净 home 下 `xim:libglvnd@>=1.7.0.1` 解析到 `1.7.0.1` | pin 退回 `2026.8.8.1` → `not found` | +| B | `212`:A(miss)OK 且 B(hit)OK;`_mcpp_staged_cache` 行含 `std.gcm` | 撤掉 push_back → 变红。**测试不得先删产物** | +| C | `213`:`build→test→build` 后 target 被重链;坏测试文件不影响 `mcpp build` | 去掉 header 检查 → 变红 | +| D | `214`:gcc/clang 两路,bin `(RPATH)`、lib `(RUNPATH)` | 去掉 flag → 变红;**测试先断言默认是 RUNPATH** | +| E | 本机报 `MISMATCH` 并指出 vendor 绑定陈旧;无图形栈的 home 报 `NOT_DECLARED`;**删掉 `.wiring` 后主判据不变** | 伪造一致的绑定 → 转 `OK` | +| F | `215`:bundle 内**每个** ELF 的 RPATH/RUNPATH 无 store 前缀;清单非空 | 去掉 F2 → 变红 | +| G | `216`:`static` 与 `self-contained` 两档 + 能力需求 → 都红且给出 `vendored` 出路 | 换 `--mode vendored` → 两条都绿 | +| J | `mcpp publish` 的 `[runtime].requirements` 逐条等于 `HOST-REQUIREMENTS` | 改掉其中一处 → 一致性检查变红 | +| I1 | 全量 validate 里 8 个图形成员 ok | `MCPP_VERSION` 退回 → 8 红 | +| I4 | 有 display 的机器 `PASS`;无 display `NOT_EXERCISED`(**不是** skip) | 拔掉 display → 必须是 `NOT_EXERCISED` 而不是绿 | + +--- + +## 6. 风险 + +| 风险 | 实测/评估 | 处置 | +|---|---|---| +| **D 的爆炸半径**:DT_RPATH 优先级高于 `LD_LIBRARY_PATH`,用户不能再覆盖 | mcpp #401 **已经**把 `LD_LIBRARY_PATH` 注入删掉了,subos 里也根本不设它 ⇒ **今天不破坏任何现存行为**;代价在未来的覆盖能力 | 接受。真需要时按 xlings E1c 的形状加**显式声明的退出**,不靠推断 | +| **D 只在一台机器上验过**(NVIDIA/X11/x86_64) | xlings 那份设计的 E5 同样欠着 | rule E **warn-first**,不转硬门禁;跨硬件覆盖单列 | +| **F 的"能跑"没验** | e2e 只能验"产物里没有构建机路径" | 文档里明说;真正的验收需要第二台无 xlings 的机器 | +| **A 的 pin 跨了 5 个 xlings 版本**(`.9.2`→`.10.4`) | 其中 `.10.4` 会在装机时跑 rule E,可能对**旧 elfpatch 打过标签的已装载荷**发 warn | 那正是 rule E 该说话的场景;warn 不拦安装 | +| **C 删掉 `forget_build_cache_entry`** | `211` 已覆盖可观察行为 | 先加 header 检查并让 `211` 绿,再删函数;两步不要合成一步 | +| **xlings E2b 若无退出即落地** | 已实测 `/lib` 带 `libc.so.6` + `ld-linux`;mcpp 确实链过那个 `ld` | §1.3.1 的两条接口要求必须在 E2b 之前谈妥。**这是本设计唯一一条跨仓阻塞项** | + +--- + +## 7. 明确不做 + +- **不在 mcpp 里探测 GPU / Mesa / NVIDIA / WSL / ICD / driver。** mcpp 算路径事实,不做发现。 +- **不让产物自适应硬件**(§2.2)。适配是加载器在目标机器上的事; + 构建工具只看得见构建机,再实现一遍必然更差。mcpp 的职责是**不阻断**适配。 + 唯一合法的"看硬件"是**测试的运行条件**(有无 display),不是产物的内容。 +- **不把 `.wiring` 放在承重位上**(§2.1)。能自己算的事实,不去读别人的结论 —— + 尤其当那份结论的条件语义还没被表达出来时。 +- **不在 `mcpp pack` 里加第五个 `--mode`**(§J)。xpkg 不是"更自包含", + 是"把解析推迟到目标机器",它属于 `publish`。 +- **不在构建过程中执行 provider 提供的探针。** dev 档的收益已被"物 + 身份"覆盖; + pack/static 档探针问的是**另一台机器**(autoconf `AC_RUN_IFELSE` 三十年的教训); + 且在构建期执行第三方可执行文件是一大块不必要的安全面。 +- **不改 `xim-pkgindex` 的 libglvnd 约束。** §1.2:那个洞已经修好了。 +- **不把 `/lib` 放进任何环境变量或全局搜索路径。** #401 已实测: + 那个目录同时装着 vendor 库**和私有 glibc**,任何"按目录可达"的修法都会让宿主二进制 + 立刻死于 `__pointer_chk_guard`。**vendor 只能按对象可达(RPATH)。** +- **不把 `tests/` 加进 `sources_newer_than`。** §C:两张图分开之后不需要第二个机制。 +- **不动 `compat.glx-runtime` 的 dispatch RPATH** —— 它已经是对的。 +- **不替 #392 的报告者宣布修好。** + +--- + +## 附:这一轮的三条方法论 + +1. **`grep` 一次生成物,胜过读三遍源码。** + #405 的根因错了两次,而 `grep std.gcm build.ninja` 三秒就定死了它。 + **生成物是判据,源码是假设。** + +2. **"我这一片是绿的"从来不等于"它能用"** —— 这一轮的第五、第六个实例: + - index `main` 绿 ≠ 图形包装得上(`workspace` job 是 `skipped`) + - **8 个成员全红了两周,而没有任何一方的 CI 认为自己红了** + - 缓存第一次通过 ≠ 第二次通过(#405,miss 与 hit 是两条路径) + +3. **测量工具与被测对象共享同一个错误假设时,它们会一致地错。** + 对账工具没抓到标签缺陷,因为它自己的探针也是默认 dtags 编的 —— + 它复现的正是记录所描述的那个失败。 + 本文所有 ELF 判据因此都要求:**测试先断言自己的形态** + (`214` 先断言默认是 RUNPATH,否则将来链接器默认一变它就静默空转)。 diff --git a/.agents/docs/2026-08-10-graphics-closure-implementation-plan.md b/.agents/docs/2026-08-10-graphics-closure-implementation-plan.md new file mode 100644 index 00000000..902cea34 --- /dev/null +++ b/.agents/docs/2026-08-10-graphics-closure-implementation-plan.md @@ -0,0 +1,189 @@ +# 实施计划:图形栈闭合与分发档位 + +> 设计:`2026-08-10-graphics-closure-and-distribution-tiers-design.md` +> 分支:`feat/graphics-closure-and-distribution-tiers` +> 单 PR、一档全发。基线 `main` `3f237ed`(`2026.8.10.1`)。 + +--- + +## 0. 模块划分 + +三条**协议**各自独占一个 `.cppm`,因为它们每一条都有**两个以上的读写方**, +而"同一决策两处推导"是这个仓库反复付过学费的形状。 + +| 新模块 | 协议内容 | 谁写 | 谁读 | +|---|---|---|---| +| `src/build/graph_shape.cppm`
`mcpp.build.graph_shape` | `build.ninja` 首行 `# mcpp:graph=` | `ninja_backend` | `execute`(两条快路径) | +| `src/build/loader_contract.cppm`
`mcpp.build.loader_contract` | 加载器标签契约:哪种产物要哪个标签、flag 怎么拼、判决怎么表达 | `plan`(链接期)、`pack`(patchelf 期) | `runtime_validation`(rule E) | +| `src/pack/host_requirements.cppm`
`mcpp.pack.host_requirements` | `HOST-REQUIREMENTS` 文本格式 + 到 xpkg `[runtime].requirements` 的投影 | `pack` | `publish`(J)、一致性检查 | + +平台特化一律留在 `src/platform/`,不外溢: + +| 平台模块 | 本轮改动 | +|---|---| +| `src/platform/elf_runtime.cppm` | 新增 `SearchPathTag`(`None`/`Rpath`/`Runpath`/`Both`)与 `searchPathTag` 字段。**今天两个标签被合并进 `runpaths` 就丢了**,rule E 要的正是被丢掉的那一位 | +| `src/platform/runtime_env_contract.cppm` | 补一条:DT_RUNPATH 只对**携带它的对象自己发起**的 `dlopen` 生效;第三方对象代为 `dlopen` 时不生效 | + +`loader_contract` **不认识 ELF** —— 它表达"可执行文件要 RPATH、库要 RUNPATH"这条策略, +读标签的动作委托给 `platform::elf`。这样 macOS/Windows 只是"契约不适用",而不是散落 `#ifdef`。 + +**判断"是不是可执行文件"统一用 `PT_INTERP` 是否存在**(`facts.interp` 非空), +不用 `ET_EXEC` —— PIE 可执行文件是 `ET_DYN`,和共享库同型。xlings 的 `_has_pt_interp` 同判据。 + +--- + +## 1. 步骤 + +每步给:改哪、测什么、**怎么先证伪**。**每一步的测试必须先看到红。** + +### 步 1 — A:pin + 版本(无行为变更,先落地,让后面每一步都在新 pin 上验) + +- `src/xlings.cppm:47` `kXlingsVersion` → `"2026.8.10.4"` +- `src/version.cppm:34` `MCPP_VERSION` → 下一个补丁号 +- `mcpp.toml` 的 `version` 同步(第一组两个文件,同一个 commit) +- **不动** `.xlings.json` 的 bootstrap pin +- 验:`.github/tools/check_version_pins.sh` 绿 + +### 步 2 — 平台层:把标签这一位保住 + +`src/platform/elf_runtime.cppm`: + +```cpp +enum class SearchPathTag { None, Rpath, Runpath, Both }; +// ElfRuntimeFacts 新增: +SearchPathTag searchPathTag = SearchPathTag::None; +``` + +在已有的 `kDtRpath` / `kDtRunpath` 分支里各置一位;`runpaths` 的既有语义 +(两者都在时以 RUNPATH 为准)**不变** —— 那是加载器物理,现有调用方依赖它。 + +- 单测 `tests/unit/test_elf_runtime.cpp`:三个 fixture(仅 RPATH / 仅 RUNPATH / 两者) + 断言 `searchPathTag` 与 `runpaths` 各自正确。 +- **证伪**:两者都在时若只读第一个命中,`Both` 会退化成 `Rpath` —— fixture 必须能抓到。 + +### 步 3 — B:#405 + +`src/build/ninja_backend.cppm:1053–1083`,`staged` 非空且 `has_std_artifacts` 时, +把 `std_bmi_dst`(以及 `has_std_compat` 时的 `compat_bmi_dst`)一并放进聚合。 + +- e2e `tests/e2e/212_cached_dep_std_is_ordered.sh` + - 依赖选 `mcpplibs.cmdline@0.0.2`(其模块 `import std`),消费方**刻意不写 `import std`** + - 清该包 pkg 缓存 → 项目 A(miss)必须 OK → 项目 B(hit)必须也 OK + - **图断言**:`_mcpp_staged_cache : phony` 那一行必须含 `std.gcm` + - ⚠️ **全程不删任何产物** +- **证伪**:撤掉 push_back,B 必须红 + +### 步 4 — C:#407 + +1. `mcpp.build.graph_shape`:`enum class GraphShape { Normal, WithTests }`、 + `header_line(shape)`、`read_shape(ninjaPath)`。**读写同一处拼写。** +2. `BuildPlan` 加 `graphShape`;`prepare_build` 按 `includeDevDeps || !extraTargets.empty()` 置位 +3. `emit_ninja_string` 首行写 header +4. `try_fast_build` / `try_fast_run`:`read_shape() != Normal` ⇒ 当 miss +5. 删 `configure.cppm:101` 的调用与 `execute.cppm:288` 的 `forget_build_cache_entry` + +- e2e `213_build_after_test_is_not_the_test_graph.sh`: + `build → test → build`,第三次之后 `bin/` 必须存在且是**这次**产的; + 把 `tests/*.cpp` 写坏后 `mcpp build` 必须仍绿。**不删产物。** +- 既有 `211_configure_only_cdb.sh` 必须保持绿(它断言同一条可观察行为) +- **顺序**:先加 header 检查 → 跑 211 绿 → 再删 `forget_build_cache_entry`。两步不合并。 +- **证伪**:去掉 header 检查,213 必须红 + +### 步 5 — D:DT_RPATH 契约 + +`mcpp.build.loader_contract`: + +```cpp +enum class ArtifactForm { Executable, SharedLibrary, StaticArchive, NotElf }; +enum class RequiredTag { Rpath, Runpath, NotApplicable }; +RequiredTag required_tag(ArtifactForm); // 策略,平台无关 +std::optional link_flag(RequiredTag); // "-Wl,--disable-new-dtags" +struct TagVerdict { enum class Status { Ok, Violation, NotApplicable }; … }; +TagVerdict check(const mcpp::platform::elf::ElfRuntimeFacts&, ArtifactForm); +``` + +- **链接期**:`plan.cppm` 按 link unit 的 kind 追加 `link_flag(...)`, + 与 `shared_library_link_flags`(`:391`)同层;**必须排在所有 `--enable-new-dtags` 之后** +- **校验期**:`runtime_validation.cppm` 加 rule E,**warn-first** +- e2e `214_executable_carries_dt_rpath.sh`: + - gcc 与 clang 各建 bin + shared lib + - **测试自身先断言默认值是 RUNPATH**(不带 flag 编一个),否则将来链接器默认一变它静默空转 + - bin 必须 `Rpath`,lib 必须 `Runpath` +- **证伪**:去掉 flag,bin 那条必须红 + +### 步 6 — E:dev 档身份一致 + +`src/build/runtime_validation.cppm`(或近邻)实现原生求解: + +``` +dispatch(libGLX.so.0)→ DT_RPATH → vendor 目录 → 解符号链接 → 真实载荷路径 + → 与声明 provider 身份比对 +``` + +- 三值 `OK` / `MISMATCH` / `NOT_DECLARED`,由 `mcpp why runtime` 呈现 +- `.wiring` **只做增强**:存在则附加显示 `state`,并标注按本产物标签重新求值; + 缺失 / 未知 `state` ⇒ `unverified`;未知 key ⇒ 忽略 +- 单测:构造 fixture 目录树(符号链接指向 `0.1.1`、声明 `0.1.2`)⇒ 必须 `MISMATCH` +- **证伪**:把符号链接改成指向 `0.1.2` ⇒ 必须 `OK`;删掉 `.wiring` ⇒ **主判据不变** + +### 步 7 — F:pack 档 + +- **F1** `pack.cppm:350` `set_runpath`:可执行文件加 `--force-rpath`(走 `loader_contract`) +- **F2** 对 `toBundle` 里每个 ELF 同样重写为 `$ORIGIN`(库保持 RUNPATH) +- **F3/F4** `mcpp.pack.host_requirements`:从 plan 的 `RuntimeRequirement` 生成清单 +- e2e `215_pack_has_no_build_machine_paths.sh`: + 遍历 bundle 内**每个** ELF,原生解析,**不得出现 xlings store 前缀**; + 可执行 `Rpath`、库 `Runpath`;有能力需求时 `HOST-REQUIREMENTS` 非空 +- **证伪**:去掉 F2,必须红 + +### 步 8 — G:自带 libc 的档拒绝能力型需求 + +- `pack`/`plan` 期:`static` 与 `self-contained` + 存在 `phase=="run"` 的能力型 + `RuntimeRequirement` ⇒ 失败,错误里说**哪一条能力 / 为什么不相容 / 改用 `vendored`** +- e2e `216_selfcontained_refuses_host_capability.sh`:两档都必须红 +- **证伪**:换 `--mode vendored` ⇒ 两条都绿 + +### 步 9 — J:xpkg-app 投影 + +- `publish` 的 `[runtime].requirements` 由 `host_requirements` **同一个函数**产出 +- 一致性检查(单测):同一 plan 下两种投影逐条相等 +- **证伪**:改掉一处 ⇒ 检查红 + +### 步 10 — 文档 + +- `docs/` 里 pack / publish / runtime 三处随改动更新 +- 设计文档标记实施状态 +- `CHANGELOG.md` + +### 步 11 — 本地验证 + +`mcpp build` → `mcpp test`(unit)→ e2e 全量。记录真实输出,不概括。 + +### 步 12–15 — PR / CI / 合入 / 发版 / index / 生态验收 + +按 `.agents/skills/mcpp-release/SKILL.md`。 + +--- + +## 2. 顺序 + +``` +步1(pin/版本) + └→ 步2(平台层标签位)──→ 步5(D)──→ 步7(F1/F2) + └→ 步3(B #405) └→ 步8(G) + └→ 步4(C #407) └→ 步9(J) + 步6(E)独立(只需步2) +``` + +步 3 / 步 4 与其余互不相干,可先落地先验证。 + +--- + +## 3. 风险与守则 + +- **每个 e2e 先红后绿**,红的输出要贴进 PR 描述。 +- **`212` / `213` 全程不删产物** —— 删了未修的二进制也会绿。 +- **`214` 先断言默认标签是 RUNPATH** —— 否则将来默认一变它静默空转。 +- **步 4 的两小步不合并** —— 先加检查、211 绿,再删旧机制。 +- **rule E warn-first** —— 结论只来自一台 NVIDIA/X11/x86_64 机器。 +- 跨仓阻塞项:xlings E2b 若无声明式退出即落地(设计 §1.3.1),**与本 PR 无耦合**,单独跟。 diff --git a/.agents/docs/2026-08-10-graphics-stack-usability-design.md b/.agents/docs/2026-08-10-graphics-stack-usability-design.md new file mode 100644 index 00000000..15544d19 --- /dev/null +++ b/.agents/docs/2026-08-10-graphics-stack-usability-design.md @@ -0,0 +1,311 @@ +# 图形栈全面不可用 —— 三层独立故障,和一条没有主人的依赖链 + +> 日期:2026-08-10 +> 基线:mcpp `2026.8.10.1`(main `3724102`)、xlings `2026.8.10.2`、 +> mcpp-index main `6b8d77aa`、xim-pkgindex main +> 本文所有结论都有实测或日志出处;本机绝对路径省略为 `` / ``。 + +--- + +## 0. 结论先行 + +**图形栈不可用不是一个 bug,是三层各自独立的故障叠在一起,任何一层单独都足以致命。** + +| 层 | 故障 | 当前影响 | +|---|---|---| +| **L1 装** | `xim:libglvnd@>=1.7.0.1` 解析不到 | mcpp-index 全量跑时 **8/8 图形包 FAIL** | +| **L2 建** | 缓存命中丢掉 std 次序([#405](https://github.com/mcpp-community/mcpp/issues/405)) | **每个图形项目的第二次构建必挂** | +| **L3 跑** | dispatch/vendor 两半无人校验,本机绑定陈旧 | 拿不到 GL context | + +而它们能同时存在且长期没人发现,原因只有一个: + +> **这条链跨了四方(mcpp / mcpp-index / xim / 宿主驱动),而没有任何一方对「整条链是活的」负责。 +> 每一方的 CI 都绿在自己那一片上。** + +--- + +## 1. L1 —— 装不上:一个约束伪装成一个缺包 + +### 1.1 现象 + +mcpp-index PR #200 的全量 workspace 跑: + +``` +error: xlings install_packages failed (exit 1) for 'compat.glx-runtime@2026.08.08' + xlings reported: E_INVALID_INPUT: package 'xim:libglvnd@>=1.7.0.1' not found +``` + +`gui-stack` / `imgui-window` / `eui-neo` / `eui-neo-app-main` / `eui-neo-markdown` / +`eui-neo-vulkan` / `eui-neo-window` / `eui-neo-sdl2` —— **八个,全部同一句**。 +同一次跑里 `core` / `catch2` / `catch2-main` / `gmp` 都是 ok。 + +### 1.2 但 xim 确实发布了 `1.7.0.1` + +`pkgs/l/libglvnd.lua`: + +```lua +["latest"] = { ref = "1.7.0.1" }, +-- Same upstream artifact as 1.7.0, new version key. +["1.7.0.1"] = { … } +["1.7.0"] = { … } +``` + +版本存在,而且是 `latest`。**所以「not found」是假话。** + +### 1.3 真因写在同一个文件里,往上一百行 + +`xim-pkgindex/pkgs/g/graphics.lua` 第 58–75 行,原文: + +> BARE, no range, and that is **measured rather than stylistic**: mesa's version is +> `25.0.7.1` — four components … — and **the resolver's range comparison cannot parse +> a four-component version at all**. Both `@>=25.0.7` and `@>=25.0.7.1` resolve to +> **"package not found"**, which reads as a missing package rather than an +> unparseable constraint. + +然后第 157 行: + +```lua +"xim:libglvnd@>=1.7.0.1", +``` + +**四段版本 + 范围约束 —— 正是它自己在一百行前说不可解析的那件事。** +mesa 用裸名规避了,libglvnd 没有。 + +### 1.4 这条缺陷有三个可独立修的面 + +1. **数据面(立刻止血)**:`xim:libglvnd@>=1.7.0.1` → `xim:libglvnd`,与 mesa 同治。 +2. **诊断面**:不可解析的约束**必须报成不可解析**,不能报成 "package not found"。 + 现在这条错误信息会把每一个看到它的人送去找一个并不缺的包 —— + 这一轮我自己就被同类信息误导过两次。 +3. **规则面**:一条 lint —— **任何 `@>=` / `@<=` 约束不得指向四段版本**, + 或者反过来把解析器补成能比较四段。二选一,但不能继续靠注释。 + +> **判据:注释无法强制不变量。** 同一个文件里,知识写在注释里、陷阱掉在一百行后, +> 这不是作者不小心,是**知识放错了地方**。 + +### 1.5 对 mcpp 的直接牵连 + +mcpp 自己的版本就是四段(`2026.8.10.1`),而记忆里早有一条: +`version_req` 三段解析静默丢第 4 段。**任何对 mcpp 版本做范围约束的地方, +都有同一个latent 缺陷。** 这次是别人的注册表先炸,下次可能是 `min_mcpp`。 + +--- + +## 2. L2 —— 建不了第二次:#405 + +依赖被缓存命中时,包的编译边被换成 stage 边。`ninja_backend.cppm` 自己的注释 +已经写明这会丢掉编译边携带的次序,并为此把每个 staged 产物聚合进 +`_mcpp_staged_cache` 作为 order-only 前置 —— **但 std BMI 是另一条更早发出的 +stage 边,没被收进那个聚合。** + +``` +build gcm.cache/std.gcm : stage_file /std/…/std.gcm ← 存在 +build _mcpp_staged_cache : phony obj/… obj/… ← 不含它 +build obj/main.o : cxx_object … || _mcpp_staged_cache ← 只依赖聚合 +``` + +ninja 从不执行那条边,`std.gcm` 在盘上不存在,消费方读被恢复的 BMI 时报 +`No such file or directory` / `Bad import dependency`。 + +**只有当消费方自己不 `import std` 时才现形** —— 而 imgui 模板生成的 `main.cpp` +恰好只 `import imgui.core; import imgui.app;`。所有图形包都 `import std`。 + +修法 5 行:`staged` 非空时把 std/std.compat 的 BMI 与 object 一并放进聚合。 +不动 cache key、不改 entry schema、不使任何人现有缓存失效。 +RED 已用 `mcpplibs.cmdline` 复现(`tests/e2e/211_cached_dep_std_is_ordered.sh`)。 + +--- + +## 3. L3 —— 跑不起来:一个能力被两个注册表各提供一半 + +| 半边 | 谁提供 | 落在哪 | +|---|---|---| +| **dispatch** `libGLX.so.0` | **mcpp-index** `compat.glx-runtime` | `/compat-x-glx-runtime/…/glx_runtime/lib` | +| **vendor** `libGLX_nvidia.so.0` | **xim** graphics 栈 | `/lib/…` → `/xim-x-nvidia-gl-host-link/…` | + +dispatch 的 RPATH **已经**包含 `/lib`,路径是通的;坏的是被指向的东西: + +- `xlings install graphics` 明说 rebind 到 `nvidia-gl-host-link@0.1.2`, + 而 `subos/default/lib/libGLX_nvidia.so.0` 仍解析进 **0.1.1** 的 payload; +- 那个 vendor 文件只有 **22 KB** —— `nvidia-gl-host-link` 是一座桥,不是驱动。 + +而 mcpp 这边 `mcpp why runtime` 的实际输出是: + +``` +providers: + - opengl.glx.driver -> compat.glx-runtime@2026.08.08 +artifacts: + (none declared) +``` + +**`artifacts: (none declared)`** —— mcpp 的模型里本来就有 `RuntimeArtifact` 这一格, +图形这条链一个都没填。**provider 有名无物,所以没有任何东西可以校验。** + +顺带一条硬约束:`/lib` 同时装着 vendor 库**和私有 glibc**。 +任何「把这个目录放上搜索路径」的修法都会让宿主二进制立刻死于 +`__pointer_chk_guard`([#401](https://github.com/mcpp-community/mcpp/issues/401) 已实测复现)。 +**vendor 只能按对象可达(RPATH),永远不能按目录可达。** + +--- + +## 4. 为什么三层能同时存在:四方各自绿 + +| 方 | 它的 CI 验什么 | 为什么看不见 | +|---|---|---| +| **mcpp** | 构建/链接/运行时闭包 | **没有任何 e2e 创建过 GL context** | +| **mcpp-index** | 改动了的成员 | main 的 `validate` 里 **`workspace` job 是 `skipped`** —— 只测本次 diff 改了的包 | +| **xim** | 包能装 | 它不构建 C++ 消费方 | +| **宿主驱动** | — | 没人测 | + +mcpp-index main 最近一次 `validate` 的 job 列表实测: + +``` +success select / lint / mirror-cn-reachable / graphics install: no side effects +skipped workspace (…) ← 真正会构建图形包的 job +skipped timings +``` + +**所以「main 是绿的」和「图形包装得上」之间没有任何关系。** +`compat.glx-runtime` 自己没改过 —— 变的是 xim 那边。而增量 CI 的定义 +就是「只测改了的」,它**结构上不可能**发现一个已发布的包因为**别人**的变化而失效。 + +这次能露头,纯属偶然:PR #200 改的是 `validate.yml`,按它自己的规则触发了 `__ALL__` +全量扫描 —— 一个和图形毫无关系的 CI 配置 PR。 + +--- + +## 5. 三个结构性缺口 + +- **G1 跨注册表的版本约束没有可满足性检查,而且失败信息会撒谎。** + 一份数据引用另一份数据,没有任何一方在对方变化时重新验证自己; + 验证失败时给出的还是一个误导性的原因。 +- **G2 关键知识住在注释里,不住在检查里。** + 四段版本不可范围比较 —— 写清楚了,然后同文件掉进去。 +- **G3 没有任何一方对「整条链活着」负责。** + 每一方只验自己改了什么,而这条链是全仓唯一同时跨四方的。 + +--- + +## 6. 方案 + +### 6.1 立刻止血(按层,互不依赖) + +| | 动作 | 归属 | 代价 | +|---|---|---|---| +| **L1** | `xim:libglvnd@>=1.7.0.1` → 裸名(与 mesa 同治) | xim-pkgindex | 一行 | +| **L1'** | 不可解析的约束报成不可解析,而不是 "not found" | xlings | 小 | +| **L1''** | lint:`@>=`/`@<=` 不得指向四段版本 | xim-pkgindex | 一条脚本 | +| **L2** | #405 | mcpp | 5 行,RED 已复现 | +| **L3** | 见 6.3 dev 档 | mcpp + 索引数据 | — | + +### 6.2 分发模式:先把 mcpp 自己的三档定下来 + +**构建树 ≠ 可分发物**,这是所有成熟工具的共同前提(CMake `BUILD_RPATH`/`INSTALL_RPATH` +安装时重写甚至 relink;Meson 安装时剥 rpath;libtool relink-on-install; +Bazel 输出树从不假装可分发;Cargo `target/` 同理)。 +**开发调试期直接吃 xlings 的 store 是正确且最快的做法** —— 前提是那道边界是显式的。 + +而 mcpp 已经把这条边界**命名过了**,`src/build/distribution.cppm` 原文: + +> **KNOWN LIMIT**: `HostCoupled` … does not strip the toolchain rpath … **Removing that +> rpath is a packaging axis of its own and is not part of this contract.** + +所以不是发明架构,是**把它自己点名的那条轴建起来**: + +| 档 | libc/deps | 驱动 | mcpp 的不变量 | 现状 | +|---|---|---|---|---| +| **dev**(构建树) | xlings store,绝对路径 | 宿主(经 xlings 桥) | requirement 必须落到**物**,且物的解析路径与 provenance **身份一致** | ❌ `artifacts: (none declared)` | +| **pack**(自包含) | 产物旁 + `$ORIGIN` | **必须来自宿主** | 产物里不得残留 store 绝对路径;产出**宿主要求清单** | ❌ 源码已标 KNOWN LIMIT | +| **static**(musl) | 无 | **不适用** | **拒绝**把驱动类依赖静态化,plan 期失败并说明理由 | ❌ 今天链过去,运行时崩 | + +实测:当前图形产物的 RUNPATH 是 + +``` +/xim-x-glibc/2.44/lib64 : /xim-x-gcc/16.1.0/lib64 +: /compat-x-glx-runtime/…/lib : $ORIGIN +``` + +三条指向**构建机 store** 的绝对路径。它不是「依赖 xlings 生态」,是 +「依赖这一台机器的这一份 store」。dev 档这样完全正确;**pack 档这样是缺陷。** + +### 6.3 dev 档要补的那一格(L3 的解) + +让「满足一条需求」落到一个**物**,而不是一个名字: + +``` +requirement capability:opengl.glx.driver + satisfied-by + provenance xim:nvidia-gl-host-link@0.1.2 +``` + +mcpp 用纯构建工具的手段就能抓到本机这次的故障:**物在不在;它解析出来的真实路径, +是否落在声明的那个版本的 store 目录里。** 本机答案是「解析进 0.1.1,而声明是 0.1.2」—— +**不需要懂 GL 就能判**。 + +而这条规则 mcpp **已经对 glibc 严格执行了**(`glibc@2.44` 只解析那一个 payload, +陈旧即错误,绝不回退)。套到 runtime artifact 上是**一致性,不是新机制**。 + +> **明确不做:能力探针。** +> 我先后提过「provider 在描述符里带探针」和「xlings 暴露机器可读能力查询」,两条都收回: +> dev 档的收益大部分被「物 + 身份」覆盖;pack/static 档探针问的是**另一台机器** +> (autoconf `AC_RUN_IFELSE` 三十年的教训);而在构建过程中执行 provider 提供的 +> 任意可执行文件,是一大块不必要的安全与复杂度面。 +> 真正只能靠跑才知道的那部分(桥有没有搭上宿主驱动),mcpp 就该报 `NOT_EXERCISED`, +> 那是 `xlings doctor` 的事。 + +### 6.4 G3 的解:谁来对整条链负责 + +三条,由近及远: + +1. **mcpp-index 增量 CI 要有一条不增量的兜底。** + 现在 cron 已经跑全量 —— 但**它红了不拦任何东西**。至少要让「图形成员全红」 + 变成一个会被看见的信号(cron 失败通知 / 一个 badge / 一条 issue 自动开)。 + 否则全量跑和不跑没有区别。 +2. **上游变化要能触发下游重验。** + `compat.glx-runtime` 依赖 `xim:graphics`;xim 那边动了,mcpp-index 这边应当重跑 + 受影响成员。今天是零。 +3. **mcpp 侧加一条真的创建 GL context 的 e2e**,三值报告: + PASS / FAIL / **NOT_EXERCISED**(无 display 或无驱动)。 + 没有三值,这条 e2e 只能在「跳过」和「失败」之间二选一,而跳过会被读成没问题。 + +--- + +## 7. 分阶段 + +| 阶段 | 内容 | 判据 | +|---|---|---| +| **S0** | L1 数据面止血(libglvnd 裸名) | index 全量跑里 8 个图形成员由 FAIL 转 ok | +| **S1** | #405 + `211` | 该 e2e 先 RED 后 GREEN;三平台不回归 | +| **S2** | L1 规则面:四段版本 lint + 不可解析约束的正确诊断 | 故意写一条 `@>=x.y.z.w` 会被 lint 拦下 | +| **S3** | dev 档:requirement→artifact 必须有物;物的路径与 provenance 身份一致 | 本机现状必须报错并指出 vendor 绑定陈旧,而不是 `pass` | +| **S4** | GL context e2e(三值)+ `display` 能力轴 | 有 display 的机器 PASS,无 display 报 NOT_EXERCISED | +| **S5** | pack 档:RPATH 重写为 `$ORIGIN` + 宿主要求清单 | 打出来的图形产物在没有 xlings 的机器上能跑 | +| **S6** | static 档:驱动类依赖不可静态化,plan 期硬拒 | `--target *-musl` 拉进 GL 依赖时红,且说明理由 | + +S0/S1 互不依赖,都可以立刻走。S3 需要索引侧先填 artifact(数据,不是代码)。 +S5 是图形程序真正的分发出路,也是最大的一块。 + +--- + +## 8. 明确不做 + +- 不在 mcpp 里探测 GPU / Mesa / NVIDIA / WSL / ICD / driver。 +- 不在构建过程中执行 provider 提供的探针(见 6.3)。 +- 不把 `/lib` 放进任何环境变量或全局搜索路径(#401)。 +- 不动 `compat.glx-runtime` 的 dispatch RPATH —— 它已经是对的。 +- 不动 #398 冻结的四条产品边界。 + +--- + +## 附:这一轮反复出现的同一条判据 + +> **「我这一片是绿的」从来不等于「它能用」。** + +- Wine 重放通过 ≠ Windows 通过(`Z:` 映射) +- 单测断言路径拼写通过 ≠ 索引可读(它在 Windows 全红时一直绿) +- 缓存第一次通过 ≠ 第二次通过(#405,miss 与 hit 是两条路径) +- **index main 绿 ≠ 图形包装得上**(`workspace` job 根本是 skipped) + +四次都是同一个形状:**验证的对象比它声称覆盖的范围窄,而窄在哪里没有被说出来。** +本文提的每一条检查,目的都是把「没验」和「验过了」区分开 —— 三值、lint、 +身份一致性,全都是这一件事。 diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 68355d10..d22e74ef 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.9.2' + default: '2026.8.10.4' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 0e7fef7d..750ed3df 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.9.2' + default: '2026.8.10.4' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index f69a58eb..5983d4b2 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.9.2' + XLINGS_VERSION: '2026.8.10.4' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 1e1c96ea..c08bc91f 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.9.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.10.4 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -293,7 +293,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.9.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.10.4 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -364,7 +364,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.9.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.10.4 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index fe479bbc..a33ad7ee 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -133,7 +133,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.9.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.10.4 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index be57dd3a..16679054 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -118,7 +118,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.9.2' + XLINGS_VERSION: '2026.8.10.4' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -255,7 +255,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.9.2' + XLINGS_VERSION: '2026.8.10.4' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19a2f994..fbb0b9b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.9.2' + XLINGS_VERSION: '2026.8.10.4' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -288,7 +288,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.9.2' + XLINGS_VERSION: '2026.8.10.4' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -358,11 +358,11 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.9.2-linux-aarch64.tar.gz" + XLA="xlings-2026.8.10.4-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.9.2/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.10.4/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.9.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.10.4-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -440,7 +440,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.9.2' + XLINGS_VERSION: '2026.8.10.4' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -622,7 +622,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.9.2' + XLINGS_VERSION: '2026.8.10.4' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/CHANGELOG.md b/CHANGELOG.md index 867b0df6..d59e0d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,77 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.10.2] — 2026-08-10 + +图形栈闭合与分发档位。完整设计与实施计划见 +`.agents/docs/2026-08-10-graphics-closure-and-distribution-tiers-design.md` 与 +`.agents/docs/2026-08-10-graphics-closure-implementation-plan.md`。 + +### 修复 + +- **依赖 BMI 缓存命中时,被恢复的包传递依赖的 `std` 没进构建图([#405])。** + 消费方自己不 `import std` 时,`gcm.cache/std.gcm` 的 stage 边没有任何消费者, + ninja 从不执行它,于是被恢复的 BMI 报 `No such file or directory` / + `Bad import dependency` —— 一条完全不指向缓存的错误。缓存 **miss** 时依赖在本地编译, + 这个动作把 std 边带进图,所以**第一个构建它的人永远是好的、之后每个人都坏**, + 看起来像升级回归。修法把 std BMI 放进 `_mcpp_staged_cache` 聚合 —— + 那个聚合本来就是为「stage 边丢掉编译边携带的次序」而存在的。 + 不动 cache key、不改 entry schema,**现有缓存全部继续有效**。 + +- **`mcpp build` 会重放 `mcpp test` 留下的构建图([#407])。** 三种模式写同一个 + `build.ninja`(指纹不含 dev-deps 与 test targets),而快路径只比源码 mtime、 + 从不校验这张图是哪一种。于是 `build → test → build` 链测试、不链 target、 + 还报 `Finished`;改坏一个测试文件会让**普通 `mcpp build` 失败**,而 `src/` 一个字没动。 + 修法是让 `build.ninja` 自己声明形态(`# mcpp:graph=normal|test`),快路径校验它即将 + 重放的那张图。**这是读取侧不变式**:#387 那半边的写入侧修补被同时删掉了 —— + 写入侧修补要求每一个未来的图重写者都记得调它,而这正是 `mcpp test` 那半边 + 在 `--configure-only` 修好之后仍然坏着的原因。 + +### 新增 + +- **加载器标签契约(`mcpp.build.loader_contract`)。** 可执行文件必须 **DT_RPATH**, + 共享库保持 **DT_RUNPATH**。DT_RUNPATH 只对携带它的对象**自己发起**的 `dlopen` 生效; + 图形程序到驱动要经三到四层 `dlopen`,而**这些 dlopen 都不是它发起的**, + 是 `libGLX.so.0` / `libEGL.so.1` 代发的 —— 所以标签(不是路径)决定它能不能拿到 GPU。 + 同一路径只翻标签,egl / gles2 / egl-surfaceless 从 llvmpipe 变 NVIDIA。 + 反过来在**库**上强制 RPATH 有害(传递性会打断 `eglInitialize`),所以这是一分为二、 + 不是一起翻。链接期与 `mcpp pack` 的 patchelf 期读**同一条契约**。 +- **rule E:标签校验落在产物上,并写进 `resolution.json` 的 `loader_tags`。** + warn-first,与既有闭包规则同一推进节奏。记录而不只是告警 —— + 一个只在沉默中通过的检查,和一个根本没跑的检查,输出完全相同。 +- **`mcpp pack`:产物里不再残留构建机路径。** 此前只有主二进制被重写, + bundle 进来的每个 `.so` 都保留着链接时的 RUNPATH,而在这个生态里那是一串指向 + **构建机 xlings store** 的绝对路径。「依赖 xlings 生态」是设计选择, + 「依赖这一台机器的这一份 store」是缺陷,而且它在构建它的机器上跑得好好的。 + 另外 `patchelf --set-rpath` 默认写 DT_RUNPATH —— 对可执行文件而言是同一个缺陷晚一层。 +- **`HOST-REQUIREMENTS`:自包含也有底。** 驱动类库只能来自目标机器 + (与内核模块锁步,且专有栈禁止再分发),所以打包这类程序的诚实产出不是一个悄悄 + 少了它的 bundle,而是 bundle **加上一份声明**。带 `discovery` 一列,因为几种发现 + 机制互不通用 —— 一种是烙进派发库的搜索路径,另一种是 JSON 里的**绝对**路径, + 「把目录搬过去」只满足其中一个。 +- **自带 libc 的档遇到宿主能力硬拒。** `--mode self-contained` 与 `--mode static` + 在存在 run 期能力需求时于 plan 期失败,并指出改用 `--mode vendored`。 + 两者坏在同一件事上:那个 `.so` 带着对**宿主 libc** 的要求进来,而进程里没有那份 + (#392 / #401 的两个方向)。此前两档都链得过去、然后运行时崩或静默降级。 +- **`[[runtime.requirements]] discovery`。** 声明式,**mcpp 绝不推断** —— + 从能力名推断机制就是把 provider 专属知识写进 mcpp(`test_runtime_contract` 正是 + 为此设的门),而且它是 provider 的属性、会脱离 mcpp 变化。未声明报 `unknown`。 +- **声明的 runtime artifact 带身份判决。** `resolution.json` 每个 artifact 增加 + `identity`:`ok` / `mismatch` / `missing` / `unverified`。这正是 mcpp **已经** + 对私有 libc 执行的规则(`glibc@2.44` 只解析那一个载荷,陈旧即错误)的推广, + 纯路径事实、跟随符号链接、不需要认识 GL。`mcpp why runtime` 的 + `artifacts: (none declared)` 改为 `(not declared by the environment — + nothing to verify)`:一个有名无物的 provider 是**未验证**,不是验证通过。 + +### 变更 + +- **内带 xlings 升到 `2026.8.10.4`。** 其中 `2026.8.9.2` 的 semver 重写让四段版本可以 + 参与范围比较 —— `xim:libglvnd@>=1.7.0.1` 此前解析成 `package not found`, + 这正是 mcpp-index 全量跑里 8 个图形成员全红的原因(不是数据缺陷)。 + +[#405]: https://github.com/mcpp-community/mcpp/issues/405 +[#407]: https://github.com/mcpp-community/mcpp/issues/407 + ## [2026.8.10.1] — 2026-08-10 包身份、开发运行时与发布链收敛为同一组可验证事实。完整设计与验证记录见 diff --git a/docs/02-pack-and-release.md b/docs/02-pack-and-release.md index a493820b..f1cce7e5 100644 --- a/docs/02-pack-and-release.md +++ b/docs/02-pack-and-release.md @@ -61,6 +61,40 @@ How to choose: - Cross-distro / older glibc (legacy CentOS, Kylin) → `self-contained` - Single portable file, no host deps → `static` +### A program that needs the HOST to provide something + +"Self-contained" has a floor. Some libraries can only come from the target +machine: a graphics driver's user-space half is version-locked to the running +kernel module, and for the proprietary stacks redistribution is not permitted. +Declare those as run-phase capability requirements (§2.11 of +`docs/05-mcpp-toml.md`), and the mode table gains a column: + +| Mode | Program needing a host-provided capability | +|---|---| +| `system` | ✅ | +| `vendored` (default) | ✅ **the right default for these** | +| `self-contained` | ❌ **refused at pack time** | +| `static` | ❌ **refused at pack time** | + +The two refusals are the same fact: **a bundle that carries its own libc cannot +consume a library the host supplies.** That `.so` arrives with its own +requirements on the *target's* libc, and the process does not have that libc — +measured in both directions as mcpp#392 / mcpp#401, where a private glibc +meeting host-loaded objects dies during relocation, before `main`. Previously +both modes linked and then failed at startup, or silently degraded (for +graphics: software rendering, with nothing saying so). + +`vendored` packages such a program and writes a **`HOST-REQUIREMENTS`** file at +the bundle root stating what the target must supply: + +``` +capability=opengl.glx.driver discovery=rpath-of-dispatch +``` + +`discovery` is the actionable half — the mechanisms are independent, so +satisfying one does not satisfy another. It is written only when there is +something to say: an empty file would be a *claim* that nothing is needed. + ### Mode name compatibility Canonical names are shown above. The old names remain **permanent aliases**: diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 8ed81a6d..804b980a 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -948,7 +948,8 @@ whose recorded addresses escape it, so a recurrence is auditable offline. ```toml [runtime] requirements = [ - { kind = "capability", value = "display.present", phase = "run", required = true }, + { kind = "capability", value = "display.present", phase = "run", required = true, + discovery = "rpath-of-dispatch" }, { kind = "soname", value = "libwidget.so.1", phase = "link", required = false }, ] provides = ["display.present"] @@ -971,6 +972,17 @@ provider = "acme.widget-runtime@2.0.0" `requirements` records a non-empty `kind`/`value`, a `link` or `run` phase, and whether the requirement is mandatory (`required` defaults to `true`). + +`discovery` is optional and says **how the loader finds** whatever satisfies the +requirement — e.g. `rpath-of-dispatch`, `json-dir`, `glvnd-dispatch`. It is +**declared, never inferred**: which mechanism a capability uses is the +provider's property and changes without mcpp, so mcpp carries the value and +reports an undeclared one as `unknown` rather than guessing. It earns a field +because the mechanisms are not interchangeable — one may be a search path baked +into a dispatch library, another a JSON file holding an *absolute* path, so +"copy the directory across" satisfies one and not the other. `mcpp pack` writes +it into the bundle's `HOST-REQUIREMENTS` and `mcpp publish` projects it into +the descriptor, from one derivation. Optional requirements remain visible provenance but do not become hard ABI or doctor inputs. A `libraries` entry that is an explicit relative file path is resolved against the declaring package root; a bare logical name remains a @@ -1013,6 +1025,22 @@ the latest stored file: it neither re-resolves the manifest nor launches a graphics/hardware probe. Use `xlings doctor` when the selected host provider itself needs re-diagnosis. +Each artifact also carries an `identity` verdict, computed from paths alone: + +| `identity` | meaning | +|---|---| +| `ok` | the declared path resolves (through symlinks) into the declared version | +| `mismatch` | it resolves somewhere else — **the binding is stale**, a later install repointed it | +| `missing` | declared, but nothing is at that path | +| `unverified` | declared without a version to check against | + +This is the rule mcpp already applies to the private libc (`glibc@2.44` resolves +that one payload; stale or missing is an error, never "whichever installed +version looks usable"), generalised. It needs no knowledge of what the artifact +does. `unverified` is deliberately not `ok`: a resolved provider with no +artifact behind it has not been checked, and `mcpp why runtime` says +`(not declared by the environment — nothing to verify)` rather than `(none)`. + Capability names use layered lowercase `domain.sub.role` (for example `display.present`) and prefix-style `abi:` (for example `abi:glibc`, which participates in toolchain ABI enforcement). diff --git a/mcpp.toml b/mcpp.toml index d4685526..bb6e7ecb 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.10.1" +version = "2026.8.10.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/configure.cppm b/src/build/configure.cppm index 35a17563..e3c93ad4 100644 --- a/src/build/configure.cppm +++ b/src/build/configure.cppm @@ -93,13 +93,16 @@ int run_configure_plan(BuildContext& ctx, bool verbose) { // The backend writes build.ninja before it honors dryRun, and a configure // plan's graph is NOT a normal build's graph: it carries the test targets // and dev-dependencies, so its `default` line names the test binaries and - // omits the package's own target entirely. Drop the fast-path entry that - // still claims this build dir holds a normal graph — otherwise the next - // plain `mcpp build` replays it, links the tests, never links the target, - // and prints `Finished`. Invalidated BEFORE the backend runs so a failed - // configure cannot leave the stale claim standing either. - forget_build_cache_entry(ctx.projectRoot, ctx.outputDir); - + // omits the package's own target entirely. + // + // This used to be handled here, by dropping the fast-path cache entry that + // still claimed the build dir held a normal graph. That repair was on the + // WRITE side and therefore had to be repeated by every mode that rewrites + // the graph — which is exactly why `mcpp test` stayed broken (#407) after + // this half was fixed (#387). The plan now stamps its shape into + // build.ninja and the fast paths check it, so a graph that is not a plain + // build's graph cannot be replayed no matter who wrote it. Nothing to do + // here. auto result = backend->build(ctx.plan, options); if (!result) { mcpp::ui::error(result.error().message); diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 39aaf1de..aab1b3b0 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -14,6 +14,7 @@ import mcpp.build.prepare; import mcpp.build.test_targets; import mcpp.diag; import mcpp.build.plan; +import mcpp.build.graph_shape; // #407: which mode wrote this build.ninja import mcpp.build.backend; import mcpp.build.ninja; import mcpp.build.runtime_validation; @@ -193,9 +194,8 @@ std::vector read_build_cache(const std::filesystem::path& proje return entries; } -// Serialize the P3 format. Split out of write_build_cache so the invalidation -// path (forget_build_cache_entry) can rewrite the file without inventing a -// second spelling of it. +// Serialize the P3 format. Declared ahead of its single caller so the reader +// and the writer of this file sit next to each other. void write_build_cache_entries(const std::filesystem::path& path, const std::vector& entries); @@ -271,40 +271,6 @@ void write_build_cache_entries(const std::filesystem::path& path, } } -// Drop the fast-path entries that point at `outputDir`. -// -// The fast path's contract is "the build.ninja in this entry's outputDir is -// still the graph a normal `mcpp build` would generate" — and it verifies that -// by comparing mtimes against the SOURCES, never against the graph itself. So -// any mode that rewrites build.ninja to something other than a normal build's -// graph has to say so here, or the next `mcpp build` replays the wrong graph -// and reports success for it. `--configure-only` is exactly such a mode: its -// plan carries the test targets and dev-dependencies, so its `default` line is -// the TEST binaries and does not contain the package's own target at all. -// -// Scoped to the one outputDir whose graph was rewritten, not the whole file: -// the other (target, profile, cache mode) triples own different build dirs and -// their graphs are untouched, so evicting them would only cost rebuilds. -export void forget_build_cache_entry(const std::filesystem::path& projectRoot, - const std::filesystem::path& outputDir) { - auto path = projectRoot / kBuildCacheFile; - std::error_code ec; - if (!std::filesystem::exists(path, ec)) return; - - auto entries = read_build_cache(projectRoot); - auto target = outputDir.lexically_normal(); - auto removed = std::erase_if(entries, [&](const BuildCacheEntry& e) { - return std::filesystem::path(e.outputDir).lexically_normal() == target; - }); - if (removed == 0) return; - - if (entries.empty()) { - std::filesystem::remove(path, ec); - return; - } - write_build_cache_entries(path, entries); -} - std::vector read_ninja_command_prefixes(const std::filesystem::path& ninjaPath) { std::ifstream f(ninjaPath); if (!f) return {}; @@ -784,6 +750,16 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo auto ninjaPath = outputDir / "build.ninja"; if (!std::filesystem::exists(ninjaPath, ec)) return std::nullopt; + // #407. Freshness is measured against the SOURCES, which says nothing + // about what kind of graph this is. `mcpp test` and + // `mcpp build --configure-only` write their plan — dev-deps, test targets, + // `default` naming the test binaries and NOT the package's target — into + // this same file, because the fingerprint covers neither input. Replaying + // that for a plain build linked the tests, never linked the target, and + // printed `Finished`; and a broken file under tests/ (never scanned here) + // failed a plain `mcpp build` outright. + if (!mcpp::build::is_plain_build_graph(ninjaPath)) return std::nullopt; + auto ninjaTime = std::filesystem::last_write_time(ninjaPath, ec); if (ec) return std::nullopt; @@ -886,6 +862,10 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, std::filesystem::path outputDir(outputDirStr); auto ninjaPath = outputDir / "build.ninja"; if (!std::filesystem::exists(ninjaPath, ec)) return std::nullopt; + // #407, same reason as try_fast_build: a test-shaped graph does not build + // the run target at all, so running ninja against it would report success + // and then exec a stale (or absent) binary. + if (!mcpp::build::is_plain_build_graph(ninjaPath)) return std::nullopt; auto ninjaTime = std::filesystem::last_write_time(ninjaPath, ec); if (ec) return std::nullopt; diff --git a/src/build/graph_shape.cppm b/src/build/graph_shape.cppm new file mode 100644 index 00000000..385f9ba6 --- /dev/null +++ b/src/build/graph_shape.cppm @@ -0,0 +1,85 @@ +// mcpp.build.graph_shape — what kind of graph a build.ninja holds, written +// into the file and read back out of it. +// +// `target///build.ninja` is SHARED MUTABLE STATE. Three modes +// write it — `mcpp build`, `mcpp test`, `mcpp build --configure-only` — and +// they land in the same directory because the fingerprint covers neither +// dev-dependencies nor test targets. But `emit_ninja_string`'s `default` line +// lists every link unit in the plan, and in test mode that is ONLY the test +// binaries: the package's own target is not in the graph at all. +// +// The fast path used to compare build.ninja's mtime against the SOURCES and +// nothing else, so `build → test → build` replayed the test graph, linked the +// tests, never linked the target, and printed `Finished`. Breaking a test file +// then failed a plain `mcpp build` with `src/` untouched. +// +// WHY THE FILE AND NOT THE CACHE. The first repair for this (mcpp#387) was on +// the WRITE side: the mode that rewrites the graph drops the fast-path entry +// afterwards. That works, and it has to be repeated by every future mode that +// rewrites build.ninja — the same decision derived in a new place each time, +// which is how the `mcpp test` half stayed broken after the `--configure-only` +// half was fixed. A READ-side invariant only has to hold once: the fast path +// checks the graph it is about to replay. +// +// Putting the shape in build.ninja rather than in `.build_cache` is the same +// argument one level down. Two files can disagree; a file that describes +// itself cannot. The single producer of build.ninja is also the single writer +// of this line. + +export module mcpp.build.graph_shape; + +import std; + +export namespace mcpp::build { + +enum class GraphShape { + // What a plain `mcpp build` generates: the package's own targets. + Normal, + // Dev-dependencies and synthetic test targets are in the plan, so `default` + // names the test binaries. Produced by `mcpp test` and by + // `mcpp build --configure-only`. + WithTests, +}; + +std::string_view to_string(GraphShape shape) { + return shape == GraphShape::WithTests ? "test" : "normal"; +} + +// The marker line, without its newline. A ninja comment, so it costs nothing +// and older ninja versions do not care. +std::string header_line(GraphShape shape) { + return std::format("# mcpp:graph={}", to_string(shape)); +} + +// Read the shape back. `nullopt` means "this file does not say" — a build.ninja +// written before this line existed, an unreadable file, or something that is +// not a mcpp graph at all. Callers must treat that as a MISS, never as +// `Normal`: the whole point is that an unlabelled graph is exactly the case +// that used to be replayed blind. +std::optional read_shape(const std::filesystem::path& ninjaPath) { + std::ifstream input(ninjaPath); + if (!input) return std::nullopt; + // The marker is written first, but read a few lines anyway so a future + // banner above it does not silently turn every build into a full prepare. + std::string line; + for (int i = 0; i < 8 && std::getline(input, line); ++i) { + constexpr std::string_view prefix = "# mcpp:graph="; + if (!line.starts_with(prefix)) continue; + auto value = std::string_view(line).substr(prefix.size()); + while (!value.empty() && (value.back() == '\r' || value.back() == ' ')) + value.remove_suffix(1); + if (value == "normal") return GraphShape::Normal; + if (value == "test") return GraphShape::WithTests; + // A shape this binary does not know is not `Normal`. An older mcpp + // meeting a newer graph must fall back, not guess. + return std::nullopt; + } + return std::nullopt; +} + +// The one question every fast path asks. +bool is_plain_build_graph(const std::filesystem::path& ninjaPath) { + return read_shape(ninjaPath) == GraphShape::Normal; +} + +} // namespace mcpp::build diff --git a/src/build/loader_contract.cppm b/src/build/loader_contract.cppm new file mode 100644 index 00000000..73f6e36a --- /dev/null +++ b/src/build/loader_contract.cppm @@ -0,0 +1,166 @@ +// mcpp.build.loader_contract — which dynamic tag an artifact must carry, and +// how each producer spells it. +// +// THE RULE +// +// executable DT_RPATH +// shared library DT_RUNPATH +// +// and it is measured, not stylistic. DT_RUNPATH is consulted only for the +// object that carries it and for the dlopen() that object performs itself; +// DT_RPATH is consulted for every dlopen anywhere in the process, at any depth. +// A GL program reaches its driver through three to four dlopen() calls it does +// not make — libGLX.so.0 / libEGL.so.1 make them on its behalf — so an +// executable tagged DT_RUNPATH has the right path and cannot reach through it. +// Flipping only the tag, with identical paths, moves egl / gles2 / +// egl-surfaceless from llvmpipe to the GPU. +// +// The second half is equally measured and runs the other way: forcing DT_RPATH +// onto a LIBRARY is harmful. Transitivity pushes that library's search path +// into every lookup below it, and eglInitialize fails outright +// (openxlings/xlings#593). So this is a split, not a flip. +// +// WHY A MODULE +// +// Three producers must agree: the linker command line (dev builds), patchelf +// (`mcpp pack`), and the checker that reads the result back. Spelling the rule +// once is the point — a per-recipe decision is exactly how the ecosystem got +// 1 correct executable out of 73, with the one author who found the problem +// having no way to carry it to the other 72. +// +// This module does not parse ELF. `mcpp.platform.elf_runtime` reads the bytes; +// this decides what they should have said. Non-ELF formats get +// `NotApplicable`, so Mach-O and PE need no branches in the callers. + +export module mcpp.build.loader_contract; + +import std; +import mcpp.platform.elf_runtime; + +export namespace mcpp::build::loader { + +// What the artifact IS. Decided by PT_INTERP, never by ELF type: a PIE +// executable is ET_DYN and therefore type-identical to a shared library. +enum class Form { Executable, SharedLibrary, NotElf }; + +// What it must carry. +enum class RequiredTag { Rpath, Runpath, NotApplicable }; + +RequiredTag required_tag(Form form) { + switch (form) { + case Form::Executable: return RequiredTag::Rpath; + case Form::SharedLibrary: return RequiredTag::Runpath; + case Form::NotElf: return RequiredTag::NotApplicable; + } + return RequiredTag::NotApplicable; +} + +// The linker flag that produces `tag`, or nothing when the default already +// does. +// +// MUST BE APPENDED AFTER every other linker argument. GCC specs and clang +// config files hand ld `--enable-new-dtags`, and the last occurrence wins. +// xlings' first linker wrapper put its arguments first, the tag stayed +// DT_RUNPATH, and the only entry point exercised at the time (GLX) worked +// anyway — so the bug shipped looking fixed. +std::optional link_flag(RequiredTag tag) { + switch (tag) { + // GNU ld and lld both default to --enable-new-dtags (DT_RUNPATH) on + // every distribution mcpp targets, so the executable case is the one + // that needs saying. + case RequiredTag::Rpath: return "-Wl,--disable-new-dtags"; + case RequiredTag::Runpath: return std::nullopt; + case RequiredTag::NotApplicable: return std::nullopt; + } + return std::nullopt; +} + +// The patchelf argument that produces `tag` alongside `--set-rpath`. +// +// `patchelf --set-rpath` writes DT_RUNPATH by default, which is the same +// defect one layer later: a packaged GL program with a bundled vendor would +// carry the path and be unable to dlopen through it. +std::optional patchelf_flag(RequiredTag tag) { + switch (tag) { + case RequiredTag::Rpath: return "--force-rpath"; + case RequiredTag::Runpath: return std::nullopt; + case RequiredTag::NotApplicable: return std::nullopt; + } + return std::nullopt; +} + +std::string_view to_string(RequiredTag tag) { + switch (tag) { + case RequiredTag::Rpath: return "DT_RPATH"; + case RequiredTag::Runpath: return "DT_RUNPATH"; + case RequiredTag::NotApplicable: return "n/a"; + } + return "n/a"; +} + +struct TagFinding { + std::filesystem::path artifact; + Form form = Form::NotElf; + RequiredTag required = RequiredTag::NotApplicable; + mcpp::platform::elf::SearchPathTag actual = + mcpp::platform::elf::SearchPathTag::None; + + // Three-valued on purpose. `NotChecked` is not `Ok`: an artifact that + // could not be read, or that carries no search path at all, has not been + // shown to satisfy the contract — and a check that can only say pass or + // fail reports "nothing wrong" for both. + enum class Status { Ok, Violation, NotChecked }; + Status status = Status::NotChecked; + + std::string explain() const { + using SearchPathTag = mcpp::platform::elf::SearchPathTag; + if (status != Status::Violation) return {}; + return std::format( + "{}: {} carries {} but the loader contract requires {} — a search " + "path under {} is not reachable from a dlopen() performed by " + "another object, which is how a graphics program reaches its " + "driver (it never dlopens the vendor itself)", + artifact.string(), + form == Form::Executable ? "executable" : "shared library", + mcpp::platform::elf::to_string(actual), + to_string(required), + mcpp::platform::elf::to_string(actual == SearchPathTag::Both + ? SearchPathTag::Runpath + : actual)); + } +}; + +// Rule E, evaluated where the artifact lands rather than in a repository +// workflow the user's machine never sees. +TagFinding check_artifact(const std::filesystem::path& artifact) { + using SearchPathTag = mcpp::platform::elf::SearchPathTag; + TagFinding out; + out.artifact = artifact; + + auto facts = mcpp::platform::elf::inspect_elf_runtime(artifact); + if (!facts) return out; // not ELF, or unreadable + + out.form = facts->is_executable() ? Form::Executable : Form::SharedLibrary; + out.required = required_tag(out.form); + out.actual = facts->searchPathTag; + + // No search path at all is not a violation: nothing was claimed, so + // nothing is unreachable. It stays NotChecked so it can never be counted + // as evidence that the contract holds. + if (out.actual == SearchPathTag::None) return out; + + // `Both` reads as DT_RUNPATH because that is what the loader does with it, + // and DT_RPATH-first is the common layout — a checker that stops at the + // first tag it finds would call this compliant while the loader ignores + // the tag it matched on. + const auto effective = out.actual == SearchPathTag::Both + ? SearchPathTag::Runpath : out.actual; + const bool ok = (out.required == RequiredTag::Rpath + && effective == SearchPathTag::Rpath) + || (out.required == RequiredTag::Runpath + && effective == SearchPathTag::Runpath); + out.status = ok ? TagFinding::Status::Ok : TagFinding::Status::Violation; + return out; +} + +} // namespace mcpp::build::loader diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 12480305..9fade77a 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -21,6 +21,8 @@ import std; import mcpp.build.backend; import mcpp.manifest; import mcpp.build.distribution; +import mcpp.build.graph_shape; +import mcpp.build.loader_contract; import mcpp.build.plan; import mcpp.build.flags; import mcpp.build.hermetic; @@ -402,6 +404,10 @@ std::string emit_ninja_string(const BuildPlan& plan) { auto append = [&](std::string s) { out += std::move(s); }; append("# Auto-generated by mcpp v0.0.1. Do not edit by hand.\n"); + // #407: the graph declares which mode produced it, because three modes + // write this one file and the fast path has to know what it is about to + // replay. Must stay within the first few lines — see read_shape. + append(mcpp::build::header_line(plan.graphShape) + "\n"); append("ninja_required_version = 1.11\n\n"); // All compile/link flags are computed once via flags.cppm. @@ -1070,6 +1076,27 @@ std::string emit_ninja_string(const BuildPlan& plan) { } } if (!staged.empty()) { + // mcpp#405. A restored BMI carries its own imports, and `std` is + // the one every index package has. When the CONSUMER does not + // `import std` itself, no compile edge below names + // `gcm.cache/std.gcm`, so the stage edge emitted above it has zero + // consumers and ninja never runs it — the restored BMI then reads + // `std.gcm: No such file or directory` / `Bad import dependency`, + // an error that points nowhere near the cache. + // + // Cache MISS hides it: the dependency compiles locally, which puts + // the std edge in the graph as a real prerequisite. So the first + // project to build a package always works and every later one + // fails, which reads as an upgrade regression. + // + // This aggregate is where it belongs: it exists precisely because + // replacing compile edges with stage edges drops the ordering the + // compile edges carried (see ORDERING above). The std BMI is that + // same defect, one edge earlier — it was simply emitted before the + // loop that fills `staged`. + if (has_std_artifacts) staged.push_back(escape_ninja_path(std_bmi_dst)); + if (has_std_compat) staged.push_back(escape_ninja_path(compat_bmi_dst)); + append("\n"); // One phony aggregates them so each consuming edge names a single // prerequisite instead of repeating the whole list (mcpp#274: long @@ -1336,6 +1363,13 @@ std::string emit_ninja_string(const BuildPlan& plan) { // could not reach the test side of it). std::string unit = join_flags(lu.linkFlags); unit += flags.ldStdlibFor(role_of(lu.kind)); + // LAST, after every other linker argument, because the loader tag + // is decided by the last `--enable-new-dtags`/`--disable-new-dtags` + // ld sees — and both gcc specs and clang config files supply the + // former. `$unit_ldflags` is itself the final expansion in every + // link rule above, so "last here" is "last on the line". + if (!lu.loaderTagFlag.empty()) + unit += " " + lu.loaderTagFlag; if (!unit.empty()) out_line += " unit_ldflags =" + unit + "\n"; } @@ -1733,6 +1767,20 @@ std::expected NinjaBackend::build(const BuildPlan& plan "runtime closure validation failed (proven Linux ELF mismatch)", runtimeFailureArtifact, std::move(runtimeFailure)}); } + // Rule E — the loader-tag contract, checked on what actually landed + // and recorded in resolution.json. + // + // WARN-FIRST, on purpose, matching how the closure rules were rolled + // out: the measurement behind the rule comes from one NVIDIA/X11/ + // x86_64 machine, and turning a gate hard on unverified hardware + // stops builds that were fine. Collect real deviations first. + for (auto const& finding : + mcpp::build::runtime_validation::check_and_record_loader_tags( + plan, runtimeBefore)) { + if (finding.status != mcpp::build::loader::TagFinding::Status::Violation) + continue; + mcpp::ui::warning(finding.explain()); + } if (opts.verbose && !out.empty()) std::fputs(out.c_str(), stdout); std::set want(opts.ninjaTargets.begin(), opts.ninjaTargets.end()); diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 8100bf90..50873ae9 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -6,6 +6,8 @@ export module mcpp.build.plan; import std; +import mcpp.build.graph_shape; +import mcpp.build.loader_contract; import mcpp.manifest; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; @@ -71,6 +73,12 @@ struct LinkUnit { std::vector objects; std::vector implicitInputs; // relative to plan.outputDir std::vector linkFlags; // per-link edge flags + // The loader-tag flag for THIS unit's form (mcpp.build.loader_contract). + // Separate from linkFlags because its correctness is positional: gcc specs + // and clang config files hand ld `--enable-new-dtags`, last occurrence + // wins, so the emitter puts this after every other linker argument. + // Deciding it stays here; placing it is the emitter's business. + std::string loaderTagFlag; std::filesystem::path output; // relative to plan.outputDir std::string soname; // ABI name for shared libraries std::vector runtimeAliases; // relative aliases, e.g. bin/libfoo.so.1 @@ -119,6 +127,11 @@ struct BuildPlan { mcpp::manifest::Manifest manifest; mcpp::toolchain::Toolchain toolchain; mcpp::toolchain::Fingerprint fingerprint; + // Which graph this plan will write into build.ninja. The fingerprint does + // NOT cover dev-deps or test targets, so `mcpp build` and `mcpp test` + // share an output directory and overwrite each other's graph; this is what + // lets a fast path tell them apart (mcpp#407, mcpp.build.graph_shape). + GraphShape graphShape = GraphShape::Normal; // One immutable snapshot selected before workspace member substitution. // Build/run/test and cache fast paths consume this value; none may re-read // xlings active/current state. @@ -767,6 +780,29 @@ make_plan(const mcpp::manifest::Manifest& manifest, }(); const auto naming = naming_for(tc); + // The loader-tag contract exists only where DT_RPATH/DT_RUNPATH do. + // Mach-O and PE have neither, so they get no flag rather than a branch in + // every consumer. + const bool elfTarget = targetTriple.empty() + ? bool(mcpp::platform::is_linux) + : (targetTriple.os != "macos" && targetTriple.os != "windows"); + auto loader_tag_flag = [&](LinkUnit::Kind kind) -> std::string { + if (!elfTarget) return {}; + using mcpp::build::loader::Form; + Form form; + switch (kind) { + case LinkUnit::Binary: + case LinkUnit::TestBinary: form = Form::Executable; break; + case LinkUnit::SharedLibrary: form = Form::SharedLibrary; break; + // An archive has no dynamic section; whoever links it gets the + // tag for their own form. + case LinkUnit::StaticLibrary: return {}; + } + auto flag = mcpp::build::loader::link_flag( + mcpp::build::loader::required_tag(form)); + return flag ? std::string(*flag) : std::string{}; + }; + // Shared libraries have never been verified end to end on PE or Mach-O: // every shared-library e2e declares `# requires: elf`, and run_all.sh only // grants that capability on Linux. The non-ELF paths through @@ -1344,6 +1380,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.output = dep.output; lu.soname = dep.target.soname; lu.runtimeAliases = runtime_aliases_for_target(dep.target, naming); + lu.loaderTagFlag = loader_tag_flag(lu.kind); append_package_objects(lu, dep.packageName); append_direct_shared_deps(lu, dep.packageIndex); plan.linkUnits.push_back(std::move(lu)); @@ -1379,6 +1416,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.output = target_output(t, naming); if (!t.main.empty()) lu.entryMain = projectRoot / t.main; } + lu.loaderTagFlag = loader_tag_flag(lu.kind); // Include all module units' objects (they may be needed at runtime via global init). // For binary target, also include main.cpp's object if main is present. diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index f38f4600..7dcc191e 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -32,6 +32,8 @@ import mcpp.toolchain.post_install; import mcpp.toolchain.abi; import mcpp.toolchain.triple; import mcpp.build.plan; +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.runtime_validation; // declared artifact -> identity verdict import mcpp.build.cache_key; import mcpp.build.build_program; import mcpp.build.directives; // directive table: mark / fold_private_tail @@ -5264,6 +5266,15 @@ prepare_build(bool print_fingerprint, stdBmiPath, stdObjectPath, storeRoots); if (!planResult) return std::unexpected(planResult.error()); ctx.plan = std::move(*planResult); + // mcpp#407. Both callers that produce a non-plain graph arrive here the + // same way: dev-dependencies enabled, synthetic test targets appended. The + // resulting `default` line names the test binaries and omits the package's + // own target, and the output directory is shared with plain builds because + // the fingerprint covers neither input. Stamping it on the plan is what + // lets the graph say so about itself. + ctx.plan.graphShape = (includeDevDeps || !extraTargets.empty()) + ? mcpp::build::GraphShape::WithTests + : mcpp::build::GraphShape::Normal; ctx.plan.runtimeBinding = runtimeBindingSnapshot; mcpp::build::merge_runtime_binding_contract( ctx.plan, runtimeBindingSnapshot); @@ -6331,6 +6342,15 @@ prepare_build(bool print_fingerprint, {"abi", artifact.abi}, {"digest", artifact.digest}, {"host_fingerprint", artifact.hostFingerprint}, + // A requirement must land on a THING, and the thing must be + // the one that was declared. mcpp already enforces this for + // the private libc; recording it per artifact makes a stale + // binding visible instead of leaving `providers:` naming + // something nobody checked. + {"identity", std::string( + mcpp::build::runtime_validation::to_string( + mcpp::build::runtime_validation + ::artifact_identity_verdict(artifact)))}, }); } nlohmann::json binding = nlohmann::json::parse( diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm index 24cd3876..02e58676 100644 --- a/src/build/runtime_validation.cppm +++ b/src/build/runtime_validation.cppm @@ -9,7 +9,9 @@ export module mcpp.build.runtime_validation; import std; +import mcpp.build.loader_contract; import mcpp.build.plan; +import mcpp.manifest; import mcpp.libs.json; import mcpp.platform; import mcpp.platform.elf_runtime; @@ -70,6 +72,46 @@ std::optional validated_artifact_snapshot( bool artifact_snapshot_unchanged(const ArtifactSnapshot& snapshot); +// Does a declared runtime artifact actually resolve to the payload it claims? +// +// mcpp ALREADY enforces exactly this for the private libc: `glibc@2.44` +// resolves that one payload, a stale or missing one is an error, and it never +// picks "whichever installed version looks usable". Applying the same rule to +// every declared runtime artifact is consistency, not a new mechanism — and it +// is the whole check the graphics stack was missing, where a provider was +// declared at one version while the symlink on disk still resolved into the +// previous one. Nothing here knows what a driver is. +// +// FOUR-VALUED, and the last two are the point: +// +// Ok resolved real path lies under the declared version +// Mismatch it resolves somewhere else -- the binding is stale +// Missing declared, but nothing is there +// Unverified declared without a version to check against +// +// A two-valued answer would report Unverified as a pass, which is the failure +// mode this whole area keeps producing: "not checked" and "checked and fine" +// must not look the same. +enum class ArtifactVerdict { Ok, Mismatch, Missing, Unverified }; + +std::string_view to_string(ArtifactVerdict verdict); + +ArtifactVerdict artifact_identity_verdict( + const mcpp::manifest::RuntimeArtifact& artifact); + +// Rule E — the loader-tag contract, evaluated on the artifacts this run +// produced, and RECORDED rather than only warned about. +// +// The record is the point. A warning scrolls past; `resolution.json` is the +// machine-readable answer to "what did the last build decide", so a tag +// deviation can be read by CI, by `mcpp why runtime`, and by a test — without +// anyone needing readelf on the box. It is also how "checked and compliant" +// stays distinguishable from "never checked": both look identical when the +// only output is the absence of a warning. +std::vector +check_and_record_loader_tags(const mcpp::build::BuildPlan& plan, + const ArtifactSnapshot& produced); + } // namespace mcpp::build::runtime_validation namespace mcpp::build::runtime_validation { @@ -385,6 +427,109 @@ bool artifact_snapshot_unchanged(const ArtifactSnapshot& snapshot) { }); } +std::string_view to_string(ArtifactVerdict verdict) { + switch (verdict) { + case ArtifactVerdict::Ok: return "ok"; + case ArtifactVerdict::Mismatch: return "mismatch"; + case ArtifactVerdict::Missing: return "missing"; + case ArtifactVerdict::Unverified: return "unverified"; + } + return "unverified"; +} + +ArtifactVerdict artifact_identity_verdict( + const mcpp::manifest::RuntimeArtifact& artifact) { + if (artifact.path.empty()) return ArtifactVerdict::Missing; + + std::error_code ec; + if (!std::filesystem::exists(artifact.path, ec) || ec) + return ArtifactVerdict::Missing; + + // The version the provenance CLAIMS. `:@` is the + // ecosystem's address form; without a version there is nothing to check + // against and the honest answer is Unverified. + auto at = artifact.provenance.rfind('@'); + if (at == std::string::npos || at + 1 >= artifact.provenance.size()) + return ArtifactVerdict::Unverified; + auto version = artifact.provenance.substr(at + 1); + if (version.empty()) return ArtifactVerdict::Unverified; + + // FOLLOW THE SYMLINKS. The declaration is a promise about which payload + // the loader will reach, and a payload directory is normally reached + // through a symlink that some later install can silently repoint. Reading + // the declared path alone would confirm the promise against itself. + auto real = std::filesystem::weakly_canonical(artifact.path, ec); + if (ec) real = artifact.path; + + // A path COMPONENT, not a substring: `0.1.1` must not satisfy `0.1.11`, + // and a version appearing inside a file name is not the store directory + // this is about. + for (auto const& part : real) { + if (part.string() == version) return ArtifactVerdict::Ok; + } + return ArtifactVerdict::Mismatch; +} + +std::vector +check_and_record_loader_tags(const mcpp::build::BuildPlan& plan, + const ArtifactSnapshot& produced) { + namespace loader = mcpp::build::loader; + std::vector findings; + if constexpr (!mcpp::platform::is_linux) return findings; + + for (auto const& [artifact, ignored] : produced) { + (void)ignored; + auto finding = loader::check_artifact(artifact); + if (finding.form == loader::Form::NotElf) continue; + findings.push_back(std::move(finding)); + } + if (findings.empty()) return findings; + + const auto path = plan.outputDir / "resolution.json"; + std::ifstream input(path); + auto resolution = nlohmann::json::parse(input, nullptr, false); + if (resolution.is_discarded() || !resolution.is_object()) return findings; + auto runtime = resolution.find("runtime"); + if (runtime == resolution.end() || !runtime->is_object()) return findings; + + nlohmann::json entries = nlohmann::json::array(); + for (auto const& finding : findings) { + std::error_code ec; + auto relative = std::filesystem::relative( + finding.artifact, plan.outputDir, ec); + entries.push_back({ + {"path", (ec ? finding.artifact : relative) + .lexically_normal().generic_string()}, + {"form", finding.form == loader::Form::Executable + ? "executable" : "shared_library"}, + {"required", loader::to_string(finding.required)}, + {"actual", std::string( + mcpp::platform::elf::to_string(finding.actual))}, + {"status", finding.status == loader::TagFinding::Status::Ok + ? "ok" + : finding.status == loader::TagFinding::Status::Violation + ? "violation" : "not_checked"}, + }); + } + (*runtime)["loader_tags"] = std::move(entries); + + std::error_code ec; + auto tmp = path; + tmp += ".tmp"; + if (std::ofstream output(tmp); output) { + output << resolution.dump(2) << '\n'; + output.close(); + std::filesystem::rename(tmp, path, ec); + if (ec) { + ec.clear(); + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(tmp, path, ec); + } + } + return findings; +} + std::optional latest_stored_verdict(const std::filesystem::path& targetRoot) { std::error_code ec; diff --git a/src/doctor.cppm b/src/doctor.cppm index d4c5705c..b6d5201a 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -586,7 +586,13 @@ int print_stored_runtime_resolution() { auto artifacts = runtime->find("artifacts"); if (artifacts == runtime->end() || !artifacts->is_array() || artifacts->empty()) { - std::println(" (none declared)"); + // NOT "none". A provider can be resolved by name and still have no + // artifact behind it, and `(none declared)` read as a clean bill of + // health for exactly that state — the graphics stack sat there for + // weeks with `providers:` populated and nothing to check. + std::println(" (not declared by the environment — nothing to verify)"); + std::println(" note: a resolved provider with no artifact is UNVERIFIED,"); + std::println(" not verified-good"); } else { for (auto const& artifact : *artifacts) { if (!artifact.is_object()) continue; @@ -594,10 +600,20 @@ int print_stored_runtime_resolution() { if (auto id = artifact.find("provider"); id != artifact.end() && id->is_object()) provider = id->value("canonical", "?"); - std::println(" - {} {} <- {} [{}; abi={}]", + auto identity = artifact.value("identity", "unverified"); + std::println(" - {} {} <- {} [{}; abi={}; identity={}]", artifact.value("role", "?"), artifact.value("path", "?"), provider, artifact.value("provenance", "?"), - artifact.value("abi", "?")); + artifact.value("abi", "?"), identity); + if (identity == "mismatch") { + std::println(" ^ STALE BINDING: this resolves into a " + "different version than the one declared."); + std::println(" The declaration is a promise about which " + "payload the loader reaches;"); + std::println(" a later install repointed it."); + } else if (identity == "missing") { + std::println(" ^ declared, but nothing is at that path"); + } } } diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index f926fc07..f424a193 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -1118,7 +1118,7 @@ std::expected parse_string(std::string_view content, auto const& table = value.as_table(); for (auto const& [key, _] : table) { if (key != "kind" && key != "value" && key != "phase" - && key != "required") { + && key != "required" && key != "discovery") { return std::unexpected(error(origin, std::format( "runtime.requirements[{}] has unsupported key '{}'", index, key))); @@ -1131,6 +1131,7 @@ std::expected parse_string(std::string_view content, "runtime.requirements[{}] kind/value/phase must be strings", index))); } + table_string(table, "discovery", requirement.discovery); if (auto it = table.find("required"); it != table.end()) { if (!it->second.is_bool()) { return std::unexpected(error(origin, std::format( diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 426c13f9..9a73c0a4 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -505,6 +505,18 @@ struct RuntimeRequirement { std::string kind; std::string value; std::string phase = "run"; // link | run + // How the loader finds whatever satisfies this, e.g. "rpath-of-dispatch", + // "json-dir", "glvnd-dispatch". DECLARED, never inferred by mcpp: the + // mechanism is a property of the provider's ecosystem, and inferring it + // from the capability name would put provider-specific knowledge in mcpp + // (`test_runtime_contract` gates exactly that). + // + // It earns its place because the mechanisms are not interchangeable: an + // EGL vendor is found through a JSON file whose library_path is ABSOLUTE, + // while GLX is found through the dispatch library's own DT_RPATH — so + // "copy the directory across" satisfies one and not the other. Empty means + // "not declared", which is reported as unknown rather than guessed. + std::string discovery; PackageId requester; bool required = true; }; diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index d5ae2551..edf227a2 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -1818,6 +1818,8 @@ synthesize_from_xpkg_lua(std::string_view luaContent, if (field == "kind") requirement.kind = entry.read_string(); else if (field == "value") requirement.value = entry.read_string(); else if (field == "phase") requirement.phase = entry.read_string(); + // Declared, not inferred — see RuntimeRequirement. + else if (field == "discovery") requirement.discovery = entry.read_string(); else if (field == "required") { auto raw = entry.read_bareword(); if (raw != "true" && raw != "false") { diff --git a/src/pack/host_requirements.cppm b/src/pack/host_requirements.cppm new file mode 100644 index 00000000..ac86fb88 --- /dev/null +++ b/src/pack/host_requirements.cppm @@ -0,0 +1,117 @@ +// mcpp.pack.host_requirements — what a distributed artifact needs the TARGET +// machine to provide, and how that list is spelled. +// +// WHY THIS EXISTS +// +// "Self-contained" has a floor. A graphics program's driver — the vendor +// user-space half — cannot be bundled: it is version-locked to the running +// kernel module, and for the proprietary stacks redistribution is forbidden +// outright. So the honest output of packaging such a program is not a bundle +// that quietly omits it, but a bundle plus a STATEMENT of what the host must +// supply. +// +// WHY IT IS A MODULE AND NOT A `printf` IN pack.cppm +// +// Two consumers must produce the same list from the same plan: `mcpp pack` +// writes it beside the artifact, and `mcpp publish` projects it into an xpkg +// descriptor's `[runtime].requirements`. Deriving it twice is how the two +// drift, and a drifted host-requirements list is undetectable — both sides +// look reasonable in isolation. +// +// WHY `discovery` IS A COLUMN +// +// Because the mechanisms are not interchangeable. In the graphics stack, for +// instance, four entry points are four independent loader chains found four +// different ways, so a bare capability name is not actionable: +// +// GLX libGLX.so.0's own DT_RPATH points at the vendor directory +// EGL a JSON file in __EGL_VENDOR_LIBRARY_DIRS whose `library_path` +// is an ABSOLUTE path +// GLESv1/v2 glvnd's dispatch, by SONAME +// Vulkan an ICD JSON, independent of the GL stack entirely +// +// Because that JSON holds an absolute path, "copy the directory across" fixes +// one and does nothing for the other. A reader told only the capability cannot +// know that; a reader told the mechanism can. +// +// mcpp does not KNOW any of that, and must not: the value is declared on the +// requirement and carried through. The paragraph above is why the field +// exists, not a table mcpp implements. + +export module mcpp.pack.host_requirements; + +import std; +import mcpp.manifest; + +export namespace mcpp::pack { + +struct HostRequirement { + std::string capability; // e.g. "opengl.glx.driver" + std::string discovery; // declared mechanism; empty = not declared + bool required = true; +}; + +// The file name written at the bundle root. Deliberately not hidden and not an +// extension anyone will double-click: it is meant to be read. +inline constexpr std::string_view kFileName = "HOST-REQUIREMENTS"; + +// NOTE: mcpp does NOT infer the discovery mechanism from the capability name. +// +// That inference is the exact shape `test_runtime_contract` forbids — a branch +// in mcpp's source on a provider's vocabulary. It is also wrong on its merits: +// which mechanism a capability uses is the PROVIDER's property, it changes +// without mcpp, and a stale guess here would be worse than saying nothing. +// The value travels as declared data on the requirement +// (`[[runtime.requirements]] discovery = "..."`), and an undeclared one is +// reported as `unknown` — which is information, not a gap to fill in. + +// THE single derivation. Both `mcpp pack` and `mcpp publish` call this. +// +// A requirement counts when it must be satisfied at RUN time by something +// outside the artifact. Link-phase requirements are consumed during the build +// and say nothing about the target machine. +std::vector +host_requirements_of(const mcpp::manifest::RuntimeConfig& runtime) { + std::vector out; + auto add = [&](std::string capability, std::string discovery, bool required) { + if (capability.empty()) return; + if (std::ranges::any_of(out, [&](auto const& r) { + return r.capability == capability; })) + return; + out.push_back({std::move(capability), std::move(discovery), required}); + }; + for (auto const& req : runtime.requirements) { + if (req.phase != "run") continue; + if (req.kind != "capability") continue; + add(req.value, req.discovery, req.required); + } + // The legacy vector carries the same meaning and is still readable for one + // compatibility train; a package that has not migrated must not silently + // produce an empty list. It has no place to declare a mechanism, so those + // rows say `unknown` — accurately. + for (auto const& capability : runtime.capabilities) + add(capability, /*discovery=*/{}, /*required=*/true); + std::ranges::sort(out, {}, &HostRequirement::capability); + return out; +} + +// Render. One requirement per line, `key=value` fields, so the format can be +// read by a shell one-liner as well as by a program — a manifest nobody can +// grep is a manifest nobody reads. +std::string render(std::span requirements) { + std::string out = + "# These must be provided by the TARGET machine. They are not bundled:\n" + "# a graphics driver's user-space half is version-locked to the running\n" + "# kernel module, and for the proprietary stacks redistribution is not\n" + "# permitted. `discovery` is how the loader finds each one -- they are\n" + "# independent mechanisms, so satisfying one does not satisfy another.\n"; + for (auto const& req : requirements) { + out += std::format("capability={} discovery={}", req.capability, + req.discovery.empty() ? "unknown" : req.discovery); + if (!req.required) out += " required=false"; + out += '\n'; + } + return out; +} + +} // namespace mcpp::pack diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index c41866a6..b86f184c 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -20,7 +20,9 @@ module; export module mcpp.pack; import std; +import mcpp.build.loader_contract; import mcpp.config; +import mcpp.pack.host_requirements; import mcpp.platform; import mcpp.xlings; import mcpp.manifest; @@ -54,6 +56,9 @@ struct Plan { std::vector excludeGlobs; std::vector alsoSkipLibs; std::vector forceBundleLibs; + // What the TARGET machine must provide. Derived once, in make_plan, from + // the same predicate `mcpp publish` uses — see mcpp.pack.host_requirements. + std::vector hostRequirements; }; struct Error { std::string message; }; @@ -172,6 +177,46 @@ make_plan(const mcpp::manifest::Manifest& manifest, p.packageName = manifest.package.name; p.packageVersion = manifest.package.version; p.triple = std::string(triple); + p.hostRequirements = host_requirements_of(manifest.runtimeConfig); + + // A MODE THAT CARRIES ITS OWN libc CANNOT CONSUME A HOST CAPABILITY. + // + // `static` has no libc to share and `self-contained` brings its own, and + // for a library the target must supply the consequence is identical: that + // .so arrives with its own requirements on the HOST's libc, and the + // process does not have that libc. The proprietary graphics stacks are the + // everyday case — they cannot be bundled (kernel lockstep, redistribution + // terms), so they are always the host's, and a self-contained bundle meets + // them with the wrong loader. Measured in both directions as mcpp#392 / + // mcpp#401: a private glibc meeting host-loaded objects dies during + // relocation, before main. + // + // Today both modes link and then fail at run time, or silently fall back + // to software rendering — worse than not building. The predicate is + // DECLARED data, not a list of driver names mcpp would have to maintain + // and would get wrong. + if (!p.hostRequirements.empty() + && (opts.mode == Mode::Static || opts.mode == Mode::BundleAll)) { + std::string names; + for (auto const& req : p.hostRequirements) { + if (!names.empty()) names += ", "; + names += req.capability; + } + return std::unexpected(Error{std::format( + "--mode {} cannot be used by a program that needs the host to " + "provide {}.\n" + " That capability is satisfied at run time by a library on the " + "TARGET machine, and it\n" + " arrives with its own requirements on the target's libc — which " + "a bundle carrying its\n" + " own libc does not have. The result links and then fails at " + "startup, or silently\n" + " degrades (mcpp#392, mcpp#401).\n" + " use: --mode vendored — third-party .so travel with the " + "artifact; libc and the\n" + " capability above both come from the host.", + mode_cli_name(opts.mode), names)}); + } auto distDir = projectRoot / "target" / "dist"; if (opts.output.empty()) { @@ -337,21 +382,36 @@ sandbox_patchelf(const mcpp::config::GlobalConfig& cfg) { return {}; } -// Set RUNPATH (so the dynamic linker finds bundled libs in /lib -// from anywhere). $ORIGIN is the directory of the binary at load time, -// so $ORIGIN/../lib is the bundled lib dir relative to /bin/. +// Set the search path (so the dynamic linker finds bundled libs in +// /lib from anywhere). $ORIGIN is the directory of the object at load +// time, so $ORIGIN/../lib is the bundled lib dir relative to /bin/ +// and $ORIGIN is it relative to /lib/. +// +// WHICH TAG. `patchelf --set-rpath` writes DT_RUNPATH by default, and for an +// EXECUTABLE that is wrong: DT_RUNPATH is consulted only for the object +// carrying it, so a packaged program cannot reach its bundled libraries +// through a dlopen() performed on its behalf by something else. That is the +// same defect as the link-time one (mcpp.build.loader_contract), one layer +// later, and it is why this takes the form from the shared contract instead of +// deciding for itself. Libraries keep DT_RUNPATH — forcing DT_RPATH on a +// library pushes its search path into every lookup below it. std::expected -set_runpath(const std::filesystem::path& binary, - std::string_view rpath, - const std::filesystem::path& patchelf) +set_search_path(const std::filesystem::path& object, + std::string_view rpath, + mcpp::build::loader::Form form, + const std::filesystem::path& patchelf) { if (patchelf.empty() || !std::filesystem::exists(patchelf)) return std::unexpected("patchelf not available in sandbox"); - auto cmd = std::format("'{}' --set-rpath '{}' '{}'", - patchelf.string(), rpath, binary.string()); + std::string extra; + if (auto flag = mcpp::build::loader::patchelf_flag( + mcpp::build::loader::required_tag(form))) + extra = std::format(" {}", *flag); + auto cmd = std::format("'{}' --set-rpath '{}'{} '{}'", + patchelf.string(), rpath, extra, object.string()); int rc = run_silent(cmd); if (rc != 0) return std::unexpected(std::format( - "patchelf --set-rpath failed (exit {}): {}", rc, binary.string())); + "patchelf --set-rpath failed (exit {}): {}", rc, object.string())); return {}; } @@ -611,6 +671,22 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) copy_if_exists(plan.projectRoot / "README.md", plan.stagingRoot); copy_if_exists(plan.projectRoot / "LICENSE", plan.stagingRoot); + // 3b. What the TARGET must provide. + // + // Only written when there is something to say — an empty file would be + // read as "nothing is needed", which is a claim, and for most programs the + // absence of the file is the honest form of it. When it IS written it is + // load-bearing: a bundle that omits the driver without saying so is a + // bundle that fails on the user's machine with no way to find out why. + if (!plan.hostRequirements.empty()) { + std::ofstream out(plan.stagingRoot / std::filesystem::path(kFileName)); + if (!out) return std::unexpected(Error{std::format( + "cannot write {} into the bundle", kFileName)}); + out << render(plan.hostRequirements); + if (!out) return std::unexpected(Error{std::format( + "failed writing {}", kFileName)}); + } + // 4. Library bundling for non-static modes. // // BundleProject (default) — drop all manylinux-allowed system libs @@ -645,15 +721,53 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg) auto patchelf = sandbox_patchelf(cfg); if (!patchelf.empty()) { - // RUNPATH: point at bundled libs (or clear if none). + // Search path: point at bundled libs (or clear if none). // non-empty bundle → "$ORIGIN/../lib" so the binary finds them // empty bundle → clear the original dev-sandbox RUNPATH // (~/.mcpp/registry/... doesn't exist on // a user's target machine) const char* rpath = toBundle.empty() ? "" : "$ORIGIN/../lib"; - if (auto r = set_runpath(bundledBinary, rpath, patchelf); !r) + if (auto r = set_search_path(bundledBinary, rpath, + mcpp::build::loader::Form::Executable, + patchelf); !r) return std::unexpected(Error{r.error()}); + // EVERY BUNDLED LIBRARY, not just the executable. + // + // A bundled .so keeps whatever RUNPATH it was built with, and on + // this ecosystem that is a set of ABSOLUTE paths into the BUILD + // MACHINE's xlings store. Measured on a graphics artifact: + // + // /xim-x-glibc/2.44/lib64 : /xim-x-gcc/16.1.0/lib64 + // : /compat-x-glx-runtime/…/lib : $ORIGIN + // + // Those directories do not exist on the target, and worse, if the + // target happens to be another developer's machine they exist with + // DIFFERENT contents. "Depends on the xlings ecosystem" would be a + // design choice; "depends on this one machine's store" is a defect, + // and it is invisible because the bundle runs fine where it was + // built. $ORIGIN is where its siblings actually are. + // + // EXCEPT THE DYNAMIC LOADER. `ld-linux-*.so` is not a shared + // library that gets searched for; it is the program that DOES the + // searching, and it is loaded by the kernel from an absolute path + // (PT_INTERP, or `run.sh`'s explicit invocation). Rewriting its + // own search path is meaningless, and patchelf rewriting it is + // destructive: Mode `self-contained` then segfaults before main, + // because the thing that was supposed to resolve the process's + // libraries no longer loads. Found by `30_pack_modes`. + auto loaderSoname = find_loader_soname(toBundle); + for (auto const& dep : toBundle) { + if (!loaderSoname.empty() && dep.soname == loaderSoname) continue; + auto staged = plan.stagingRoot / "lib" / dep.soname; + std::error_code ec; + if (!std::filesystem::is_regular_file(staged, ec)) continue; + if (auto r = set_search_path( + staged, "$ORIGIN", + mcpp::build::loader::Form::SharedLibrary, patchelf); !r) + return std::unexpected(Error{r.error()}); + } + // PT_INTERP handling differs by mode: // BundleProject → repoint to the target distro's loader // (LSB layout: /lib64/ on x86_64, diff --git a/src/platform/elf_runtime.cppm b/src/platform/elf_runtime.cppm index 3e63535f..053257b5 100644 --- a/src/platform/elf_runtime.cppm +++ b/src/platform/elf_runtime.cppm @@ -13,17 +13,55 @@ import mcpp.platform.runtime_binding; export namespace mcpp::platform::elf { +// WHICH dynamic tag carried the search path, kept separately from the path +// list itself. +// +// `runpaths` below answers "where will the loader look"; this answers "how far +// does that reach". They are different questions and only the first one used to +// be recorded: both tags were folded into `runpaths` and the tag was dropped. +// +// DT_RUNPATH is consulted only for the object that carries it and for the +// dlopen() that object performs ITSELF. DT_RPATH is consulted for every dlopen +// anywhere in the process, at any depth. A GL program reaches its driver +// through three to four dlopen() calls that IT does not make -- libGLX.so.0 +// makes them -- so with DT_RUNPATH the path is present and unreachable. +// Measured: same paths, tag flipped, egl/gles2/egl-surfaceless move from +// llvmpipe to the GPU. +// +// `Both` is a real state and must not silently read as `Rpath`: glibc ignores +// DT_RPATH whenever DT_RUNPATH is also present, and DT_RPATH-first is the +// common layout, so a reader that stops at the first hit reports the opposite +// of what the loader will do. +enum class SearchPathTag { None, Rpath, Runpath, Both }; + +std::string_view to_string(SearchPathTag tag) { + switch (tag) { + case SearchPathTag::None: return "none"; + case SearchPathTag::Rpath: return "DT_RPATH"; + case SearchPathTag::Runpath: return "DT_RUNPATH"; + case SearchPathTag::Both: return "DT_RPATH+DT_RUNPATH"; + } + return "none"; +} + struct ElfRuntimeFacts { std::filesystem::path artifact; std::uint16_t elfType = 0; std::string interp; std::string soname; std::vector runpaths; + SearchPathTag searchPathTag = SearchPathTag::None; std::vector needed; std::vector requiredGlibcVersions; std::vector definedGlibcVersions; std::filesystem::path resolvedLibc; std::vector resolvedObjects; + + // "Is this an executable" is PT_INTERP, not ET_EXEC: a PIE executable is + // ET_DYN and therefore indistinguishable from a shared library by type + // alone. The loader-tag contract splits exactly along this line, so the + // predicate lives with the facts rather than in each caller. + bool is_executable() const { return !interp.empty(); } }; struct RuntimeResolution { @@ -397,6 +435,18 @@ inspect_elf_runtime(const std::filesystem::path& artifact) { tag == detail::kDtRunpath ? modernRunpaths : legacyRpaths, *path); } } + // Record WHICH tag was present before collapsing the two lists -- the + // collapse below is lossy and the lost bit is the one the loader-tag + // contract is about (see SearchPathTag). Presence is keyed on the tag + // having been seen, so a DT_RPATH holding an empty string still counts as + // present: the loader saw the tag either way. + if (!legacyRpaths.empty() && !modernRunpaths.empty()) + out.searchPathTag = SearchPathTag::Both; + else if (!modernRunpaths.empty()) + out.searchPathTag = SearchPathTag::Runpath; + else if (!legacyRpaths.empty()) + out.searchPathTag = SearchPathTag::Rpath; + // glibc ignores legacy DT_RPATH when DT_RUNPATH exists. Preserve that // effective distinction while exposing one ordered search-path vector. out.runpaths = modernRunpaths.empty() diff --git a/src/pm/publisher.cppm b/src/pm/publisher.cppm index 4efda26b..d39e0e99 100644 --- a/src/pm/publisher.cppm +++ b/src/pm/publisher.cppm @@ -7,6 +7,7 @@ module; export module mcpp.pm.publisher; import std; +import mcpp.pack.host_requirements; // J: one derivation, two projections import mcpp.manifest; import mcpp.modgraph.graph; import mcpp.platform; @@ -190,6 +191,30 @@ std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, } out += " },\n"; + // What the TARGET machine must provide. + // + // THE SAME DERIVATION `mcpp pack` USES. A tarball can only DESCRIBE these + // (its HOST-REQUIREMENTS file); a descriptor can have them RESOLVED, by + // the xlings on the machine that installs the package. Two projections of + // one fact — so they come from one function. Deriving them separately is + // how they drift, and a drifted list is undetectable: each side looks + // reasonable on its own. + if (auto hostReqs = mcpp::pack::host_requirements_of(manifest.runtimeConfig); + !hostReqs.empty()) { + out += " runtime = {\n"; + out += " requirements = {\n"; + for (auto const& req : hostReqs) { + out += std::format( + " {{ kind = \"capability\", value = {}, " + "phase = \"run\", required = {}, discovery = {} }},\n", + lua_escape(req.capability), + req.required ? "true" : "false", + lua_escape(req.discovery)); + } + out += " },\n"; + out += " },\n"; + } + out += " manifest = \"mcpp.toml\",\n"; out += " },\n"; out += "}\n"; diff --git a/src/version.cppm b/src/version.cppm index d5cdc466..887f49ea 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.10.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.10.2"; } // namespace mcpp diff --git a/src/xlings.cppm b/src/xlings.cppm index dfde913d..21e06b93 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -44,7 +44,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.9.2"; + inline constexpr std::string_view kXlingsVersion = "2026.8.10.4"; inline constexpr std::string_view kNasmVersion = "3.02"; } diff --git a/tests/e2e/212_cached_dep_std_is_ordered.sh b/tests/e2e/212_cached_dep_std_is_ordered.sh new file mode 100755 index 00000000..9736b7c4 --- /dev/null +++ b/tests/e2e/212_cached_dep_std_is_ordered.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# requires: gcc fresh-sandbox +# 212_cached_dep_std_is_ordered.sh — mcpp#405. +# +# A dependency whose modules `import std`, consumed by a project that does NOT, +# must build on a cache HIT as well as on a miss. +# +# On a miss the dependency is compiled locally and that puts `gcm.cache/std.gcm` +# in the graph as a real prerequisite of its compile edges. On a HIT those +# compile edges are replaced by stage edges, and the std stage edge — emitted +# earlier, outside the loop that fills `_mcpp_staged_cache` — is left with zero +# consumers. ninja never runs it, and the restored BMI reports +# +# std: failed to read compiled module: No such file or directory +# : failed to read compiled module: Bad import dependency +# +# which names neither the cache nor std's absence from the graph. +# +# WHY THE FIRST PROJECT ALWAYS WORKED: miss and hit are different code paths. +# "It used to work and now it doesn't" is what a per-package cache turning warm +# looks like from the outside; the version never mattered. +# +# TWO THINGS THIS TEST MUST NOT DO +# +# * It must NOT delete build outputs between builds. A missing artifact makes +# ninja fail in the shape of a stale graph, the fast path falls back to a +# full prepare, and the defect is covered — the unfixed binary goes green. +# * It must NOT assert only on the exit code. A machine that happens to have +# `gcm.cache/std.gcm` already staged passes for the wrong reason, so the +# graph itself is asserted too. +set -e +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +INDEX_DIR="$TMP/local-index" +INDEX_DIR_HOST="$(host_path "$INDEX_DIR")" +mkdir -p "$INDEX_DIR/pkgs/s" +cat > "$INDEX_DIR/pkgs/s/stdlib-dep.lua" <<'EOF' +package = { + spec = "1", + name = "stdlib-dep", + description = "A dependency whose module imports std", + licenses = {"MIT"}, + type = "package", + xpm = { + linux = { + ["1.0.0"] = { + url = "https://example.invalid/stdlib-dep-1.0.0.tar.gz", + sha256 = "0000000000000000000000000000000000000000000000000000000000000000", + }, + }, + }, + mcpp = { + language = "c++23", + import_std = true, + sources = { "src/**/*.cppm" }, + targets = { ["stdlib-dep"] = { kind = "lib" } }, + deps = {}, + }, +} +EOF + +# `make_project ` — the consumer deliberately does NOT `import std`. +# That one line is the whole reproduction: with it, the consumer's own compile +# edge names gcm.cache/std.gcm and the stage edge acquires a consumer. +make_project() { + local dir="$1" name="$2" + mkdir -p "$TMP/$dir/src" + mkdir -p "$TMP/$dir/.mcpp/.xlings/data/xpkgs/local-dev.stdlib-dep/1.0.0/src" + cat > "$TMP/$dir/.mcpp/.xlings/data/xpkgs/local-dev.stdlib-dep/1.0.0/src/lib.cppm" <<'EOF' +export module stdlib.dep; +import std; +export std::string dep_greeting() { return std::string("42"); } +EOF + cat > "$TMP/$dir/src/main.cpp" <<'EOF' +import stdlib.dep; +extern "C" int puts(const char*); +int main() { puts(dep_greeting().c_str()); return 0; } +EOF + cat > "$TMP/$dir/mcpp.toml" < build.log 2>&1 || { + echo "FAIL: cold build (cache miss) failed — fixture problem, not #405" + cat build.log + exit 1 +} + +# ── project two: cache HIT. This is the one that used to fail. ─────────────── +make_project projhit projhit +cd "$TMP/projhit" +if ! "$MCPP" build > build.log 2>&1; then + echo "FAIL: second project (cache hit) did not build" + if grep -q 'Bad import dependency\|std.gcm' build.log; then + echo " this is mcpp#405: the restored BMI's std edge is not in the graph" + fi + cat build.log + exit 1 +fi + +N="$(find_ninja "$TMP/projhit")" +[[ -n "$N" ]] || { echo "FAIL: projhit has no build.ninja"; exit 1; } + +# The hit actually happened — otherwise the assertion below proves nothing. +grep -qE 'Cached local-dev\.stdlib-dep v1\.0\.0 \([0-9]+ unit' build.log || { + echo "FAIL: the second project did not hit the cache, so #405 was not exercised" + cat build.log + exit 1 +} + +# THE graph assertion. The std BMI stage edge must be reachable, and the +# aggregate every non-staged edge already depends on is where it becomes so. +phony="$(grep -E '^build _mcpp_staged_cache : phony' "$N" || true)" +[[ -n "$phony" ]] || { + echo "FAIL: no _mcpp_staged_cache aggregate, so the dependency was not staged" + grep -n 'stdlib-dep' "$N" | head + exit 1 +} +case "$phony" in + *std.gcm*) ;; + *) + echo "FAIL: the staged-cache aggregate does not include the std BMI" + echo " $phony" + echo " nothing else names gcm.cache/std.gcm, so ninja will never stage it" + exit 1 + ;; +esac + +# And it has to run. Nothing is deleted anywhere in this file. +./target/*/*/bin/projhit > run.log 2>&1 || { cat run.log; exit 1; } +grep -q '^42$' run.log || { + echo "FAIL: staged artifacts produced wrong output" + cat run.log + exit 1 +} + +echo "PASS: a cached dependency's transitive std BMI is staged for a consumer that does not import std" diff --git a/tests/e2e/213_build_after_test_is_not_the_test_graph.sh b/tests/e2e/213_build_after_test_is_not_the_test_graph.sh new file mode 100755 index 00000000..b7ad36db --- /dev/null +++ b/tests/e2e/213_build_after_test_is_not_the_test_graph.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# requires: gcc +# 213_build_after_test_is_not_the_test_graph.sh — mcpp#407. +# +# `mcpp build`, `mcpp test` and `mcpp build --configure-only` all write +# `target///build.ninja`, and they land in the same directory +# because the fingerprint covers neither dev-dependencies nor test targets. +# A test-mode plan's `default` line names the TEST binaries and does not +# contain the package's own target at all. +# +# The fast path used to check build.ninja's mtime against the SOURCES and +# nothing else, so: +# +# mcpp build → default bin/ +# mcpp test → default bin/ (same file, rewritten) +# mcpp build → Finished in 0.00s (asked ninja for bin/) +# +# reported success for a build that never linked the target. And because +# `tests/` is not in the source sweep, breaking a test file made a plain +# `mcpp build` FAIL with src/ untouched. +# +# DO NOT DELETE ARTIFACTS ANYWHERE IN THIS FILE. A missing output makes ninja +# fail in the shape of a stale graph, the fast path falls back to a full +# prepare, and an unfixed binary goes green. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +mkdir -p "$TMP/proj/src" "$TMP/proj/tests" +cd "$TMP/proj" + +cat > mcpp.toml <<'EOF' +[package] +name = "tst" +version = "0.1.0" +EOF +cat > src/main.cpp <<'EOF' +int main() { return 0; } +EOF +cat > tests/smoke.cpp <<'EOF' +int main() { return 0; } +EOF + +find_ninja() { find "$TMP/proj/target" -name build.ninja | head -1; } +default_line() { grep -E '^default ' "$1" | head -1; } + +# ── 1. plain build ────────────────────────────────────────────────────────── +"$MCPP" build > b1.log 2>&1 || { cat b1.log; exit 1; } +N="$(find_ninja)" +[[ -n "$N" ]] || { echo "FAIL: no build.ninja after the first build"; exit 1; } + +BIN="$(dirname "$(dirname "$N")")" # unused guard; the real path is globbed below +target_bin() { ls target/*/*/bin/tst 2>/dev/null | head -1; } + +[[ -n "$(target_bin)" ]] || { + echo "FAIL: plain build produced no bin/tst" + cat b1.log + exit 1 +} + +# The graph must say what it is. Without this line there is nothing for the +# fast path to check, and the rest of this test would pass for the wrong +# reason on a binary that simply always does a full prepare. +grep -q '^# mcpp:graph=normal' "$N" || { + echo "FAIL: a plain build's build.ninja does not declare graph=normal" + head -3 "$N" + exit 1 +} + +# ── 2. mcpp test rewrites the same file with the test graph ───────────────── +"$MCPP" test > t1.log 2>&1 || { cat t1.log; exit 1; } +N2="$(find_ninja)" +[[ "$N2" == "$N" ]] || { + echo "NOTE: test used a different build.ninja ($N2); #407 needs the shared one" +} +grep -q '^# mcpp:graph=test' "$N2" || { + echo "FAIL: mcpp test's build.ninja does not declare graph=test" + head -3 "$N2" + exit 1 +} + +# ── 3. THE assertion: a plain build after a test must not replay that graph ─ +# +# NOTHING IS TOUCHED HERE. Not the sources, not the outputs. An earlier draft +# ran `touch src/main.cpp` to make the relink observable — which invalidates +# the fast path by mtime and makes the test pass on an unfixed binary. The +# defect only exists while the fast path is ELIGIBLE, so the test has to leave +# every input alone and read the graph instead. +"$MCPP" build > b2.log 2>&1 || { + echo "FAIL: plain build after mcpp test failed" + cat b2.log + exit 1 +} +N3="$(find_ninja)" +grep -q '^# mcpp:graph=normal' "$N3" || { + echo "FAIL: plain build replayed the test graph (build.ninja still says test)" + echo " this is mcpp#407: the fast path never checked what graph it had" + head -3 "$N3" + cat b2.log + exit 1 +} +case "$(default_line "$N3")" in + *bin/tst*) ;; + *) + echo "FAIL: default line still names the test graph: $(default_line "$N3")" + exit 1 + ;; +esac +[[ -n "$(target_bin)" ]] || { + echo "FAIL: bin/tst is gone after a plain build" + exit 1 +} + +# ── 4. a broken TEST file must not fail a plain build ─────────────────────── +# src/ is untouched here. Before the fix this failed with a compile error in +# tests/smoke.cpp, from `mcpp build`. +cat > tests/smoke.cpp <<'EOF' +this is not valid C++ at all +EOF +"$MCPP" build > b3.log 2>&1 || { + echo "FAIL: a broken tests/*.cpp failed a plain mcpp build" + cat b3.log + exit 1 +} + +echo "PASS: a plain build never replays the test graph, and tests/ cannot break it" diff --git a/tests/e2e/214_executable_carries_dt_rpath.sh b/tests/e2e/214_executable_carries_dt_rpath.sh new file mode 100755 index 00000000..383c3500 --- /dev/null +++ b/tests/e2e/214_executable_carries_dt_rpath.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# requires: gcc elf python3 +# 214_executable_carries_dt_rpath.sh — the loader-tag contract. +# +# executable DT_RPATH +# shared library DT_RUNPATH +# +# and both halves are measured, not stylistic. +# +# DT_RUNPATH is consulted only for the object carrying it and for the dlopen() +# that object performs ITSELF. DT_RPATH is consulted for every dlopen anywhere +# in the process. A GL program reaches its driver through three to four +# dlopen() calls it does not make — libGLX.so.0 makes them — so an executable +# tagged DT_RUNPATH has the right path and cannot reach through it: same paths, +# tag flipped, egl/gles2/egl-surfaceless move from llvmpipe to the GPU. +# +# The other half runs the opposite way: forcing DT_RPATH onto a LIBRARY pushes +# its search path into every lookup below it and eglInitialize fails outright +# (openxlings/xlings#593). So this is a split, and both sides are asserted. +# +# WHY THIS TEST ASSERTS THE DEFAULT FIRST +# +# Every linker mcpp targets defaults to --enable-new-dtags today. If that ever +# changes, an assertion of "the executable has DT_RPATH" would keep passing +# while the flag that produces it had been deleted — a test that cannot fail is +# indistinguishable from one that is not running. So step 0 builds WITHOUT the +# contract and requires DT_RUNPATH: the test states its own premise. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +# ── tag reader: no external tool ──────────────────────────────────────────── +# The binutils in a sandbox home is not reliably present (and on at least one +# real machine its shims pointed at a deleted directory), so the dynamic +# section is parsed directly. Reads the WHOLE section, not the first hit: with +# both tags present the loader ignores DT_RPATH, and DT_RPATH-first is the +# common layout, so a first-hit reader reports the opposite of the truth. +read_tag() { +python3 - "$1" <<'PY' +import struct, sys +d = open(sys.argv[1], 'rb').read() +if d[:4] != b'\x7fELF' or d[4] != 2: + print("NOT-ELF64"); raise SystemExit +phoff, = struct.unpack_from(' mcpp.toml <<'EOF' +[package] +name = "tags" +version = "0.1.0" + +[targets.taglib] +kind = "shared" + +[targets.tagbin] +kind = "bin" +main = "src/main.cpp" +EOF +cat > src/lib.cppm <<'EOF' +export module tags.lib; +export int tag_value() { return 7; } +EOF +cat > src/main.cpp <<'EOF' +import tags.lib; +int main() { return tag_value() == 7 ? 0 : 1; } +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; exit 1; } + +# ── step 0: the flag must be where the contract says, and only there ──────── +# +# THE SHARED LIBRARY IS THE CONTROL GROUP. It is linked by the same driver, +# with the same ldflags, and deliberately WITHOUT the contract flag — so its +# tag is this linker's default. That makes the premise self-checking without a +# probe binary: if the default ever becomes DT_RPATH, the library assertion +# below fails and says so, instead of the executable assertion passing for a +# reason that has nothing to do with mcpp. +# +# Reading it out of the graph as well as off the binary matters because they +# fail differently: a missing flag is a regression in mcpp, while a flag that +# is present but produced the wrong tag is a regression in the toolchain. +NINJA="$(ls target/*/*/build.ninja | head -1)" +[[ -n "$NINJA" ]] || { echo "FAIL: no build.ninja"; exit 1; } + +bin_edge="$(grep -A4 -E '^build bin/tagbin *: *cxx_link' "$NINJA" | grep unit_ldflags || true)" +lib_edge="$(grep -A4 -E '^build bin/libtaglib\.so.* : *cxx_shared' "$NINJA" | grep unit_ldflags || true)" +case "$bin_edge" in + *--disable-new-dtags*) ;; + *) + echo "FAIL: the executable's link edge carries no loader-tag flag" + echo " $bin_edge" + exit 1 + ;; +esac +case "$lib_edge" in + *--disable-new-dtags*) + echo "FAIL: the shared library's link edge carries the executable's flag" + echo " forcing DT_RPATH on a library breaks eglInitialize (xlings#593)" + echo " $lib_edge" + exit 1 + ;; +esac + +BIN="$(ls target/*/*/bin/tagbin 2>/dev/null | head -1)" +LIB="$(ls target/*/*/bin/libtaglib.so 2>/dev/null | head -1)" +[[ -n "$BIN" ]] || { echo "FAIL: no executable produced"; ls -R target | head -40; exit 1; } +[[ -n "$LIB" ]] || { echo "FAIL: no shared library produced"; ls -R target | head -40; exit 1; } + +bin_tag="$(read_tag "$BIN")" +lib_tag="$(read_tag "$LIB")" +echo "executable: $bin_tag" +echo "library: $lib_tag" + +case "$bin_tag" in + "executable RPATH") ;; + *) + echo "FAIL: executable must carry DT_RPATH, got: $bin_tag" + echo " a search path under DT_RUNPATH is unreachable from a dlopen()" + echo " performed by another object — which is every dlopen in the" + echo " graphics stack" + grep -n 'unit_ldflags' target/*/*/build.ninja | head + exit 1 + ;; +esac +case "$lib_tag" in + "shared_library RUNPATH"|"shared_library NONE") ;; + "shared_library RPATH"|"shared_library BOTH") + echo "FAIL: shared library came out as $lib_tag" + echo " Its link edge carries NO loader-tag flag (asserted above), so" + echo " this is the linker's DEFAULT — and if the default is now" + echo " DT_RPATH, the executable assertion below is passing for a" + echo " reason unrelated to mcpp. Re-derive the contract." + echo " Also: forcing DT_RPATH on a library pushes its search path" + echo " into every lookup below it (openxlings/xlings#593)." + exit 1 + ;; + *) + echo "FAIL: unexpected library tag: $lib_tag" + exit 1 + ;; +esac + +# ── the finding must be recorded, not just true ───────────────────────────── +# A check whose only output is silence is indistinguishable from a check that +# never ran. resolution.json is where the answer lives. +RES="$(ls target/*/*/resolution.json 2>/dev/null | head -1)" +[[ -n "$RES" ]] || { echo "FAIL: no resolution.json"; exit 1; } +grep -q '"loader_tags"' "$RES" || { + echo "FAIL: resolution.json records no loader_tags — rule E did not run" + exit 1 +} +if grep -q '"status": *"violation"' "$RES"; then + echo "FAIL: rule E reported a violation on mcpp's own output" + python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1]))['runtime']['loader_tags'],indent=1))" "$RES" + exit 1 +fi +# `ok` for both artifacts, not merely "no violation": absence of a violation is +# also what a check that silently did nothing produces. +python3 - "$RES" <<'PY2' || exit 1 +import json, sys +tags = json.load(open(sys.argv[1]))["runtime"]["loader_tags"] +by_form = {t["form"]: t for t in tags} +for form, want in (("executable", "DT_RPATH"), ("shared_library", "DT_RUNPATH")): + t = by_form.get(form) + if t is None: + print(f"FAIL: rule E recorded no {form}"); raise SystemExit(1) + if t["status"] != "ok" or t["required"] != want: + print(f"FAIL: {form}: {t}"); raise SystemExit(1) +print("rule E: executable=%s library=%s" % ( + by_form["executable"]["actual"], by_form["shared_library"]["actual"])) +PY2 + +# and it has to run +"$MCPP" run tagbin > run.log 2>&1 || { cat run.log; exit 1; } + +echo "PASS: executables carry DT_RPATH, shared libraries keep DT_RUNPATH, and rule E recorded it" diff --git a/tests/e2e/215_pack_has_no_build_machine_paths.sh b/tests/e2e/215_pack_has_no_build_machine_paths.sh new file mode 100755 index 00000000..243e853c --- /dev/null +++ b/tests/e2e/215_pack_has_no_build_machine_paths.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# requires: pack patchelf elf python3 +# 215_pack_has_no_build_machine_paths.sh — a bundle must not depend on the +# machine that built it, and its executable must carry DT_RPATH. +# +# TWO DEFECTS, ONE FILE. +# +# 1. Only the main executable's search path was rewritten. Every bundled .so +# kept the RUNPATH it was LINKED with, and in this ecosystem that is a list +# of absolute paths into the build machine's xlings store: +# +# /xim-x-glibc/2.44/lib64 : /xim-x-gcc/16.1.0/lib64 +# : /compat-x-glx-runtime/…/lib : $ORIGIN +# +# "Depends on the xlings ecosystem" would be a design choice; "depends on +# THIS machine's store" is a defect — and it is invisible, because the +# bundle runs perfectly where it was built. +# +# 2. `patchelf --set-rpath` writes DT_RUNPATH by default. For an executable +# that is the graphics defect one layer later: DT_RUNPATH is not consulted +# for a dlopen() performed by another object, and every dlopen in the GL +# stack is performed by another object. +# +# The assertion sweeps EVERY ELF in the bundle, not just the binary — the +# first defect lived precisely in the ones nobody looked at. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +export MCPP_HOME=$HOME/.mcpp + +read_tag() { +python3 - "$1" <<'PY' +import struct, sys +d = open(sys.argv[1], 'rb').read() +if d[:4] != b'\x7fELF' or d[4] != 2: + print("NOT-ELF64"); raise SystemExit +phoff, = struct.unpack_from(' /dev/null +cd bundled +cat >> mcpp.toml <<'EOF' + +[pack.bundle-project] +force_bundle = ["libgcc_s.so.1"] +EOF + +"$MCPP" pack > "$TMP/pack.log" 2>&1 || { cat "$TMP/pack.log"; exit 1; } + +TARBALL="$(ls target/dist/*.tar.gz | head -1)" +[[ -n "$TARBALL" ]] || { echo "FAIL: no tarball"; cat "$TMP/pack.log"; exit 1; } +mkdir -p "$TMP/x" && tar -xzf "$TARBALL" -C "$TMP/x" + +# The build machine's store prefix. Everything under it is machine-local by +# construction, so its presence in a shipped artifact is the defect itself. +STORE="$(cd "$MCPP_HOME/registry/data/xpkgs" 2>/dev/null && pwd || true)" + +fail=0 +found_exe=0 +found_lib=0 +while IFS= read -r obj; do + head -c4 "$obj" 2>/dev/null | grep -q $'\x7fELF' || continue + read -r form tag paths <<<"$(read_tag "$obj")" + [[ "$form" == "NOT-ELF64" ]] && continue + printf ' %-40s %-14s %s %s\n' "${obj#$TMP/x/}" "$form" "$tag" "$paths" + + if [[ -n "$STORE" && "$paths" == *"$STORE"* ]]; then + echo "FAIL: bundled object still points at the BUILD MACHINE's store" + echo " $obj" + echo " $paths" + fail=1 + fi + if [[ "$form" == "executable" ]]; then + found_exe=1 + case "$tag" in + RPATH|NONE) ;; + *) + echo "FAIL: bundled executable carries $tag, contract requires DT_RPATH" + echo " a bundled vendor library cannot be reached through DT_RUNPATH" + echo " when the dlopen is performed by something else" + fail=1 + ;; + esac + else + found_lib=1 + case "$tag" in + RUNPATH|NONE) ;; + *) + echo "FAIL: bundled library carries $tag, contract requires DT_RUNPATH" + fail=1 + ;; + esac + fi +done < <(find "$TMP/x" -type f) + +[[ "$found_exe" == "1" ]] || { echo "FAIL: swept no executable — the test proved nothing"; exit 1; } +# The load-bearing coverage guard: the defect this file exists for was in the +# BUNDLED LIBRARIES, so a run that found none has not tested it. +[[ "$found_lib" == "1" ]] || { + echo "FAIL: the bundle contains no shared library, so the defect this test" + echo " targets (bundled .so keeping build-machine paths) was not exercised" + find "$TMP/x" -type f | sed "s|$TMP/x/||" + exit 1 +} +[[ "$fail" == "0" ]] || exit 1 + +# A program with no host capabilities must NOT get a requirements file: an +# empty one would be read as a claim that nothing is needed. +if find "$TMP/x" -name HOST-REQUIREMENTS | grep -q .; then + echo "FAIL: a program with no host capabilities got a HOST-REQUIREMENTS file" + exit 1 +fi + +echo "PASS: no build-machine paths survive packing, and every object carries its contract tag" diff --git a/tests/e2e/216_selfcontained_refuses_host_capability.sh b/tests/e2e/216_selfcontained_refuses_host_capability.sh new file mode 100755 index 00000000..e50c3e41 --- /dev/null +++ b/tests/e2e/216_selfcontained_refuses_host_capability.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# requires: pack patchelf elf +# 216_selfcontained_refuses_host_capability.sh — a bundle that carries its own +# libc cannot consume a capability the host must satisfy. +# +# WHY BOTH MODES +# +# `static` has no libc to share; `self-contained` brings its own. For a library +# the target must supply, the consequence is the same: that .so arrives with +# its own requirements on the HOST's libc, and the process does not have that +# libc. Measured in both directions as mcpp#392 / mcpp#401 — a private glibc +# meeting host-loaded objects dies during relocation, before main. +# +# The graphics drivers are the everyday case: they cannot be bundled at all +# (version-locked to the running kernel module; redistribution not permitted), +# so they are always the host's. +# +# Today both modes link and then fail at startup, or silently fall back to +# software rendering — which is worse than not building, because nothing says +# so. +# +# THE REFUSAL MUST NAME A WAY FORWARD. A check that only says no is a check +# users route around; `vendored` is the mode that actually works here, and the +# test asserts the message says so. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +export MCPP_HOME=$HOME/.mcpp + +cd "$TMP" +"$MCPP" new gfxapp > /dev/null +cd gfxapp +cat >> mcpp.toml <<'EOF' + +[[runtime.requirements]] +kind = "capability" +value = "opengl.glx.driver" +phase = "run" +# DECLARED by the package, not inferred by mcpp: which mechanism a capability +# uses is the provider's property. GLX is reached through the dispatch +# library's own DT_RPATH; EGL through a JSON file holding an ABSOLUTE path — so +# the two are not interchangeable, and a row without this is not actionable. +discovery = "rpath-of-dispatch" +EOF + +# ── the two modes that carry their own libc must refuse ───────────────────── +for mode in self-contained static; do + if "$MCPP" pack --mode "$mode" > "$TMP/$mode.log" 2>&1; then + echo "FAIL: --mode $mode packed a program that needs a host capability" + echo " it would link and then fail at startup on the user's machine" + tail -20 "$TMP/$mode.log" + exit 1 + fi + grep -q 'opengl.glx.driver' "$TMP/$mode.log" || { + echo "FAIL: --mode $mode refused without naming the capability" + cat "$TMP/$mode.log" + exit 1 + } + grep -q 'vendored' "$TMP/$mode.log" || { + echo "FAIL: --mode $mode refused without naming a way forward" + echo " a refusal with no next step is a refusal users disable" + cat "$TMP/$mode.log" + exit 1 + } + echo " $mode: refused, named the capability and the alternative" +done + +# ── vendored must WORK, and must state what the host has to provide ───────── +"$MCPP" pack --mode vendored > "$TMP/vendored.log" 2>&1 || { + echo "FAIL: --mode vendored refused a program it can package" + cat "$TMP/vendored.log" + exit 1 +} +TARBALL="$(ls target/dist/*.tar.gz | head -1)" +mkdir -p "$TMP/x" && tar -xzf "$TARBALL" -C "$TMP/x" +REQ="$(find "$TMP/x" -name HOST-REQUIREMENTS | head -1)" +[[ -n "$REQ" ]] || { + echo "FAIL: vendored bundle does not state what the host must provide" + find "$TMP/x" -type f | sed "s|$TMP/x/||" + exit 1 +} +cat "$REQ" + +grep -q 'capability=opengl.glx.driver' "$REQ" || { + echo "FAIL: HOST-REQUIREMENTS does not list the capability" + exit 1 +} +# The discovery mechanism is the actionable half. GLX is found through the +# dispatch library's own DT_RPATH, while EGL is found through a JSON file whose +# library_path is ABSOLUTE — so "copy the directory over" fixes one and not the +# other. A row without it is not actionable. +grep -q 'discovery=rpath-of-dispatch' "$REQ" || { + echo "FAIL: HOST-REQUIREMENTS states the capability but not how it is found" + cat "$REQ" + exit 1 +} + +echo "PASS: modes that carry their own libc refuse host capabilities; vendored packages and declares them" diff --git a/tests/unit/test_loader_contract.cpp b/tests/unit/test_loader_contract.cpp new file mode 100644 index 00000000..1e55d3cc --- /dev/null +++ b/tests/unit/test_loader_contract.cpp @@ -0,0 +1,237 @@ +#include + +import std; +import mcpp.build.loader_contract; +import mcpp.build.graph_shape; +import mcpp.pack.host_requirements; +import mcpp.manifest; +import mcpp.platform.elf_runtime; +import mcpp.build.runtime_validation; + +namespace { + +// ─── the loader-tag contract ──────────────────────────────────────────────── + +TEST(LoaderContract, ExecutablesNeedRpathLibrariesNeedRunpath) { + using namespace mcpp::build::loader; + EXPECT_EQ(required_tag(Form::Executable), RequiredTag::Rpath); + EXPECT_EQ(required_tag(Form::SharedLibrary), RequiredTag::Runpath); + EXPECT_EQ(required_tag(Form::NotElf), RequiredTag::NotApplicable); +} + +// The two producers must not each decide the spelling. If one of them ever +// grows its own string literal, this is the test that notices. +TEST(LoaderContract, BothProducersSpellTheSameRuleFromOneSource) { + using namespace mcpp::build::loader; + auto exeLink = link_flag(required_tag(Form::Executable)); + auto exePatch = patchelf_flag(required_tag(Form::Executable)); + ASSERT_TRUE(exeLink.has_value()); + ASSERT_TRUE(exePatch.has_value()); + EXPECT_EQ(*exeLink, "-Wl,--disable-new-dtags"); + EXPECT_EQ(*exePatch, "--force-rpath"); + + // Libraries take the default in BOTH producers -- forcing DT_RPATH onto a + // library pushes its search path into every lookup below it and breaks + // eglInitialize (openxlings/xlings#593). + EXPECT_FALSE(link_flag(required_tag(Form::SharedLibrary)).has_value()); + EXPECT_FALSE(patchelf_flag(required_tag(Form::SharedLibrary)).has_value()); +} + +// ─── graph shape ──────────────────────────────────────────────────────────── + +TEST(GraphShape, UnlabelledOrUnknownGraphIsNeverPlain) { + using namespace mcpp::build; + auto dir = std::filesystem::temp_directory_path() + / "mcpp_graph_shape_test"; + std::filesystem::create_directories(dir); + struct Cleanup { std::filesystem::path d; + ~Cleanup() { std::error_code ec; std::filesystem::remove_all(d, ec); } } + cleanup{dir}; + + auto write = [&](std::string_view name, std::string_view body) { + auto p = dir / name; + std::ofstream out(p, std::ios::trunc); + out << body; + return p; + }; + + EXPECT_TRUE(is_plain_build_graph( + write("normal.ninja", "# banner\n# mcpp:graph=normal\nrule x\n"))); + EXPECT_FALSE(is_plain_build_graph( + write("test.ninja", "# banner\n# mcpp:graph=test\nrule x\n"))); + + // A build.ninja from before the marker existed. It MUST read as a miss: + // treating it as plain is precisely the replay #407 is about. + EXPECT_FALSE(is_plain_build_graph( + write("legacy.ninja", "# banner\nninja_required_version = 1.11\n"))); + + // A shape this binary does not know. An older mcpp meeting a newer graph + // must fall back, not guess. + EXPECT_FALSE(is_plain_build_graph( + write("future.ninja", "# mcpp:graph=coverage\n"))); + + // Missing file. + EXPECT_FALSE(is_plain_build_graph(dir / "absent.ninja")); +} + +TEST(GraphShape, HeaderAndReaderAgree) { + using namespace mcpp::build; + auto dir = std::filesystem::temp_directory_path() / "mcpp_graph_shape_rt"; + std::filesystem::create_directories(dir); + struct Cleanup { std::filesystem::path d; + ~Cleanup() { std::error_code ec; std::filesystem::remove_all(d, ec); } } + cleanup{dir}; + + for (auto shape : {GraphShape::Normal, GraphShape::WithTests}) { + auto p = dir / "build.ninja"; + { std::ofstream out(p, std::ios::trunc); out << header_line(shape) << "\n"; } + auto read = read_shape(p); + ASSERT_TRUE(read.has_value()); + EXPECT_EQ(*read, shape); + } +} + +// ─── host requirements: ONE derivation, two projections ───────────────────── + +mcpp::manifest::RuntimeConfig runtime_with(std::vector capabilities) { + mcpp::manifest::RuntimeConfig rc; + rc.capabilities = std::move(capabilities); + return rc; +} + +TEST(HostRequirements, OnlyRunPhaseCapabilitiesCount) { + mcpp::manifest::RuntimeConfig rc; + rc.requirements.push_back({.kind = "capability", .value = "opengl.glx.driver", + .phase = "run"}); + // A link-phase requirement is consumed during the build and says nothing + // about the target machine. + rc.requirements.push_back({.kind = "capability", .value = "pkg-config", + .phase = "link"}); + // A non-capability requirement is not a host capability either. + rc.requirements.push_back({.kind = "library", .value = "libfoo.so.1", + .phase = "run"}); + + auto reqs = mcpp::pack::host_requirements_of(rc); + ASSERT_EQ(reqs.size(), 1u); + EXPECT_EQ(reqs[0].capability, "opengl.glx.driver"); +} + +// The legacy vector carries the same meaning. A package that has not migrated +// must not silently produce an empty list -- an empty HOST-REQUIREMENTS is a +// CLAIM that nothing is needed. +TEST(HostRequirements, LegacyCapabilitiesVectorIsStillRead) { + auto reqs = mcpp::pack::host_requirements_of( + runtime_with({"vulkan.icd", "opengl.egl.driver"})); + ASSERT_EQ(reqs.size(), 2u); + EXPECT_EQ(reqs[0].capability, "opengl.egl.driver"); // sorted + EXPECT_EQ(reqs[1].capability, "vulkan.icd"); +} + +TEST(HostRequirements, DuplicatesAcrossBothFormsCollapse) { + mcpp::manifest::RuntimeConfig rc; + rc.requirements.push_back({.kind = "capability", .value = "opengl.glx.driver", + .phase = "run"}); + rc.capabilities.push_back("opengl.glx.driver"); + EXPECT_EQ(mcpp::pack::host_requirements_of(rc).size(), 1u); +} + +// The mechanism is DECLARED, never inferred. mcpp inferring it from the +// capability name would be provider-specific knowledge in mcpp's source -- +// gated by test_runtime_contract, and wrong on its merits: the mechanism is +// the provider's property and changes without mcpp. +TEST(HostRequirements, DiscoveryIsCarriedNotGuessed) { + mcpp::manifest::RuntimeConfig rc; + rc.requirements.push_back({.kind = "capability", .value = "opengl.egl.driver", + .phase = "run", .discovery = "json-dir"}); + auto reqs = mcpp::pack::host_requirements_of(rc); + ASSERT_EQ(reqs.size(), 1u); + EXPECT_EQ(reqs[0].discovery, "json-dir"); + + // Undeclared stays empty and renders as `unknown` -- saying "we do not + // know how this is found" is information; guessing would be a claim. + auto legacy = mcpp::pack::host_requirements_of( + runtime_with({"opengl.glx.driver"})); + ASSERT_EQ(legacy.size(), 1u); + EXPECT_TRUE(legacy[0].discovery.empty()); + EXPECT_NE(mcpp::pack::render(legacy).find("discovery=unknown"), + std::string::npos); +} + +TEST(HostRequirements, RenderIsGreppable) { + mcpp::manifest::RuntimeConfig rc; + rc.requirements.push_back({.kind = "capability", .value = "opengl.glx.driver", + .phase = "run", .discovery = "rpath-of-dispatch"}); + auto text = mcpp::pack::render(mcpp::pack::host_requirements_of(rc)); + EXPECT_NE(text.find("capability=opengl.glx.driver"), std::string::npos); + EXPECT_NE(text.find("discovery=rpath-of-dispatch"), std::string::npos); +} + +// ─── declared runtime artifact -> identity verdict ────────────────────────── +// +// The graphics failure this exists for: a provider declared at 0.1.2 while the +// symlink on disk still resolved into 0.1.1. Detectable as a pure path fact — +// no knowledge of what the artifact does. + +TEST(ArtifactIdentity, FourValuedAndSymlinkAware) { + using namespace mcpp::build::runtime_validation; + namespace fs = std::filesystem; + + auto root = fs::temp_directory_path() / "mcpp_artifact_identity"; + std::error_code ec; + fs::remove_all(root, ec); + fs::create_directories(root / "store" / "0.1.1" / "lib"); + fs::create_directories(root / "store" / "0.1.2" / "lib"); + struct Cleanup { fs::path d; + ~Cleanup() { std::error_code e; fs::remove_all(d, e); } } cleanup{root}; + + auto write = [](const fs::path& p) { std::ofstream out(p); out << "x"; }; + write(root / "store" / "0.1.1" / "lib" / "libvendor.so"); + write(root / "store" / "0.1.2" / "lib" / "libvendor.so"); + + mcpp::manifest::RuntimeArtifact a; + a.role = "driver"; + a.provenance = "xim:vendor@0.1.2"; + + // Nothing declared / nothing there. + EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Missing); + a.path = root / "absent.so"; + EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Missing); + + // Declared version matches the resolved payload. + a.path = root / "store" / "0.1.2" / "lib" / "libvendor.so"; + EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Ok); + + // THE case. A symlink that still points into the previous payload: reading + // the declared path alone would confirm the promise against itself. + fs::create_directory_symlink(root / "store" / "0.1.1", root / "current", ec); + if (!ec) { + a.path = root / "current" / "lib" / "libvendor.so"; + EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Mismatch); + } + + // No version to check against is UNVERIFIED, never Ok — "not checked" and + // "checked and fine" must not look the same. + a.path = root / "store" / "0.1.2" / "lib" / "libvendor.so"; + a.provenance = "xim:vendor"; + EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Unverified); +} + +// A component match, not a substring: 0.1.1 must not satisfy 0.1.11. +TEST(ArtifactIdentity, VersionMatchIsAPathComponent) { + using namespace mcpp::build::runtime_validation; + namespace fs = std::filesystem; + auto root = fs::temp_directory_path() / "mcpp_artifact_identity_sub"; + std::error_code ec; + fs::remove_all(root, ec); + fs::create_directories(root / "0.1.11"); + struct Cleanup { fs::path d; + ~Cleanup() { std::error_code e; fs::remove_all(d, e); } } cleanup{root}; + { std::ofstream out(root / "0.1.11" / "lib.so"); out << "x"; } + + mcpp::manifest::RuntimeArtifact a; + a.path = root / "0.1.11" / "lib.so"; + a.provenance = "xim:vendor@0.1.1"; + EXPECT_EQ(artifact_identity_verdict(a), ArtifactVerdict::Mismatch); +} + +} // namespace