diff --git a/.agents/docs/2026-05-19-pack-windows-design.md b/.agents/docs/2026-05-19-pack-windows-design.md index 14544d03..093f31a5 100644 --- a/.agents/docs/2026-05-19-pack-windows-design.md +++ b/.agents/docs/2026-05-19-pack-windows-design.md @@ -1,7 +1,37 @@ # Windows Pack Design **Date:** 2026-05-19 -**Status:** Planned (stub guard in place, implementation not yet started) +**Status:** SUPERSEDED and implemented, 2026-08-17. See +`2026-08-16-windows-toolchain-three-axes-design.md` §4 for the design that +shipped, and `src/pack/binfmt.cppm` / `src/pack/zip.cppm` for the code. + +> ## What this document got wrong, and it is worth keeping +> +> Everything below assumes **pack runs on Windows**. That assumption is +> visible in every proposal: `dumpbin /dependents`, `ImageNtHeader` from +> ``, PowerShell's `Compress-Archive`, and an implementation +> "under `#if defined(_WIN32)`". +> +> The assumption came from the guard it was trying to remove. `pack` refused +> Windows, so the problem looked like "pack has no Windows branch". It was +> not: the ELF closure is derived by RUNNING the artifact +> (`LD_TRACE_LOADED_OBJECTS=1 ''`), so it can cross neither an OS nor +> an ARCHITECTURE — a Linux box cannot trace a PE, and an x86_64 box cannot +> trace an aarch64 ELF either. The `#if defined(_WIN32)` was that limitation +> surfacing at the nearest place a user would hit it. +> +> Reading the import table statically removes both limits at once, and then +> cross-OS packaging is not a feature that had to be added — it is what +> remains when the obstacle is gone. Each Windows-only tool above would have +> reintroduced the obstacle one layer down. +> +> What did survive from here: the `.zip` output, the flat +> DLLs-beside-the-`.exe` layout, no wrapper script, and the skip-list concept +> (with one correction — `vcruntime*.dll` is listed below as a system DLL, and +> it is not: it belongs to the TOOLSET, and whether it travels is +> `cxx_runtime`'s decision). + +--- ## Current state diff --git a/.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md b/.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md new file mode 100644 index 00000000..6c04d471 --- /dev/null +++ b/.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md @@ -0,0 +1,572 @@ +# 两处「模型比生态少一层」:Windows 资源输入 与 版本身份 + +> 状态:**已实施(2026.8.7.1)**。剩余待决的 4/5/6 按文档推荐执行,可回退,见 §D。 +> 实施中发现的三条见 §E。 +> 关联:[#365](https://github.com/mcpp-community/mcpp/issues/365)(Windows 资源编译)、 +> [#363](https://github.com/mcpp-community/mcpp/issues/363)(版本模型) +> 涉及:`src/build/{plan,prepare,ninja_backend,flags}.cppm`、 +> `src/manifest/{types,toml}.cppm`、`src/toolchain/{model,dialect,llvm,gcc,msvc}.cppm`、 +> `src/version_req.cppm`、`src/pm/{resolver,lock_io}.cppm`、`src/build/execute.cppm` + +--- + +## 0. 为什么写在一份文档里 + +两个 issue 领域无关,但失效形状同一条:**生态已经产出的东西,mcpp 的模型表达不了,于是走到一条「不报错但结果是错的」路径上。** + +| | 生态产出 | mcpp 的模型 | 用户实际遭遇 | +|---|---|---|---| +| #365 | Windows 应用带 `.rc`(图标、VERSIONINFO) | 链接期输入只有「对象文件」和「不透明 ldflags 字符串」 | 只能把预编译 `.res` 塞进 ldflags;改图标后 `ninja: no work to do` | +| #363 | 上游发 `b10069`、`1.92.8-docking` | 版本 = 4 个整数,字面键被丢弃 | `^1.92.8` 在两个不同 tarball 之间任选一个;lock 记的是约束本身 | + +两条都不是「少个 feature」,是**表达力缺口导致的静默错误**。本仓库反复付过这个学费(假 `Cached` 骗了三个月、`.mcpp_ok` 只证进程退 0、索引下限把旧客户端变砖),处理原则一致:**要么给出正确答案,要么给出指名的错误,不要第三种。** + +除此之外两者有一处真实交汇(不是硬凑):`version_req::Version` 同时是 #363 的排序依据 和 #365 生成 `FILEVERSION` 所需的 4 元组来源。#363 把「字面 / 序」分开之后,#365 正好取它的数值侧。**Part A 与 Part B 可各自独立发布**,只要 A 落在 B 之后(或 A 自己解析一次版本号,成本一行)。 + +--- + +# Part A — #365:Windows 资源编译 + +## A1. 关键发现:VERSIONINFO 读不到,不是 llvm-rc 的 bug + +issue 结尾要求作者注意「llvm-rc 生成的 VERSIONINFO 结构不被 GetFileVersionInfo 解析」,并建议 mcpp 侧做专门处理或换编译器。**这条归因是错的,实测已证伪。** + +用 issue 里那份 `.rc`(`VS_VERSION_INFO VERSIONINFO`,未 include 任何头),以及把首 token 换成字面 `1` 的版本,分别用 `llvm-rc 22.1.8` 编译,比对 `.res` 的资源头: + +``` +# VS_VERSION_INFO VERSIONINFO → 508 字节 +ff ff 10 00 56 00 53 00 5f 00 56 00 45 00 52 00 ... +^type=0xFFFF,16 ^name = UTF-16 字符串 "VS_VERSION_INFO" + +# 1 VERSIONINFO → 480 字节 +ff ff 10 00 ff ff 01 00 +^type=0xFFFF,16 ^name = 序号 1 +``` + +`VS_VERSION_INFO` 是 `verrsrc.h` 里的宏(`#define VS_VERSION_INFO 1`),随 `windows.h` 引入。**没有 include 它时,rc 语法允许标识符出现在资源名位置,于是它被当成资源名字符串**——资源类型仍是 RT_VERSION(16),所以 `llvm-readobj --coff-resources` 照样显示 `Type: VERSIONINFO`;但 `GetFileVersionInfo` 查的是 `MAKEINTRESOURCE(VS_VERSION_INFO)` 即**序号 1**,查不到,所有字段返回空。报告者观测到的每一条都对上了。 + +两种修法都实测通过,产出与 `1 VERSIONINFO` **字节相同**(480 字节,`ff ff 01 00`): + +``` +llvm-rc -D VS_VERSION_INFO=1 /fo out.res in.rc # 命令行定义 +# 或在 .rc 顶部写 #define VS_VERSION_INFO 1 / #include +``` + +同时实测清了 llvm-rc 的能力边界(`llvm-rc /?`):**默认就做预处理**(有 `/no-preprocess` 才关),支持 `/I` 加 include 路径、`/D` 定义宏;**没有任何 depfile 选项**。所以「llvm-rc 不认常量」也不是 llvm-rc 的缺陷,是 `.rc` 里没有 include,预处理器无从展开。 + +**对设计的三条影响** + +1. mcpp 不需要绕开任何工具 bug。合成 `.rc` 时写字面 `1 VERSIONINFO`,构造性正确。 +2. 用户自写 `.rc` 要能 `#include `,所以 mcpp **必须把目标的 SDK / mingw include 目录喂给 rc 工具**(`/I`,MSVC 走 `INCLUDE` 环境变量——`Toolchain::envOverrides` 已经在填它)。 +3. **不注入 `-DVS_VERSION_INFO=1`。** 那是拿 mcpp 去局部模仿 Windows SDK:报告者接下来还缺 `VOS_NT_WINDOWS32`、`VFT_APP`……每补一个就多一处与真 SDK 漂移的定义。正解是让真的 `windows.h` 可达。作为补偿,见 A5 的 lint。 + +## A2. 现状:三条通道,没有一条能表达「被跟踪的链接期输入」 + +| 通道 | 现状 | 为什么不行 | +|---|---|---| +| `[build].ldflags` | `ninja_backend.cppm:825` 把 `$ldflags` 整串拼进 link 命令 | ldflags 是不透明字符串,**没有任何一处从里面析出文件路径当 implicit input**。`.res` 改了 ninja 不知道 → issue 报的 `no work to do` | +| `[build].sources` | `.rc` 不在 `is_implementation_source` / `is_c_source` / nasm / gas 任一分派里(`plan.cppm:299`、`ninja_backend.cppm:244`) | 会被当成 C++ 翻译单元进模块图与编译集 | +| `build.mcpp` 的 `action{}` | `BuildAction::Role` 三个值:`Source`(产出进**编译**集)、`Check`(产出是 stamp)、`Artifact`(**输入是 link 产物**) | 三条接线分别接在「编译输入 / 无 / 链接输出」上。**「链接输入」这个接线点在角色表里不存在。** | + +第三行是架构级的:`types.cppm:219` 明说「role 不是三种机制,是同一条边的三种接线」。资源编译正好证明这张表**缺一格**——link 边是有输入有输出的节点,而没有任何角色能把产物接到它的输入上。同类需求还有:`objcopy` 嵌入二进制 blob、`.def`/`.exp`、外部生成的 `.o`、linker script。 + +> 换句话说:#365 不修,用户就只能继续用 ldflags 塞路径——而 ldflags 塞路径**正是 issue 抱怨的那个不被跟踪的东西**。表达力缺口和它逼出的坏 workaround 是同一件事。 + +## A3. 设计:三层,每层是下一层的默认值 + +分层控制的判据来自 grpcgen 那批的教训:**断崖本身就是设计缺陷**——两个旋钮之后只能手写六十行绕开规则,而那六十行会与规则悄悄漂移。所以三层必须能连续下降,L0 的产物就是 L1 的输入。 + +### L0:声明式(覆盖 90% 场景,零 `.rc` 编写) + +```toml +[resources] +icon = "assets/app.ico" + +[resources.version-info] # 全部可选,默认从 [package] 取 +company = "…" # 默认 authors[0] +product = "…" # 默认 package.name +description = "…" # 默认 package.description +copyright = "…" # 默认 "© " + authors[0] + "," + license +``` + +`[package]` 已有 `name / version / description / license / authors / repo`(`types.cppm:37`),足够生成一份合法 VERSIONINFO。`FILEVERSION` / `PRODUCTVERSION` 取 `version` 的 4 段数值(`0.2.0` → `0,2,0,0`;`2026.8.6.3` → `2026,8,6,3`),每段 clamp 到 u16 并在越界时报错而不是截断;`StringFileInfo` 里的 `FileVersion` / `ProductVersion` 写**版本字面串**(这样 `1.0.0-rc1` 这类预发布信息不丢——见 Part B)。 + +mcpp 把合成的 `.rc` 写到 `target///resources/.rc`,编译,产物挂到该包每个 PE link unit 的输入上。 + +### L1:自写 `.rc` + +```toml +[resources] +files = ["res/app.rc"] +extra-inputs = ["res/dialogs.h"] # 兜底:扫描没认出来的输入 +``` + +mcpp 编译并**跟踪**它:`.rc` 自身 + 从 `.rc` 文本扫出的 `#include "…"` 与资源语句里的引号文件名(`ICON "x.ico"`、`24 MANIFEST "app.manifest"`、`RCDATA`、`BITMAP` …)都进 implicit inputs。llvm-rc/windres 都不给 depfile(A1 实测),所以这里只能扫,因此配一条 `extra-inputs` 显式兜底——与 `[modules].scan_overrides` 同一个「发现不够时改成声明」的先例。 + +**合成与自写的交互,一张 3 行表**(不做文本解析猜测,规则显式): + +| `version-info` | `files` | 行为 | +|---|---|---| +| 未写 | 空 | 合成(L0) | +| 未写 | 非空 | **不合成**——用户接管了资源 ID 空间 | +| `= true` | 非空 | 合成;ID 冲突由用户负责(文档写明 RT_VERSION 只能有一个 id 1) | + +`version-info = false` 恒不合成。 + +**L0 → L1 没有断崖**:合成的 `.rc` 落在 `target/` 下一个稳定路径,用户 `cp` 进自己的树、填进 `files`,得到**字节相同**的资源。这条要作为判据机器化验证(A-判据 6),否则「每层是下一层的默认值」只是文档里的一句话。 + +### L2:通用原语 —— `action` 的第四个 role + +给 `BuildAction::Role` 补上 `Object`:**产出接到 link 边的输入上**。角色表变成完整的四格: + +``` +Source → 编译输入 +Check → 无(stamp) +Object → 链接输入 ← 新增 +Artifact → 链接输出之后 +``` + +这不是为资源加的钩子(钩子数是乘法成本,`build-mcpp-extensibility-architecture` 那批已经定过调)——L0/L1 走的是引擎自己的 rc 边,不经过 action。`Object` 是**补齐角色表**,顺带让 ldflags 塞路径这类 workaround 全类退休。 + +**已决:本批做。** 不做的话「不被跟踪的 ldflags 路径」仍是唯一出路,等于留着这个 issue 的成因。 + +`Object` 的产物接到**哪个** link unit:`Artifact` 是靠 `${mcpp.target_file:NAME}` 出现在 inputs 里反推的,`Object` 反不了(它的产物在 link 之前,没有 link 产物可引用)。定为给 `BuildAction` 加一个可选 `targets = [...]`——空 = 声明它的那个包的所有 link unit,与 L0/L1 的默认作用域一致。`targets` 里出现未知目标名时报错并列出本次构建的目标,复用 `${mcpp.target_file:}` 已有的那条诊断(`prepare.cppm:4941`)。 + +## A4. rc 工具解析:dialect × payload 的实测矩阵 + +**绝不走 PATH。** `~/.mcpp/registry/subos/default/bin/x86_64-w64-mingw32-windres` 现在是个指向 `bin/xlings` 的符号链接——xlings 的裸名 shim 机制,最后注册者抢名(已经弄坏过一次真实工具链:`gcc --version` 全对而产物是 ARM)。工具必须**相对编译器二进制所在 payload 解析**,先例是 `clang::find_scan_deps(tc)`(`prepare.cppm:4958`)。 + +| dialect / 目标 | rc 工具 | 产物 | 链接器接受 | +|---|---|---|---| +| MSVC(`rc.exe` / `link.exe`) | `rc.exe`(SDK;include 走 `envOverrides` 里的 `INCLUDE`) | `.res` | link.exe 直接吃 `.res` | +| clang + lld-link(Windows 原生) | `llvm-rc`(payload 自带) | `.res` | lld-link 直接吃 `.res` | +| GNU / mingw(`x86_64-w64-mingw32-g++` + ld) | `windres -O coff` | COFF `.o` | ld 吃对象;**ld(bfd) 不吃 `.res`** | + +**实测到的 payload 缺口**:Linux 上的 `xim-x-llvm/22.1.8/bin` 只有 `llvm-rc` 和 `llvm-readobj`,**没有 `llvm-windres`、没有 `cvtres`**(Windows 的 20.1.7 payload 才有 `llvm-windres.exe`)。所以: + +- Linux → Windows 走 **mingw 交叉链**(mcpp 现在的交叉路径):`x86_64-w64-mingw32-windres` 在 mingw payload 里,没问题。 +- Linux → Windows 走 **clang + lld** :只有 `llvm-rc` 能产 `.res`。**`ld.lld` 的 mingw 模式是否接受 `.res` 需实测**(VERIFY-A3)。接受则这条路直接通;不接受则必须从 mingw payload 借 `windres`,此时要给出指名的错误。 + +解析时机与失败策略照抄 nasm(`prepare.cppm:4964-5031`):**惰性**——只有 plan 里真的有资源单元才解析;**硬失败**——找不到工具就报错并指名工具与 dialect,绝不静默跳过(掉一个 `.o` 会以「图标没了」或几层之外的 undefined 现形)。 + +## A5. 增量、隔离、与那条 lint + +- **构建图**:新增 `plan.resourceUnits`(源 `.rc`、输出、implicit inputs),backend 出一条 `rc_object` 规则,输出 append 到对应 `LinkUnit::objects`。**不进 `CompileUnit`**——那会把 `.rc` 拖进模块图、topo 序、`compile_commands.json`(clangd 会当场噎住)和缓存键。 +- **与依赖缓存零交互**:资源只由「拥有 link unit 的那个包」声明,root 包永不进全局缓存。依赖声明的资源**不传播**(一个依赖的 VERSIONINFO 和消费者的会打架);非 root 包声明了资源但自己不产 PE link unit → 警告并忽略。 +- **非 PE 目标**:整节**不适用**(不是降级)。不需要 `cfg(windows)`——而且**不能**走那条通道:`[target.'cfg(…)'.build]` 的 `kKnownConditionalBuildKeys`(`toml.cppm:1174`)是封闭表,且「条件通道只载 BuildInputs,一条轴一套作用域规则」是明文设计立场,图标/版本元数据不是 build input,塞进去会被 schemaWarnings 拒掉。用户无条件写一次即可。 +- **可见性补偿**(§D-3 的义务):PE 目标下 `[resources]` 生效时打一行 status(`Embedding app.ico + version info`);非 PE 目标不打也不警告。节名里既然看不出平台,就得让「它这次生效了」出现在输出里——顺带让判据 A2 的增量行为可观测。 + > 这里**偏离 issue 的第 3 条要求**。issue 要「资源文件缺失时跳过,不应导致构建/打包失败」。拆成两件事:跨平台不炸由「非 PE 目标不适用」解决;而**声明了却不存在的文件是硬错误**——与 `main = "…"` 必须匹配恰好一个文件同一条规则。静默跳过一个声明过的输入,等于让「图标为什么没了」变成不可归因的问题,正是这个 issue 的起点。 +- **lint(补偿 A1 第 3 条)**:自写 `.rc` 里出现 `VERSIONINFO`,其名字 token 既不是字面 `1`、文件里又没有 `#include` 也没有 `#define VS_VERSION_INFO` 时,给一条 warning 并附确切修法。这是启发式(用户可能在别处定义了宏),所以只 warn;它把本 issue 里那个**完全无法自行诊断**的失效变成一行可读的话。 + +## A6. 明确不做 + +- **`subsystem` / `entry`**:报告者也在用 `-Wl,-subsystem:windows -Wl,-entry:mainCRTStartup`。那是链接模式,不是资源,属于另一条轴(`[target.].linkage` 那一层)。本设计不动它,现有 ldflags 写法继续有效——但它在**同一个「Windows GUI 应用」的用户故事**里,值得单独 triage。 +- **macOS bundle / Info.plist / `.desktop`**:`[resources]` 的语义定为「编译进产物的元数据与资产」,将来这些扩展**同一节**而不是新开 `[macos]`;本批只实现 PE 消费者。 +- **对话框、字符串表等资源类型**:mcpp 不解析 `.rc` 语义,L1 原样交给 rc 工具。 + +## A7. 判据(可机器验证) + +1. **A1**:Windows 目标 + `[resources] icon=…` 产出的 exe,其 RT_VERSION 资源名是**序号 1**(按字节验,不是「存在一个版本资源」),且 `FileVersionInfo::GetVersionInfo` 的 ProductName / FileVersion 非空。 +2. **A2**:动 `.ico`、动 `.rc`、动 `[package].description` 三者任一 → ninja 重链;都不动 → no-op。(原症状是 `no work to do`。) +3. **A3**:同一份 manifest 在 Linux/macOS 构建**逐字节不变**,零 warning。 +4. **A4**:`.res`/`.o` 不出现在 `compile_commands.json`,不出现在模块图。 +5. **A5**:rc 工具不可用 → 错误消息含工具名与 dialect;e2e 用「把 payload 里的 windres 改名」构造。 +6. **A6(无断崖)**:把合成的 `.rc` 复制进源码树并填入 `files`,产出资源**字节相同**。 +7. **A7**:`.rc` 里 `#include` 的头改动 → 重编(验证扫描确实进了 implicit inputs)。 + +## A8. 待实测(VERIFY) + +- **VERIFY-A1**:mingw payload 的 `windres -O coff` 产物在 mcpp 的交叉链里能被 ld 正常收进 `.rsrc`(本机当前没装 mingw 交叉 payload)。 +- **VERIFY-A2**:`rc.exe` 在 mcpp 的 MSVC 路径下能通过 `envOverrides.INCLUDE` 找到 `windows.h`。 +- **VERIFY-A3**:`ld.lld` 的 mingw 模式是否接受 `.res` 输入(决定 clang+lld 交叉是否需要借 windres)。 +- **VERIFY-A4**:CI 覆盖。Windows job 必须真的跑 `FileVersionInfo` 读取——「exe 里有 VERSIONINFO 资源」这个断言在本 issue 的失效场景下**恒为真**,是假绿(与 E0006 那次「断言出现某错误 = 假绿」同一形状)。 + +--- + +# Part B — #363:版本身份 + +## B1. 关键发现:解析器已经读到了字面键,然后把它扔了 + +`resolver.cppm:127-154`: + +```cpp +auto rawVersions = mcpp::manifest::list_xpkg_versions(*luaContent, platform); // 字面键,全在手里 +std::vector parsed; +for (auto& s : rawVersions) { + auto v = vr::parse_version(s); + if (!v) continue; // ← 不可排序的键静默消失,且此后下标不再对齐 + parsed.push_back(*v); +} +auto idx = vr::choose(*req, parsed); +return parsed[*idx].str(); // ← 从 4 个整数重新渲染出一个地址 +``` + +`list_xpkg_versions` 返回的是描述符里的**字面 key**(`xpkg.cppm:880`,就地取引号内文本)。也就是说:**正确答案一直在 `rawVersions[*idx]` 里,代码却从 `parsed` 里倒推了一个。** `str()` 只能渲染 3–4 段数字,于是任何非纯数字键在范围路径上**永远寻址不到**——不是「不支持」,是「先丢掉再重造」。 + +这解释了 issue 的全部三条现象,包括为什么精确路径完全正常:精确路径(`is_version_constraint` 为 false)根本不经过这段代码,字面串直接进 wire 地址。 + +`continue` 那行还额外制造了**下标错位**:`parsed` 与 `rawVersions` 长度不同,而 `choose` 返回的是 `parsed` 的下标。今天没暴露只因为返回值不再用 `rawVersions`。修的时候必须成对保存,不能只把 `parsed[*idx].str()` 换成 `rawVersions[*idx]`。 + +**血缘**:`str()` 头上那段注释明写「load-bearing:pm/resolver 把它当解析结果返回,流向 lock 与 wire 地址——必须复现索引的字面版本键」。这个约束**已经被写下来了,但只用日期版本的 `.0` 尾段验证过**;本 issue 是同一约束在预发布/非 semver 上的第二次违约。真正的修法不是再加一条注释,而是**让「重新渲染出地址」这件事在类型上不可表达**。 + +## B2. 三个症状,两个根因 + +issue 列了三条,我把归因拆开: + +| 现象 | 根因 | +|---|---| +| ① 精确键(含 `b10069`、`1.92.8-docking`)全通 | — 无需改动,与设计一致 | +| ② `^b10069` 不可能 | 版本模型只有「4 个整数」一种形态,没有「不可排序但可寻址」这一类 | +| ③ `^1.92.8` 看不见 `1.92.8-docking` 的区别 | 截断丢语义(parse)+ 从数值重造地址(B1) | +| ④ lock 记的是约束本身 | **另一个根因**:解析结果没有回流;见 B4 | + +②③ 是同一个根因的两面:**版本 = 字面身份 + 可选的序**,而现在只建模了序。④ 与它们无关,是消费者读错了输入。 + +## B3. 设计:字面是身份,数值只是序 + +```cpp +// 一个索引版本键。literal 是身份(wire 地址、store 目录、lock); +// order 是可选的排序能力:nullopt = 不可排序,只能精确匹配。 +struct VersionKey { + std::string literal; + std::optional order; +}; +struct Order { // 现在的 Version + 预发布 + int major, minor, patch, revision; + std::vector prerelease; // 空 = 正式版;正式版 > 任何预发布 + // build metadata('+' 之后)不参与序,但**在 literal 里**,所以不影响身份 +}; +``` + +- `resolve_semver` 返回 `keys[*idx].literal`。**`str()` 不再出现在任何寻址路径上**,降级为纯展示,并在注释里改掉「load-bearing」的说法——把约束从「需要遵守」变成「无处可犯」。 +- **不可排序的键(`b10069`)**成为一等公民的一类:`order == nullopt`,只参与 `=` / 裸字面(`is_version_constraint` 为 false 的那条路),范围与 `*` 跳过它。这把 issue 建议 2 的「明确而不是靠恰好走了另一条代码路径」落成类型。 +- **预发布序**按 semver:`1.92.8-docking < 1.92.8`;预发布标识符点分段比较,数字段按数值、数字段 < 字母数字段。加上 mcpp 的第四段:序为 `major, minor, patch, revision, 然后 prerelease`(`1.2.3-rc < 1.2.3 < 1.2.3.1`,自洽)。 +- **范围对预发布的可见性**采用 npm/Cargo 已被接受的规则:**带预发布的候选只有在约束里存在一个「同 (major,minor,patch,revision) 且自身带预发布」的比较子时才可入选。** 一条规则同时修两件事:`^1.92.8` 不再看得见 `1.92.8-docking`(issue 的核心诉求),且 `^1.2.3` 的上界不再漏进 `2.0.0-alpha`(今天 `v < upper` 会漏,是同族的既存缺陷)。 +- **序相等但字面不同**(只差 build metadata,如 `1.0.0+a` 与 `1.0.0+b`):见下面 B3.5——这个情形在真实索引里存在,但它的真身不是「平局」。**列入待决策 4。** + +## B3.5 关键发现:真实索引里的「平局」是 alias,而 mcpp 不认识 alias + +扫了本机 `xim-pkgindex` 全部 161 个描述符,非纯数字版本键只有两个包,而**两个都命中本 issue 的形状**: + +```lua +-- pkgs/j/jdk-temurin.lua,三个平台表都一样 +["latest"] = { ref = "25.0.4+7" }, +["25.0.4"] = { ref = "25.0.4+7" }, +["25.0.4+7"] = { url = …, sha256 = … }, -- 唯一的真条目 + +-- pkgs/c/cc-connect.lua +["1.3.2"], ["1.3.3-beta.1"] +``` + +`jdk-temurin` 同时是「build metadata 平局」和「不可排序键被静默跳过」的实例,而**两者都是假的**:`25.0.4` 与 `latest` 都是 `{ ref = ... }` 指针,指向同一个 `25.0.4+7`。 + +**`list_xpkg_versions` 把每个引号键都当成一个版本,包括别名**(它就地收集平台表里的所有 key,`xpkg.cppm:955`;全仓无一处读 `ref`)。于是范围解析的候选集里,三个「版本」有两个是指向第三个的指针。 + +这一条把待决策 4 的问法改掉了:**先排除纯 alias 条目,再谈平局政策**——否则是在给一个不存在的问题定规则,而且定出来的规则会在一个完全正常的索引写法上开火(对 `jdk-temurin` 报「两个版本序相等,无法取舍」,而两个答案是同一个 payload)。 + +`cc-connect` 则是另一件事的实例:今天 `^1.3` 会解析到 `1.3.3-beta.1`(截断成 `1.3.3` → 最高),改后按 npm 预发布可见性规则解析到 `1.3.2`。**方向是对的**(范围不该悄悄给你 beta),但这是一处真实的、生态可见的行为变化,必须写进发布说明。 + +**诊断**(issue 建议 2 的另一半)。今天两个方向都不好: + +- 约束侧 `^b10069` → `invalid version constraint '^b10069': version: not a number ('b10069')`。技术上不错,但没说**为什么不可能**。 +- 键侧才是真隐患:索引里只有 `b10069`,用户写 `*` 或 `^0.1` → 静默 `continue` → **`no valid versions in index`**。这句话在**索引里明明有一个完全可用的版本**时把责任推给了索引。 + +新形状:范围没匹配到任何东西、而存在不可排序的键时,错误必须指名它们并给出可执行修法——「`ggml-org:llamacpp` 的版本键(`b10069`, `b10121`)不是可排序的版本号,范围约束无法表达它们;请精确 pin:`llamacpp = "b10069"`」。 + +## B4. lock:数据结构已经存在,两个消费者读错了输入 + +`prepare.cppm:1964-1977` 里已经有: + +```cpp +struct ResolvedRecord { + std::string version; // 解析后的具体版本 + std::string constraint; // 作者写的原始约束 + std::string requestedBy; + std::string source; // "version" | "path" | "git" + ... +}; +std::map resolved; // 覆盖整张图,含传递依赖 +``` + +**lock 需要的每一个字段都在里面,覆盖范围也对。** 但两个消费者都没读它: + +| 消费者 | 现在读什么 | 后果 | +|---|---|---| +| lock 写入 `prepare.cppm:5363` | `m->dependencies`(root 直接依赖,`spec.version` = 未解析的约束) | lock 记 `^1.92.8`;**传递依赖完全不在 lock 里** | +| `Compiling` 行 `execute.cppm:323-327` | 同上 | 打印 `compat.imgui v^1.92.8` | + +`resolveSemver` 改的是 worklist 里的**副本**(`auto& spec = item.spec;`)。旁边 `prepare.cppm:3277-3283` 已经有一处专门往 `m->dependencies` 回写 `namespace_ / shortName / candidates` 的代码——**`version` 只是没被列进去**。这是本仓库反复遇到的「同一决策两处推导」的镜像:结果算出来了,消费者读的是输入。 + +**正解不是补第三处回写**(那会再造一个推导点),而是让 lock 写入和 `Compiling` 行**都读 `resolved`**。顺带解决三件事:lock 记真实版本、lock 覆盖传递依赖、控制台输出与 lock 同源不可能不一致。 + +**但还有一层更深的问题**:今天 lock 只在一处被读回,且只读 git(`prepare.cppm:1089-1092` → `parse_git_source`)。**index 依赖的 lock 条目从不参与解析。** 也就是说 lock 对 index 依赖是装饰性的——只写真实版本会让它**看起来**权威而实际仍不 pin,这比现在更容易误导(假 `Cached` 的教训)。 + +两条路: + +- **B4-a(本批)**:写真相 + 覆盖传递依赖 + 文件头注释明说「本文件记录本次解析结果,尚不 pin 后续构建」。诚实、零行为变更、零生态风险。 +- **B4-b(单独一批)**:让 lock 权威,并配 `mcpp update`。这是正确终局,但它**改变解析行为**(有 lock 时不再自动吃索引新版本),需要独立的迁移与生态验证。 + +**已决:本批只做 B4-a,lock 不权威。** B4-b 单开一批。 + +这条决定带一个**不可省略的义务**:既然 lock 写的是真实版本却仍不参与解析,文件头必须自己说出来。写死在 `serialize()` 的头注释里,而不是只写进 docs——`# Auto-generated by mcpp. Do not edit by hand.` 后面加一行「记录本次解析结果;尚不锁定后续构建(索引出现更高版本时会重新解析)」。理由与假 `Cached` 那次相同:一个看起来权威而实际不 pin 的产物,比一个明显不完整的产物更危险。判据 B6 之外加一条 **B8:lock 头部含该声明**(e2e grep,改回权威时这条断言会红,正好提醒同批删掉它)。 + +`LockedPackage` 的 `requested` 字段(作者写的约束)**推迟到 B4-b**:它唯一的用途是「manifest 改了要重解析」,而本批不读 lock,现在加就是加一个没有消费者的字段。**待决策 2 撤销。** + +## B5. 兼容性 + +爆炸半径小得反常,值得点明:**`mcpp.version_req` 全仓只有两个消费者**——`pm/resolver.cppm` 与 `pm/index_contract.cppm`(`prepare.cppm` 只 import 未用)。 + +| 面 | 影响 | 处置 | +|---|---|---| +| 四段日期版本 `2026.8.6.3` | 无预发布、无 build metadata → 新解析器逐位同旧 | 判据 B4:对现有全部 `min_mcpp` 值做全表比对 | +| E0006 索引下限(`index_contract.cppm:125`) | 只用 `>=` 比 `Order` | 不变;malformed → `nullopt` 的「永不砖」性质保留 | +| 已发布索引里的既有键 | `^1.92.8` 今天**恰好**选中非 docking,改后**确定性地**选中它 —— 结果不变、原因变对 | 但若某包**只有**预发布键而消费者写了范围,改后会从「能解析」变成「报错」 | +| `mcpp add` / `index_refresh` | 都走 `is_version_constraint`(纯语法谓词),裸 `1.92.8-docking` 仍判为精确 | 不变 | + +最后一行是唯一的真实生态风险,必须**在发版前用真实 mcpp-index 全表扫**:找出所有「版本键全是预发布」的包。(这条与「发版前必须本地跑真实 mcpp-index workspace」是同一条纪律;CI 全绿证明不了。) + +**全表扫已做一次**(本机 `xim-pkgindex`,161 个描述符):非纯数字键只有 2 个包,无「只有预发布键」的包。两个包的具体影响见 B3.5——`cc-connect` 的 `^1.3` 会从 `1.3.3-beta.1` 改到 `1.3.2`(方向正确,需写进发布说明),`jdk-temurin` 取决于 alias 是否排除。**这次扫的是本机快照,发版前要对当时的索引重扫一遍。** + +**VERIFY-B1**:把范围解析结果从 alias 键(`25.0.4`)改成真条目(`25.0.4+7`)之后,store verdir 与 wire 地址行为是否变化——xlings 是否在建 verdir 之前解析 `ref`。若会产生第二个 verdir 或改变寻址,则退回「平局时择字面较大者 + 一次 warning」,把 alias 建模单开一批。 + +## B6. 判据(可机器验证) + +1. **B1**:对索引里每个键 `k`,`resolve_semver("=" + k)` 必须原样返回 `k`。这条把「不再重新渲染地址」变成可穷举的属性测试。 +2. **B2**:`1.92.8` 与 `1.92.8-docking` 在任何约束下不可互相替代;`^1.92.8` → `1.92.8`;只有 `^1.92.8-a` 这类自带预发布的约束才可能选到 `1.92.8-docking`。 +3. **B3**:`^1.2.3` 不匹配 `2.0.0-alpha`(今天会匹配)。 +4. **B4**:`b10069` 只有精确可达;范围约束下的错误消息**指名该键并给出精确 pin 的修法**,且不含 "no valid versions in index"。 +5. **B5**:`2026.8.6.3` 与所有现存 `min_mcpp` 的比较结果逐位不变。 +6. **B6**:`mcpp.lock` 的 `version` 恒为可寻址字面版本;连续两次 `mcpp build` 之间 lock 字节不变(幂等)。 +7. **B7**:lock 含传递依赖;`Compiling` 行的版本与 lock 一致(同一数据源,构造性保证)。 + +--- + +# C. 实施顺序(各切片可独立发布) + +| 步 | 内容 | 依赖 | 风险 | +|---|---|---|---| +| B-1 | `VersionKey`(字面+可选序)、预发布序、npm 预发布可见性规则、`resolve_semver` 返回字面键、不可排序键的指名错误 | — | 低(两个消费者) | +| B-2 | lock 与 `Compiling` 行改读 `resolved`;覆盖传递依赖;文件头诚实声明 | — | 低(无行为变更) | +| A-1 | `[resources]` 解析、`plan.resourceUnits`、`rc_object` 规则、rc 工具惰性解析(payload 相对,硬失败)、L1 自写 `.rc` + 输入扫描 | — | 中(新构建边) | +| A-2 | L0 合成 VERSIONINFO + icon;`FILEVERSION` 取 4 元组 | B-1 更佳(否则自己解析一次版本号) | 低 | +| A-3 | VERSIONINFO 名 lint | A-1 | 低 | +| A-4 | `Role::Object` + `targets`(已决:本批做) | A-1 | 中(角色表扩容) | +| — | **B-3(不在本批)** lock 权威 + `mcpp update` + `requested` 字段 | B-2 | 中(改解析行为) | + +先 B 后 A:B-1 给 A-2 提供版本 4 元组,且 B 的爆炸半径最小、能先独立验证。 + +--- + +# D. 决策记录 + +## 已决 + +1. **lock 本批不权威。** 只写真相(B-2),并在 `serialize()` 头部声明它尚不 pin(判据 B8)。`mcpp update` 与 `requested` 字段一起放到 B-3,不在本批。 +2. **`Role::Object` 本批做**,用可选 `targets = [...]`(空 = 声明包的全部 link unit)指定接哪条 link 边。 +3. **节名 `[resources]`,平台不进拼写。** 平台作用域是「只有 PE 目标消费」,但不写进节名:图标作为**概念**不是 Windows 专有的,只有文件格式与嵌入机制是;将来 macOS `.icns` / Linux `.desktop` 扩**同一节**(必要时 `icon` 升级成 per-platform 映射),保持一条轴,而不是按 OS 切成 `[windows]` / `[macos]` 三份并让 `icon` 这类共性键重复三次。 + 这条决定带一个**义务**:manifest 里既然看不出「只对 Windows 生效」,就得让它在别处可见。PE 目标下 `[resources]` 生效时打一行 status(`Embedding app.ico + version info`)——非 PE 目标**不打也不警告**(是「不适用」不是「降级」,每次 Linux 构建警告一次是噪音)。这条同时让判据 A2 的增量行为可观测。 + **per-target 延后**(落点是 `kKnownTargetKeys`,老 mcpp 只 warn 不 fail)。 + +## 待 review + +4. **平局政策——但问法要先改(见 B3.5)。** 拆成三小条: + - **4a(推荐做)** 识别纯 alias 条目(`{ ref = "…" }` 无 payload),**从范围候选里排除**;精确寻址不变(`= latest` / `= 25.0.4` 照旧走别名)。不做这条,平局政策就是在给一个不存在的问题定规则,而且会在 `jdk-temurin` 这种完全正常的索引写法上开火。 + - **4b(推荐)** 排除之后剩下的真平局(两个都是真条目、只差 build metadata)→ **硬错,指名两个键并给出「精确 pin 其一」的修法**。理由:此时两个键是两个不同 tarball、不同 sha256,mcpp 无从知道要哪个;「择字面较大者」是把猜测包装成确定性,正是本 issue 抱怨的「取舍由一个看不见差别的序决定」。#349「数据不得让程序失效」的反向担忧在这里是有界的——它只影响那一个包的范围解析(不是索引级不可用),错误里就写着修法,且 4a 之后它在惯用写法上不可能触发。 + - **4c** 若 VERIFY-B1 发现排除 alias 会改变 store verdir 或寻址行为,则 4a/4b 一起退回「择字面较大者 + 一次 warning」,alias 建模单开一批。 +5. **`.rc` 输入跟踪:扫描 + 指名缺口 + `extra-inputs` 兜底(推荐),还是纯显式声明?** + 一份 `.rc` 的输入只有两类,分别处置: + - **`#include`**:只跟踪**引号形式**(`"dialogs.h"`,项目自有);**尖括号形式不跟踪**(`` 属工具链,随 payload 不变,且已被工具链 fingerprint 计入构建目录)。这条规则一下去掉了「无法枚举 windows.h 传递闭包」这个担忧。 + - **资源语句里的数据文件**(`1 ICON "app.ico"`、`24 MANIFEST "app.manifest"`、`RCDATA` / `BITMAP` / `CURSOR` / `FONT` / `TYPELIB`):扫引号字符串。 + 唯一的真缺口是**宏间接**(`1 ICON APP_ICON`,文件名藏在 `#define` 后面)。处置:扫描遇到「操作数不是字符串字面量」或「引号 include 在搜索路径上找不到」时**警告并指名**,指向 `extra-inputs`——「限定了覆盖范围就要说出漏了什么」。 + 为什么不选纯显式:漏掉一项时 mcpp **无从警告**(它不知道自己漏了什么),而漏掉的后果是 exe 里留着旧资源直到有人碰一下 `.rc`——与本 issue 报的失效同一类。 + 为什么不选「自己预处理 + 拿编译器 depfile」(最精确):只有 `llvm-rc` 有干净的 `/no-preprocess` 入口,`windres` 与 `rc.exe` 都没有「吃已预处理输入」的干净模式 ⇒ 工具矩阵从 3×1 变 3×2。精确度换来的是每条 dialect 两套管线。 + 附注:**L0 完全不需要扫描**(合成的 `.rc` 里 icon 路径是声明来的,精确),所以这套启发式只作用于自写 `.rc`——而自写 `.rc` 的人正是能写 `extra-inputs` 的人。 +6. **偏离 issue #365 第 3 条**(「资源文件缺失时跳过,不应导致构建/打包失败」)。issue 这一条把两个担忧捆在一起,拆开之后一个消失、一个反转: + - **担忧一「Windows-only 声明会弄坏我的 Linux/macOS 构建」** → 已由「非 PE 目标整节不适用」解决。不是跳过,是没有消费者:零 warning、逐字节不变。issue 想要的跨平台安全**已经拿到了**。 + - **担忧二「资产文件不在(新克隆没拉 LFS / CI 没有这个文件 / 设计还没交图)不该让构建失败」** → 这里偏离:**声明了却不存在 = 硬错误**。四条理由: + ① 一致性——mcpp 里每个「声明过的输入」都是这个规则:`main = "…"` 必须匹配恰好一个文件、`scan_overrides` 的每个 glob 必须匹配 ≥1 个文件、nasm 缺失是硬错误且注释明写「never a silent skip,掉一个 `.o` 会在几层之外以 undefined 现形」。 + ② 它会把本 issue 的失效模式**制度化**——报告者的全部抱怨就是「静默不生效」;把「文件缺失 → 跳过」写成设计,等于让静默不生效成为规定行为。 + ③ 最疼的是发布构建:一个路径拼错或资产没提交,产出的是**没有图标、没有版本信息的正式二进制,而且什么都没说**。发现时间点通常是有人下载之后,归因成本极高。 + ④ 「图标是可选资产」说的是**功能**可选,不是**声明**可选。不要图标已经可表达:把那一行删掉。 + - 若仍要字面满足 issue:`icon = { path = "…", optional = true }`,走既有 `diag::degraded` 通道(报告一次、`--strict` 拒绝)——**显式选择降级**而不是默认静默。推荐先不做(YAGNI):不声明、或用 `action{role=object}` 生成,两条退路已经在。 + +--- + +# E. 实施回执(2026.8.7.1) + +## E.1 写代码才撞出来的三条 + +1. **⚠️ 非 ASCII 元数据会让 rc 编译器直接拒绝整个脚本。** 设计里完全没有这一格。用合成器的输出跑真 `llvm-rc` 时立刻炸: + + ``` + llvm-rc: Error in VERSIONINFO statement (ID 1): + Non-ASCII 8-bit codepoint (—) can't be interpreted in the current codepage + ``` + + 触发它的是**mcpp 自己生成的**默认 copyright 里的一个 em dash。而 `[package]` 的 description/authors 是用户文本,中文项目必然命中 ⇒ **必须给 rc 工具传 UTF-8 codepage**(`/C 65001` / `--codepage=65001`),否则一个中文描述的项目根本构建不了。同时把生成文本本身收敛成纯 ASCII(单测断言),这样生成物不依赖那个 flag 是否传对——两道,因为它们防的是两件事。 + + 这条是「只写文档不写示例会漏掉」的又一次:设计里推演到了「VERSIONINFO 的名字必须是序号 1」,推不到「编码」。 + +2. **五段版本键在真实索引里存在**,而不是假想。`jdk-corretto` 发 `25.0.4.7.1`(`....`),四段截断让 `25.0.4.7.1` 与将来的 `25.0.4.7.2` 比较相等。数值段因此改成任意长度而不是加到第五段——**固定长度这件事本身**是缺陷,加一段只是把下一次推迟。 + +3. **`.res` 的资源头偏移是 40 不是 32。** 设计文档里我写了「first eight bytes of the resource header」,实际是:32 字节全零头 → dataSize+headerSize(8 字节) → type+name。判据写成字节断言时必须核对偏移,否则断言恒假/恒真。已在代码注释与 e2e 里更正。 + +4. **⚠️ `.rc` 是「mtime 扫描看不见、但改了它图就该长得不一样」的第三个实例**——由 Windows CI 抓到,本机与设计都没预见。 + + 一次只改 `res/app.rc` 的构建报 `Finished dev in 0.15s`:工程级 fast path 短路了整个 prepare。`sources_newer_than` 只扫 `src/**/*` 的 C++ 扩展名,`.rc` 既不在 `src/` 下、也不是那些扩展名。 + + **这不只是「警告没打」**:`.rc` 的 implicit input 集合来自**扫描它**,而扫描在 prepare 里。用户往脚本里加一行 `#include "ids.h"`,那个头文件永远不会被跟踪——ninja 用的是上一次算出来的输入表。 + + 同一个函数里已经为这件事写过两遍理由(`build.mcpp` 一次、#359 的 glob 输入一次),措辞都是「一种 mtime 扫描看不见的输入,而它改变了图应该长什么样」。**这是同一形状的第三次**,而前两次的注释没能让第三次被预见到——因为它们是**举例**,不是**判据**。真正该问的是:「这次新增的输入,改了它以后 prepare 的产出会不会变?会,就必须进这个扫描。」 + + 处置:只扫 `files`。`icon` 与 `extra-inputs` 已经是 ninja 的 implicit input,改它们不改变图的形状,为一次改图标强制走完整 prepare 买不到任何东西。 + +5. **我自己写了两条假绿断言。** 图标断言原本搜 4 字节(`00ff00ff`),在 MB 级二进制里撞上是常事——Linux 上通过很可能就是撞上了,而它同时**掩盖了第 4 条**(Windows 上 b3 本该在这里暴露 fast path 问题)。改成 4 像素 icon 的 16 字节高熵标记,并加一条「旧标记必须消失」。 + 教训与「断言『出现 E0006』是假绿」同族:**断言必须能失败**,而「短模式在大文件里出现」这种断言几乎不可能失败。 + +## E.2 与设计的偏差 + +- **`peUnits` 为空时整节跳过并警告**(设计没提)。一个只产静态库的包声明了 `[resources]`,原设计会去解析 rc 工具并硬失败——为一次没有消费者的编译要求一个工具。 +- **同名 `.rc` 冲突显式报错**。两个不同目录下的 `app.rc` 会写同一个产物,原本会变成 ninja 的 "multiple rules generate",报在离原因很远的地方。 +- **`[resources]` 的路径解析用 `lexically_normal` 而非 `weakly_canonical`**。canonical 会解析符号链接,把与用户所写不同的路径烙进生成的脚本(与 #344 让缓存锚点保持字面判定同一条理由)。 +- **版本号无数值形式时报 degraded**。`synthesize_rc` 是纯函数发不出诊断,所以由 prepare 侧再解析一次并报告:FILEVERSION 会是 `0,0,0,0` 而字符串字段保留真版本,不说的话属性对话框与 `[package].version` 不一致且无从解释。 + +## E.3 验证到什么程度 + +| 判据 | 状态 | +|---|---| +| A1 序号 1 / 元数据可读 | ✅ 本机实测(`.res` 字节 + `llvm-readobj` 双重断言),e2e 197/198 | +| A2 增量 | ✅ 改 icon、改 `[package].description` 都到达 exe;无变更为 no-op | +| A3 非 PE 不适用 | ✅ 198 里用同一份 manifest 构建 host,零警告、无 res 单元 | +| A4 不进 compile_commands / 模块图 | ✅ 结构性(独立 `ResourceUnit`,不入 `CompileUnit`) | +| A5 工具缺失硬失败 | ⚠️ 代码路径有,e2e 未构造(要改 payload 目录) | +| A6 L0→L1 字节相同 | ✅ 198 实测 `cmp` 通过 | +| A7 `.rc` 的 include 被跟踪 | ✅ 单测覆盖扫描;e2e 覆盖 icon/metadata 两类输入 | +| B1 返回值恒为字面键 | ✅ e2e 196 用四种真实索引形状 | +| B2 docking 不可互相替代 | ✅ 单测 + 196 | +| B3 `^1.2.3` 不匹配 `2.0.0-alpha` | ✅ 单测 | +| B4 不可排序键指名错误 | ✅ 单测 + 196 | +| B5 E0006 逐位不变 | ✅ 单测(含 `2026.8.3.3` 对现役下限) | +| B6/B7 lock 真实 + 幂等 + 传递 | ✅ 196;169 也加了断言 | +| B8 lock 头部声明 | ✅ 196 断言(改成权威时会红) | + +**A5 是唯一没有 e2e 的判据**:构造它要临时改动 payload 目录,而 payload 是共享的只读树,e2e 写它会污染其他测试(#293 的教训:一个测试的失败源于上一次运行)。 + +## E.4 本批 CI 覆盖的真实缺口 + +`cross-build-test.yml` 的 `mingw-cross-wine` 是**唯一**有 MinGW 交叉链的 job,而它**按文件名逐个调用 e2e**(不跑 `run_all.sh`)⇒ 新增的 198 必须显式加进 workflow,否则 GNU/windres 这一半在 CI 里一次都不会跑。已加。Windows 原生那一半走 `ci-windows-e2e` 的整套 `run_all.sh`,`# requires: windows` 自动生效。 + +--- + +# F. 实施后 review 轮(同一 PR,合入前) + +对已实施版本做了一次架构 / 稳定性 / 一致性 / 多平台的深度 review,并对每条可疑点做真机复现而不是代码推理。**四条实测确认的缺陷 + 三条口径不一致**,全部在本 PR 内修掉。下面记的是**判据**,不是清单。 + +## F.1 「同一决策两处推导」又出现了一次,而且两处已经不一致 + +`resources.cppm:202` 用 `find_first_of(";:")` 切工具链 PATH 覆盖,`prepare.cppm:5323` 切 INCLUDE 用 `find(';')`——**同一份数据、同一个 PR、两种切法**。 + +`;:` 那一种是错的:msvc 分支只在 Windows 上走,PATH 覆盖由 `msvc.cppm:492` 用 `;` 拼接真实 Windows 路径,而**盘符冒号就在下标 1**。`C:\Windows Kits\…` 被切成 `C` 加一段当前盘相对路径 —— 同盘侥幸命中、跨盘必然找不到。放大它的是:这条 PATH 遍历是 msvc 下的**主路径**,不是兜底(`rc.exe` 属于 Windows SDK,从不在 `cl.exe` 旁边,`probe_dir(compilerDir)` 只答得出 llvm-rc)。 + +⇒ 收敛成一个 `rsrc::split_env_list`,两个调用点共用。**判据:一个 PR 内出现两处相同解析,先合并再讨论哪种对。** + +## F.2 「模型少一层」在本 PR 自己身上复发:`Role::Object` 的默认目标集漏了测试二进制 + +真机复现(Linux/clang22,库代码调用 object 提供的符号): + +``` +mcpp build → rc=0 mcpp run → rc=0 +mcpp test → ld.lld: error: undefined symbol: blob_value +``` + +`image` 只认 `Binary | SharedLibrary`,而 `TestBinary` 是第三种 kind。**这正是 #365 那条毛病的同构体**:表格少一格,后果是用户在图外找路。 + +关键在于**它没有可用的逃生口**:显式 `.target("t_core")` 确实能修(实测通过),但测试链接单元是从 `tests/*.cpp` **发现**出来的,名字不在 `mcpp.toml` 里;而且只写测试目标时 `mcpp build` 直接硬失败(实测:`names unknown target(s): t_core / targets in this build: [objrole]`)。⇒ **目标名字空间是 mode-dependent 的**,这一层设计没有承认。 + +⇒ 默认集合加入 `TestBinary`。`[resources]` 的 `peUnits` 反向决策(排除测试二进制)保持不变,并在两边互相写明理由:**图标属于「要发布的东西」,符号属于「要链接的东西」**。 + +## F.3 F.2 与「未知 target 报错」是耦合的,不能分开修 + +未知名检查挂在 `if (!attached && ...)` 上,于是**只要有一个名字命中,其余拼错的就永不上报**。实测 `.target("objrole").target("no_such_target_TYPO")` → `rc=0`,日志零提及 —— 与 `types.cppm` 和 docs 明写的契约相反。 + +⚠️ **但先修这条会当场废掉 F.2 的唯一逃生口**:`.target("app").target("t_core")` 之所以能在 `mcpp build` 下不报错,靠的正是这个漏洞。⇒ **正确顺序是先给 F.2 一个不依赖具体测试名的表达方式(默认集合),再把检查收紧到 per-target。** 单独修任何一条都会把用户推进另一个坑。 + +## F.4 lock 从「读输入」改成「读输出」,顺带把命令也读进去了 + +真机复现(同一工程交替执行): + +``` +mcpp build → 1 条 mcpp test → 2 条(多了 dev-dep) mcpp build → 又变回 1 条 +``` + +`resolved` 在 `includeDevDeps` 时含 dev-deps,而旧代码遍历 `m->dependencies`(dev-deps 是另一张表)**结构性地写不进去**。196 只断言了 build→build 幂等,抓不到。 + +⇒ `WorkItem`/`ResolvedRecord` 增加 `devOnly` 并沿依赖边传播(**多消费者取 AND**:非 dev 的消费者一出现就清掉),lock 跳过 `devOnly`。**判据:lock 是 manifest 的函数,不是命令的函数。** 196 补 build→test→build 三段 `cmp`,并断言 dev-dep 确实被解析过(否则断言是空转)。 + +## F.5 「不适用」不该覆盖到校验 + +`[resources]` 的「声明了必须存在」整块包在 `if (trip.is_pe())` 里。实测:Linux 上 `icon = "assets/DOES_NOT_EXIST.ico"` + `files = ["res/nope.rc"]` → `rc=0`,日志里 `resource` 出现 **0 次**。 + +⇒ Linux/macOS 的开发机与 CI **结构性地**抓不到打错的资源路径,只有 Windows job 会红 —— 这正是这条硬错误要消灭的「太晚才知道」。**路径存不存在是关于工作树的事实,不是关于目标的事实。** 校验提到 `is_pe()` 之前,编译留在后面。新增 `199_resources_validation.sh`(无 `requires`,每个 shard 都跑)守这条。 + +## F.6 两条 warning 应当是 degraded + +`diag.cppm` 的规矩:`degraded` 带 impact 且 `--strict` 会失败;`warning` 是作者笔误 / schema 漂移。按这条口径,`resources/versioninfo`(你的 VERSIONINFO Windows 读不到)和 `resources/no-image`(声明了却没有任何东西嵌)都是「你要了 X,得到的是零」,应当是 degraded —— 尤其前者正是本 feature 要消灭的静默失效,`--strict` 抓不到它说不过去。`role="object"` 无消费者是同一形状,新增 `action/no-target`,与 `resources/no-image` 同口径。 + +## F.7 优雅性 + +- `} else {` 之后约 150 行**完全不缩进**、靠底部 `} // peUnits non-empty` 收尾 —— 在一个对形式如此讲究的仓里是「这块该抽出去」的直接信号。改成带早返回的 `plan_resources` lambda。 +- `ResourceUnit::packageName` 写入后全仓无人读取,删除。 +- `LinkUnit::objects` 注释写「relative to plan.outputDir」而 `Role::Object` 往里塞绝对路径 —— 有理由(ninja 按字面串识别节点),但字段契约本身要改口径,否则下一个人会「顺手规范化」。 +- `resolve_semver` 的字面短路对**任意**约束生效而不只 `=` 前缀。今天安全,靠的是三个调用方都先过 `is_version_constraint` 门——但**那个判据没有写进这个函数**。裸 `1.2.3` 在本语法里是 caret,哪天直达此处,caret 会静默变成精确 pin。收紧成必须 `=` 前缀。 +- `try_merge_semver` 在两个约束相同时不再拼成 `=X,=X`(防御性:prepare 只在两个**已解析版本**不同时才走到这里,而相同约束解析结果相同 —— 但它挡的失败是静默且彻底的)。 + +## F.8 测试自身的假绿 + +- `_windows_resources_body.sh` 里 `.res` 的序号字节判据包在 `case *.res` 中,而 windres 产 `.o` ⇒ **GNU 那一半的核心断言可能一次都不跑**;后备的 readobj 检查也是「grep 不到就跳过」。补一条方言无关的判据(字符串名资源会把 UTF-16 名字写进资源目录,序号名不会),并在 A3-lint 段加**正向反证**:故意构造坏脚本,断言它确实产生了那个 UTF-16 名字 —— 否则「名字不存在」可能因任何理由通过。 +- 改写 `res/app.rc` 那一步前缺 `sleep 1`,而该段**唯一**的重跑 prepare 触发源就是 `.rc` 的 mtime。其余每处 mtime 敏感改动都有。 + +## F.9 方法论 + +**每一条都先真机复现再下判断,没有一条是纯代码推理。** 复现同时充当「断言能失败」的证明:五条新断言各自对应一段用**修复前**二进制跑出来的、看得见的错误输出。这比事后再造一个反向用例更便宜也更可信。 + +## F.10 CI 抓到的第三条:判据选了一个那条 job 拿不到的工具 + +改完 F.8 的判据后本机 198 通过,CI 的 `mingw-cross` job 却红在: + +``` +FAIL: llvm-readobj not found under /home/runner/.mcpp +``` + +那条 job 只装 mingw 工具链,**sandbox 里根本没有 LLVM 载荷**——而本机什么都装着,所以本机永远看不见。 + +**硬失败本身是对的**(静默跳过正是 #365 的出厂方式),错的是**判据依赖了一个不属于这条路径的工具**。正解:`windres -J coff -O rc` 把编译好的资源**反读成 rc 源码**,名字直接可见,而且它在 GNU 这一支**必然存在**——它就是产出这个文件的工具。实测 `.o` 与链接后的 `.exe` 都能读: + +``` +1 VERSIONINFO ← Windows 找得到 +"VS_VERSION_INFO" VERSIONINFO ← 找不到 +``` + +rc 工具从 `build.ninja` 的 `rc =` 绑定里取而不走 PATH——mcpp 本来就是 payload 相对解析的(裸 `windres` 在 PATH 上是 xlings shim),问 PATH 会用**另一个**工具去检查这个产物。msvc 那一支保留 llvm-readobj:能走到那一支的配置里,LLVM 载荷就是默认工具链本身。 + +**判据:一条断言要用的工具,必须来自它所断言的那条路径本身。** 「本机装得全」是最容易把环境假设藏起来的地方——三次改判据里,前两次都是本机绿、别处红。 + +## F.11 第四条:`*windres*` 这个名字什么都不告诉你 + msvc 那一支其实零 CI 覆盖 + +F.10 改成 windres 反读后,mingw job 绿了,**Windows job 红了**: + +``` +FAIL: windres could not read back .../res/resapp.mcpp.o +``` + +两件事同时暴露: + +1. **`llvm-windres` 不能反读。** 它只是 `llvm-rc` 的单向包装,没有 `-J coff`。而它和 GNU binutils 的 `windres` **都匹配 `*windres*`** ⇒ 按工具名分派是错的。修法:**两条路线依次 TRY**(反读 → llvm-readobj),只有两条都不可用才硬失败。 +2. **native Windows 上默认工具链走的是 GNU 支,不是 msvc 支。** 证据在失败信息里:target 目录是 `x86_64-windows-msvc`,而产物是 **`.o` 不是 `.res`** ⇒ `dialect_for(tc).id != "msvc"`,`find_rc_tool` 取了 GNU 分支并选中 `llvm-windres.exe`,lld-link 照单全收。 + +**⚠️ 推论:`.res` 那一支(rc.exe / llvm-rc + 偏移-40 字节判据)在两个 CI job 里都不执行。** mingw job 是 GNU,Windows job 也是 GNU。也就是说: + +- e2e 里 `case "$RES_ART" in *.res)` 是**死分支**——这也正是为什么「方言无关的那条判据」不是锦上添花而是唯一的那条。 +- **F.1 修的那个 PATH 切分缺陷(msvc 下找不到 `rc.exe`)没有任何 CI 覆盖**,只有单测 `EnvListSplitsOnSemicolonsOnly` 守着字符串切分本身。要真正覆盖它需要一个 `# requires: msvc` 的资源 e2e(`msvc@system` 工具链),本批不做,**明确记为缺口**。 + +**判据:「本机/某个 job 绿」不等于「这条分支跑过」。判断一条 fork 是否被覆盖,要看产物形态(`.res` vs `.o`),不能看 job 名字里有没有 windows。** diff --git a/.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md b/.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md new file mode 100644 index 00000000..92376ade --- /dev/null +++ b/.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md @@ -0,0 +1,597 @@ +# xlings 作为 mcpp 的运行时底座:运行时身份、链接契约与环境契约 + +**日期**:2026-08-07 +**性质**:设计提案,待 review 后再实施。本文不改任何代码。 +**触发**: +- mcpp#375(产物烙私有 glibc PT_INTERP ⇒ 不可分发;系统 loader 包装 ⇒ `/proc/self/exe` 失效) +- mcpp#352(GLFW/OpenGL 静默 exit 255:沙箱 glibc 2.39 加载不了宿主 Mesa) +- xlings 侧新能力已落地:glibc 2.44、22 包 hermetic 图形栈(`xim:graphics`)、subos 自描述(`subos_info`) + +**关联(xlings 仓)**: +- `.agents/docs/2026-08-05-ecosystem-three-tier-and-composable-distro.md` —— 三分层定位,**开放问题 K:mcpp 消费 subos 还是消费 platform 描述**。本文回答它。 +- `.agents/docs/2026-08-06-subos-architecture-proposal.md` —— 七条规则 R1–R7,本文全程引用 +- `.agents/docs/2026-08-05-userspace-distro-hermetic-strategy.md` —— 运行时边界 + +**关联(mcpp 仓)**: +- `.agents/docs/2026-08-02-issue336-pr142-analysis.md` —— `cxx_runtime` 契约层。**本文是它在 libc 轴上的同族补全。** +- `.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md` —— `linkmodel.cppm` 的由来 + +--- + +## 0. TL;DR + +**一句话根因:mcpp 把 xlings 当成「一套需要自己反推的目录约定」,而不是「一个可以查询的运行时」。** + +后果是可预测的:xlings 每前进一步,在 mcpp 侧都落成**一次改代码**,而不是**一次改数据**。glibc 2.44 如此,图形栈如此,下一个能力也会如此。 + +三条缝,按依赖顺序: + +| | 缝 | 现状 | 目标 | +|---|---|---|---| +| **S1** | **运行时身份** | 从目录布局反推「哪个 libc」,且**无版本** | 读 subos 的 `subos_info.runtime`(`glibc@2.39`),成为一等轴 | +| **S2** | **分发路径** | 三条 hermetic 路径都存在,但用户找不到;其中一条自己是坏的 | 修坏的那条 + 让三条可发现。**不加「链宿主 libc」的开关** | +| **S3** | **环境契约** | mcpp 产物拿不到 subos 的 env,图形栈靠 mcpp 代码硬扛 | 消费 `subos_info.envs`;图形栈以**依赖**而非代码到达 | + +**关键结论:P0 需要 xlings 零改动。** `subos_info` 已经存在,subos view 已经填充好(`subos/default/lib/ld-linux-x86-64.so.2` 实测在位)。唯一需要 xlings 配合的是**把 installer 已经算出来、当前只活在进程里的 `resolved_deps`/`deps_exports` 持久化**——那是 P1。 + +**多维评估见 §11**(实现代价 / 用户 / 稳定性 / 跨平台 / 简洁 / 兼容性,每项带实测数字)。三条要点: + +- **本文被推翻过一整节**(§3-S2):初稿要补一条 `c_runtime` 轴,其 `host-coupled` = 链宿主 libc。已实现、全绿、然后整条撤销。理由与教训写在那一节,**比结论更值得读** +- **零 BMI/对象缓存失效** —— fingerprint 是 compile-side,分发相关的改动都只碰链接与打包 +- **收益 ≈ 全在 Linux**;#352 的图形栈迁移是其中收益最大、代价最小的一条 + +--- + +## 1. 实测现状(不是推断) + +本节每一条都在本机跑过,命令附在后面,便于 review 时复核。 + +### 1.1 产物里烙进了四个独立的版本 pin + +``` +$ mcpp new hello && cd hello && mcpp build +$ file target/x86_64-linux-gnu/*/bin/hello +… interpreter /home/speak/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2 +$ readelf -d target/x86_64-linux-gnu/*/bin/hello | grep RUNPATH + RUNPATH [ …/xim-x-llvm/22.1.8/lib/x86_64-unknown-linux-gnu + : …/xim-x-llvm/22.1.8/lib + : …/xim-x-gcc-runtime/15.1.0/lib64 + : …/xim-x-glibc/2.39/lib64 ] +``` + +一个 hello world 产物同时钉死了:**home 绝对路径**、**glibc 2.39**、**llvm 22.1.8**、**gcc-runtime 15.1.0**。 + +这正是 xlings `libs/graphics.lua:47-54` 刚刚明文废弃的反模式: + +> `${pkgdir}` 是声明包自己的载荷,它**钉死一个版本目录**:升级 mesa 会让消费者记录的 env 指向旧的那个。subos 视图才是稳定的间接层——就是 `/run/opengl-driver` 在 NixOS 扮演的角色。 + +mcpp 烙的就是 `${pkgdir}` 等价物。 + +### 1.2 稳定间接层已经存在,而且已经填充好 + +``` +$ ls -l ~/.mcpp/registry/subos/default/lib/ +ld-linux-x86-64.so.2 -> …/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2 +crt1.o crti.o crtn.o -> …/xim-x-glibc/2.39/lib64/… +libc.so.6 libc.so libm.so.6 … -> …/xim-x-glibc/2.39/lib64/… +libc++.so.1 libc++abi.so.1 -> …/xim-x-llvm/22.1.8/lib/… +libatomic.so.1 libasan.so.8 -> …/xim-x-gcc/16.1.0/lib64/… +``` + +loader、CRT、libc、C++ 运行时**全部在视图里**,按活动版本指向载荷。mcpp 一个都没用。 + +> ⚠️ 注意布局差异:载荷里 loader 在 `lib64/`,视图里在 `lib/`。mcpp 现有的 `{lib64, lib}` 顺序探测(`probe.cppm:346-349`、`linkmodel.cppm:249-258`)**碰巧两边都能命中**——因为它回退到 `lib`。但「碰巧能命中」正是本文反复要指出的那类脆弱:两个布局不同的树共用一份约定探测,靠的是回退顺序恰好合适。视图新增一个 `lib64` 就会翻。这条要在 P0 验证门里钉住(V5)。 + +### 1.3 mcpp 的 sandbox subos 不描述自己 + +``` +$ python3 -c "import json;d=json.load(open('$HOME/.mcpp/registry/subos/default/.xlings.json'));print(list(d.keys()), len(d['workspace']))" +['workspace'] 356 +``` + +356 个版本条目,**零运行时身份、零环境声明**。没有 `subos_info` 块。 + +而 xlings `src/core/subos/manifest.cppm:16-20` 的模块注释,点名了这件事: + +> 一个程序需要三样:bootstrap(PT_INTERP + CRT + libc)、discovery(PATH + RPATH)、configuration(env vars)。xlings 有前两样——glibc + elfpatch、xvm + shims——第三样什么都没有。**That is the gap behind mcpp-community/mcpp#352**:一个 GLFW 二进制链接得好好的,exit 255,因为没人告诉它 GL 驱动在哪。 + +xlings 已经把这个缺口补上了(`subos_info`,schema 1)。**mcpp 的 subos 没有拿到它。** + +### 1.4 xlings 的权威答案只活在进程里 + +`src/core/xim/installer.cppm:2394-2450` 已经为**每一个** runtime 依赖计算并记录了完整档案(遵守 R1「权威记录必须是全量的」),包括 `install_dir`、`libdirs`(`{lib64, lib}` 约定**只在这里**应用一次)、`loader`、`abi`。 + +但落到磁盘上的只有: + +``` +$ cat ~/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/.xpkg-install.json +{ "os": "linux", "version": "2.39", "xlings_version": "2026.8.2.1" } +``` + +三个字段。`exports` 一个都没落盘。 + +这解释了 mcpp `linkmodel.cppm:182-188` 当年为什么拒绝它: + +> 第三个来源——installer 写的持久化 `.xpkg-exports.json` 声明元数据——评估后移除了:它唯一的消费者只有这个 resolver,而上面两个来源已经覆盖了每一个真实载荷。 + +**那个判断在当时是对的,今天不再对**。理由见 §2.2。 + +### 1.5 图形栈:mcpp 侧仍是宿主借用 + +mcpp-index `pkgs/c/compat.glx-runtime.lua` 至今把宿主 `/usr/lib/x86_64-linux-gnu/libGL.so.1` 等 symlink 进 `mcpp_generated/glx_runtime/lib`,再经 `[runtime] library_dirs` 进 RUNPATH(`flags.cppm:698-706`)。 + +这就是 #352 的直接成因:宿主 Mesa 要 `GLIBC_2.43`,载荷 glibc 是 2.39。而 hermetic 策略已经把这个文件点名为**要淘汰的样本**。 + +同时,xlings 侧的替代品**已经可用**:`xlings install graphics` —— 22 包 hermetic 栈 + NVIDIA/WSL2 哨兵,一条命令五种宿主形态,无条件分支。 + +### 1.6 `/proc/self/exe` 陷阱不是用户的 workaround —— 是 mcpp 自己在生产它 + +这是本次调研里最出乎意料的一条,值得单独列。 + +#375 描述的第三条症状(用 `ld.so --library-path` 启动后 `/proc/self/exe` 指向 loader)读起来像是用户自己想出来的绕法。**但 mcpp 的 `self-contained` 打包模式生产的就是这个 wrapper**,`docs/02-pack-and-release.md:120-127` 原样写着: + +```sh +exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myapp" "$@" +``` + +文档给的理由是对的(ELF 规范禁止 `PT_INTERP` 用 `$ORIGIN`),但**后果没有写**:凡是用 `mcpp pack --mode self-contained` 分发的程序,`/proc/self/exe` 全部指向 loader,`/proc/self/cmdline` 全部混入 `--library-path`。所有「在 exe 旁边找资源」的逻辑静默失效——字体、assets、随包分发的辅助二进制。 + +而 mcpp 自己就依赖这个机制:`src/platform/fs.cppm:106` 读 `/proc/self/exe` 定位自身。 + +全仓 grep `proc/self/exe` 只有那一处实现,**文档里零处提及这个陷阱**。所以 #375 请求的两条文档补充里,(a)「私有 glibc 对分发的影响」其实已经写了(`02-pack-and-release.md:3-6` 开篇就是),(b)「系统 loader 包装后的 `/proc/self/exe` 陷阱」**确实没有,而且比提问者以为的更严重**——它不是一条使用建议,是一个在售模式的已知缺陷。 + +--- + +## 2. 根因 + +### 2.1 一个问题,mcpp 侧有 N 个回答者 + +「C 运行时在哪、loader 是哪个、哪些目录进 RUNPATH」这一个问题,mcpp 侧今天有六个独立推导: + +| # | 位置 | 怎么答的 | +|---|---|---| +| 1 | `probe.cppm:333-349` | `find_sibling_tool(compilerBin,"glibc")` 爬父目录 → `lib64` 不存在则 `lib` | +| 2 | `linkmodel.cppm:194-243` | 五行 arch→loader 文件名硬表,不中则 glob `ld-*.so*` | +| 3 | `linkmodel.cppm:215-221` | `distro_loader_path`:x86_64 → `/lib64/`,其余 → `/lib/` | +| 4 | `post_install.cppm:162-200` | 扫 clang cfg 文本找 `/ld-linux-` 反推烙定的 loader | +| 5 | `pack.cppm:650-653` | 从 `ldd` 输出取 loader soname,再拼 `/lib64/` 或 `/lib/` | +| 6 | `probe.cppm:190-223` | `lib` / `lib64` / `lib/` 三种约定拼 runtime 目录 | + +这**正是** xlings 侧 `2026-08-06-subos-architecture-proposal.md` §1 诊断出的 P1「一个问题有多个回答者」,只是发生在河的下游。而 xlings 已经从自己那边删到了一个(R2:约定只在写端应用,读端永远不猜)——mcpp 侧还有六个。 + +判据(R3):**修复如果是「增加一条路径」而不是「移除一条」,它是 workaround。** 下面的设计要能删掉其中的 4–5 条,否则不合格。 + +### 2.2 为什么「拒绝声明元数据」的判断需要翻转 + +`linkmodel.cppm` 当年的理由是「唯一的消费者只有这个 resolver」。今天的消费者清单是: + +| 消费者 | 需要什么 | 约定能答吗 | +|---|---|---| +| 链接模型 | loader、libdirs、CRT 目录 | 能(勉强) | +| **运行时身份**(新) | glibc **版本**、ABI family | **不能**——目录名不是契约 | +| **subos 视图寻址**(新) | 视图里的 loader 路径(在 `lib/`,载荷在 `lib64/`) | **靠回退顺序碰巧能**(§1.2)——即两个不同布局共用一份猜测 | +| **图形/env 契约**(新) | 哪些 env、哪些值 | **不能** | +| `mcpp pack` | 目标机 loader | 能(硬表) | +| `mcpp doctor` | 悬空链接、版本偏斜 | 部分 | + +从一个消费者变成六个,而新增的三个**约定原理上答不了**。R2 的判据在这里是决定性的:一句「没有就自己猜」等于授权每个读端各自实现一份猜测,而读端会随时间增加——mcpp 侧已经从 1 增加到 6。 + +### 2.3 缺的那一层:libc 轴的契约层在错误的生命周期上 + +这是最关键的一条,而且 mcpp 自己的代码已经把它写出来了。 + +`src/build/distribution.cppm:73-77`: + +> **NOTE ON SCOPE**:契约管的是 C++ 运行时(stdlib + 它的 ABI 与 unwinder)。**libc 轴是分开的,归 `linkage`/`--static` 管**,部署地板是第三条轴。 + +三层模型 `Role → Contract → Mechanism` 在 C++ 轴上是完整的: + +``` +Role Distributable | Test | Intermediate ← 内在,用户写不了 +Contract SelfContained | ToolchainCoupled | HostCoupled ← [build] cxx_runtime +Mechanism (contract × stdlib × 二进制格式) → 链接 flags ← total function +``` + +**libc 轴上只有 Mechanism 那一层,而且分散在两个生命周期**:构建期 `--static`,打包期 `--mode`。用户在构建期能说的只有机制,说不了意图(「这个产物要能在没装 mcpp 的机器上跑」)。 + +而 #336 的分析文档写清了这个形状为什么必然出错: + +> 那个 bool 拼的是**机制**(「静态链接 stdlib」),它膨胀成三种不同的平台含义——包括在 Linux/libc++ 上静默无操作:声称 static、产出 toolchain-coupled。契约拼的是**意图**。 + +**这个观察是对的,但从它推出的结论曾经是错的。** 初稿由此推出「那就补一条 `c_runtime` 轴」——而这条轴唯一的新能力是把产物链到宿主 libc,即策略上被禁的那一侧。**一个真实的架构不对称,不构成做一件被禁的事的理由。** 正确的读法见 §3-S2:用户要的是「能分发」,而 hermetic 的分发路径已经有三条。 + +这一段保留,是因为那个不对称本身仍然真实,只是它的正确用途是**解释为什么 `mcpp pack` 的默认模式是宿主耦合的**(§3-S2 末尾那笔记账),而不是给 build 期再开一个口子。 + +**准确说,不是「没有机制」——是机制在错误的生命周期上。** `mcpp pack` 有一个成熟的两轴模型(`docs/02-pack-and-release.md:8-37`:target(libc)× mode(bundling depth),四个模式 `system` / `vendored` / `self-contained` / `static`),`vendored` 默认就会把 PT_INTERP 重指到 `/lib64/ld-linux-*.so.2`(`pack.cppm:649-658`)。所以 **#375 的第 1、2 条症状今天有受支持的答案**。 + +真正的不对称是这个: + +| 轴 | 契约在哪一期可声明 | 怎么实现 | +|---|---|---| +| C++ 运行时 | **构建期**,`[build] cxx_runtime` | 链接时给对 flags | +| C 运行时 / loader | **只有打包期**,`mcpp pack --mode` | 链接后用 patchelf 改写 | + +同一类决策(「产物对运行它的机器承诺什么」),一条在构建期声明、一条只能在打包期补救。后果有三个,都是实测的: + +1. `mcpp build` 的产物看起来像交付物、其实不是,而**这件事只有读到 pack 文档才知道**——#375 的提问者没走到那一步,直接去套了 `ld.so` 包装 +2. `mcpp run` 跑的永远不是将要分发的那个配置,所以分发问题只能在 pack 之后才暴露 +3. patchelf 改写是**事后**的:它改得了 PT_INTERP 与 RUNPATH,改不了「链接时就该按目标契约选 RUNPATH 形状」这件事,于是 `self-contained` 只能退回 wrapper 脚本——**即 §1.6 那个缺陷的来源** + +> **推论(值得单独强调)**:`/proc/self/exe` 那条症状**不是私有 glibc 的后果,是 wrapper 的后果**;而 wrapper 是「契约无法在链接期表达」的后果。只要 PT_INTERP 指向一个在目标机上真实存在的路径,就不需要 wrapper,`/proc/self/exe` 自然正确。**修契约,第三条症状自己消失——不需要为它单独设计任何东西。** + +### 2.4 R6:mcpp 两个用途都绑了载荷,而只有一个该绑 + +xlings 侧 R6:**内部消费者绑定 payload,不绑定视图。** 三层的消费者不同: + +| 层 | 谁该消费 | mcpp 今天 | +|---|---|---| +| payload `data/xpkgs//` | xlings/libxpkg 自身;**构建期**的 flags | ✅ 用了(正确) | +| subos 视图 `subos//{bin,lib,usr}` | 用户,以及**用户运行的程序** | ❌ 没用 | +| 每个产物的 RPATH/INTERP | 动态加载器 | ❌ **烙的是 payload** | + +mcpp 在**构建期**绑载荷是对的(版本精确、fingerprint 稳定、R6 合规)。 +mcpp 在**运行期**绑载荷是错的——那一层的正确锚点是视图。 + +**一句话:mcpp 用一个地址服务了两个生命周期不同的用途。** 这是所有三个症状的公共上游。 + +--- + +## 3. 设计 + +### S1 — 运行时身份(RuntimeBinding) + +**mcpp 停止反推「哪个 libc」,改为读。** + +新增一条一等轴 `RuntimeBinding`,词汇与 xlings **逐字一致**(`glibc@2.39` / `musl@1.2.5` / `macos_sdk@14.0` / `ucrt@…`),而不是新造一套。 + +解析优先级(**每一级都必须显式,不允许「缺省即约定」**——A2): + +1. `--runtime ` CLI +2. `[target.].runtime` / `[build].runtime` +3. **活动 subos 的 `subos_info.runtime`** ← 新的权威源 +4. 载荷实测(仅当 subos 无 `subos_info`,即老 subos 的降级路径,且**必须打一行可见提示**) + +配套:`abi.cppm:71-106` 的 `libc` 维度从**无版本**(`"glibc"`)升级为**带版本**(`glibc@2.39`),`family_of` 的映射与 xlings `manifest.cppm:81-92` 对齐——但**只在一处推导**。 + +> **这条同时解掉一个隐性重复账**:mcpp `abi_profile()` 从 target triple 推 `*-linux-gnu → glibc`,xlings `family_of()` 从 runtime 推 `glibc@2.39 → linux-x86_64-glibc`。同一个概念两处推导,而 mcpp 那份没有版本——所以 mcpp 今天**表达不了**「这个依赖需要 glibc ≥ 2.39」。多 glibc 一到,它就是下一个 bug 源。 + +**删除**:`probe.cppm` 的 glibc 目录爬升与 `{lib64,lib}` 约定(回答者 #1)。 + +### S2 — 分发路径:修好坏的那条,让三条可发现 + +> **⚠️ 这一节被整节推翻过一次,推翻的理由比结论更重要。** +> +> 初稿在这里提出补一条 `[build] c_runtime` 轴,三值与 `cxx_runtime` 相同,其中 `host-coupled` = 把产物链到**宿主的 libc**。它已经实现并通过了全部测试,然后被整条撤销(commit 已 reset)。 +> +> **撤销理由一:那是照着 issue 提的实现方法做,不是解决 issue 的问题。** #375 的标题写着「让要分发的应用链到系统 libc」——那是提报者**已经想好的解法**。他的**问题**是「产物没法分发」。照着解法做,等于把提报者的方案当成需求。 +> +> **撤销理由二:它穿越了这个生态存在的意义所在的那条边界。** hermetic 策略明文列出禁止穿越项,第一条就是「任何 `/usr/lib*` `/lib*` 下的 `.so`(含 libc)」。xlings 是用户态发行版;一个伸手去 `/lib64` 取 libc 的产物已经不在这个发行版里了。**mcpp 能不用 host 就不用 host。** +> +> **撤销理由三:去掉 `host-coupled` 之后它不剩任何新能力。** `self-contained` 已经由 `--target x86_64-linux-musl` 表达,`toolchain-coupled` 是现状。也就是说这条轴的**全部增量就是那个不该有的值**。 +> +> 保留这段记录,是因为「同一个东西以后还会被再提一次」——下次有人拿 #375 说「加个链系统 libc 的开关吧」,这里有现成的答案。 + +**用户的真问题是「产物没法分发」,而它有三条 hermetic 答案,全都已经存在**: + +| 路径 | 命令 | libc 从哪来 | 适用 | +|---|---|---|---| +| **A. 生态闭环** | `mcpp emit xpkg` → `xlings install` | 目标机自己的 xlings 载荷,**elfpatch 在装机期重指 PT_INTERP/RUNPATH** | 目标机在生态内 | +| **B. 静态单文件** | `--target x86_64-linux-musl` | 自带,静态链接 | 任何 Linux,无任何运行期依赖 | +| **C. 自带运行时** | `mcpp pack --mode self-contained` | 自带这套工具链的 glibc + loader | 任何 Linux,含比构建机更老的 | + +**A 是这个生态真正的答案**,而且它解释了一件容易被误读的事:产物里烙的那个 `PT_INTERP` 指向构建机路径**并不构成分发障碍**——走 A 时目标机的 xlings 会重写它。#375 观察到的「目标机上路径不存在所以起不来」,前提是**绕开生态直接拷贝二进制**。 + +所以缺口不在机制,在**可发现性**,外加 **C 这条路自己是坏的**(§1.6)。 + +**要做的两件事,都不新增穿越宿主的能力:** + +1. **修 C**(§1.6 已实施):`self-contained` 的 wrapper 打坏 `/proc/self/exe`。这是三条 hermetic 路径里唯一一条自己有缺陷的,而它恰好是「目标机没有 xlings 又不想静态链接」时的那条。 +2. **让三条可发现**:`mcpp pack` 与 `mcpp build` 的文档把这三条并列写清楚,`self-contained` 在 glibc 上不可行时的诊断**逐条列出这三条**,而不是只说「不行」。一个只说「不行」的诊断会把用户推向他自己能想到的办法——而那个办法通常就是宿主 libc。 + +**明确不做**:不新增任何让 `mcpp build` 产出链宿主 libc 的开关。已有的那个决定只有一个入口(`[build] allow_host_libs` / `MCPP_ALLOW_HOST_LIBS`),再开第二个就是「同一决策两处推导」,而且这一次推导出来的是策略上被禁的那一侧。 + +> **顺带记一笔既有账**(不在本轮改):`mcpp pack --mode vendored` 是 pack 的**默认**模式,而它把 PT_INTERP 重指到 `/lib64/ld-linux-*.so.2`——即默认打包路径本身就是宿主耦合的。这与 hermetic 策略不一致,但改默认会破坏既有用户,需要单独评估。先记在这里。 + +### S3 — 环境契约(subos 一等公民) + +**mcpp 产物需要的第三样东西(configuration)由 subos 提供,mcpp 消费它,而不是自己实现。** + +三件事: + +1. **写**:mcpp 初始化 sandbox 时确保 subos 有 `subos_info` 块(今天没有,见 §1.3)。不自己造 schema——调 `xlings self init` 的对应路径,或按 schema 1 写。**权威源是 xlings,mcpp 只保证它存在。** + +2. **读并应用**:`mcpp run` / `mcpp test` 把 `subos_info.envs` 的**解析结果**(`${subosdir}` 展开后)应用到子进程,而不是今天只设 `LD_LIBRARY_PATH`(`execute.cppm:285`)。 + `LIBGL_DRIVERS_PATH` / `__EGL_VENDOR_LIBRARY_DIRS` / `XDG_DATA_DIRS` 由此**自动**到位——mcpp 侧一行图形相关的代码都不写。 + +3. **烙进分发物**:`mcpp pack` 把同一份解析结果写进 launcher —— **仅 `toolchain-coupled`**(目标机上有这个 subos);`host-coupled` 不写,因为目标机没有 subos,那里的图形栈是宿主的事。 + +**图形栈以依赖到达,不以代码到达**: + +- mcpp-index 的 `compat.glx-runtime` **废弃**,改为依赖 `xim:graphics` +- 触及 GL 的包(`compat.glfw` / `compat.opengl` / `compat.imgui` / …)声明能力需求,由 capability → xlings 包的解析落到 `xim:graphics` +- 这是 §4 那条一般原则的第一个实例:**xlings 加 Vulkan loader / 换驱动桥 / 支持新宿主形态,mcpp 侧零改动** + +--- + +## 4. 「未来 xlings 升级、mcpp 自动适配」的一般原则 + +用户的要求里最重要的一条不是修 #375,是「**以后 xlings 环境升级,mcpp 能很简单地动态适配**」。上面三条缝各自解一个症状,而让它们不再复发的是下面这条规则: + +> **凡是 xlings 拥有的事实,必须经由恰好一条数据通道到达 mcpp;mcpp 侧对该事实的再推导数必须为 0。** + +三条通道,按**生命周期**划分(不是按内容划分——这是关键,内容会变,生命周期不会): + +| 通道 | 载体 | 生命周期 | mcpp 何时读 | 承载什么 | +|---|---|---|---|---| +| **C1 载荷事实** | `/.xpkg-install.json`(需扩展) | 不可变,随版本目录 | 构建期(prepare) | loader、libdirs、abi、CRT 目录 | +| **C2 subos 事实** | `/.xlings.json` 的 `subos_info` | 可变,随 `xlings use` | 构建期取身份 + 运行期取 env | runtime 身份、env 声明 | +| **C3 宿主能力** | xlings 依赖图里的哨兵包 | 装机期探测 | **不读** | NVIDIA / WSL2 / 无 GPU | + +**C3 的正确做法是「什么都不做」**,这条值得单独说:`xim:graphics` 的哨兵机制(`pkgs/g/graphics.lua`)——每个哨兵探测一个自己不拥有的宿主侧半边,不在就「链接了零个东西然后成功返回」——意味着**「这台机器没有那个」是一个正常返回值,不是一个分支**。所以 mcpp 侧永远不需要写 `if (nvidia)`。任何时候如果 mcpp 里出现了探测宿主 GPU/图形能力的代码,它就是这条原则的违反。 + +**版本偏斜必须可检测,不能静默**:C1/C2 各带 `schema_version`。mcpp 读到不认识的高版本 → 明确降级 + 一行提示;读到缺失 → 走降级路径 + 一行提示。**沉默成功是 xlings 侧诊断出的横切属性(「『没发生』和『成功了』输出相同」),mcpp 侧不要复制它。** + +> 这里有一条来自 xlings 侧的硬教训需要照抄(`libs/sysroot.lua:38-47`):**「版本地板写成数据」永远到不了需要被告知的那些客户端**——能读这个字段的客户端恰恰是不需要被告知的那些。所以 mcpp 读 C1/C2 时,能力缺失的处置必须是**运行时可见的一行输出**,不能是一个只有新版本才会检查的字段。 + +--- + +## 5. 方案对比 + +### 5.1 mcpp 怎么拿到 xlings 的事实(§4 C1 的实现形态) + +| | A. 直接读 xlings 的磁盘文件 | B. 调 xlings CLI 查询 | **C. xlings 落一份版本化契约文件,mcpp 读** | +|---|---|---|---| +| `subos_info` | ✅ 已存在,可直接读 | 需要新子命令 | ✅ 已存在 | +| 载荷 `exports` | ❌ **没落盘** | 需要新子命令 | 需 xlings 小改(值已算出) | +| 离线 | ✅ | ⚠️ 取决于实现 | ✅ | +| 热路径开销 | 零 | ❌ 每次 prepare 起子进程 | 零 | +| 版本偏斜 | mcpp 复制 schema,易漂 | ❌ **旧 xlings 没有新子命令**,而沙箱 xlings 出了名地不刷新 | schema_version 显式 | +| 违反哪条规则 | 部分违反 R2(读端仍需知道布局) | —— | 无 | + +**推荐 C,以 A 为降级路径,B 永不进热路径。** + +理由:写端(installer)**已经**把值算全了(`installer.cppm:2394` 的注释明说「Recorded for EVERY runtime dep, declared exports or not」,正是 R1),持久化几乎零成本,而它一次性删掉 mcpp 侧四个再推导。B 在这个生态里有一个具体的、已被记录的失败模式——沙箱里的 xlings 版本长期落后于 pin,一个「新子命令」在最需要它的机器上恰好不存在。 + +**分期上这个选择是无痛的**:`subos_info`(C2)已经在盘上,所以 **S1 与 S3 的 P0 完全不依赖 xlings 改动**;只有 C1 的 `exports` 持久化需要跨仓协作,排 P1。 + +### 5.2 为什么不补 `c_runtime` 这条轴 + +这一节记录一个**被否掉的方案**,因为它已经实现过一遍,而且看起来很有说服力。 + +| | A. 补 `c_runtime`(含 `host-coupled`) | **B. 不补,修好并指明三条 hermetic 路径** | +|---|---|---| +| 解决「产物没法分发」 | ✅ | ✅ | +| 需要新概念 | 一个 manifest 键 + 一张机制表 + 一条跨层枚举镜像 | 零 | +| 是否新增穿越宿主的能力 | **是** —— 这正是它的全部增量 | 否 | +| 与 hermetic 策略 | **冲突**(禁止穿越第一条就是 `/lib*` 下的 libc) | 一致 | +| 「可不可以用宿主 libc」的回答者数 | **2**(`allow_host_libs` + 新键) | 1 | + +**选 B。** A 有三个独立的致命处,任何一个都够: + +1. **它是照着 issue 的解法做,不是解 issue 的问题**(§3-S2 撤销理由一) +2. **它穿越了这个生态存在的意义所在的边界**(理由二) +3. **去掉那个值之后它不剩任何能力**(理由三)—— 也就是说 A 列那些 ✅ 全都不是 A 独有的 + +值得单独记下:A **已经通过了全部测试** —— 12 个契约表单测、7 个渲染单测、一条覆盖五个断言的 e2e(含「默认不变」与「拒绝要出声」)。**测试全绿不能告诉你这个功能不该存在。** + +### 5.3 `toolchain-coupled` 用载荷寻址还是视图寻址 + +> **本节的推荐在评估阶段被下调过。** 初稿推荐视图寻址;查到 `doctor.cppm:369-387` 之后改为「默认不换,除非配套做完两件事」。理由如下。 + +**新证据**:mcpp 自己的 doctor **已经在检查 subos 视图悬空**,并且写下了成因: + +> Dangling symlinks under `registry/subos/default/lib` — these point into xim payload lib dirs; **a removed package leaves them broken**。 + +也就是说「视图会悬空」不是假想风险,是 mcpp 已经观测到并专门写了检查的现象。而 PT_INTERP 指向不存在的路径时,`exec` 报的是 **`No such file or directory`——指着一个明明存在的文件**,是 Linux 上最经典的假线索之一。 + +**公平地算完四格**(不能只讲对自己有利的那格): + +| 场景 | 载荷寻址(今天) | 视图寻址 | +|---|---|---| +| 载荷升级 2.39→2.44,**保留** 2.39 | ✅ | ✅(glibc 向后兼容) | +| 载荷升级 **+ GC 掉** 2.39 | ❌ 该批坏 | ✅ | +| 载荷被删(无升级) | ❌ 该批坏 | ❌ **全部坏** | +| home 迁移 | ❌ | ❌(链接仍是绝对路径) | + +**视图寻址只在第 2 格严格更好,在第 3 格的爆炸半径更差,其余两格相同。** 一格换一格,不是压倒性的。 + +| | **A. 维持载荷寻址** | B. 换视图寻址 | C. 按 role 分(bin 视图 / test 载荷) | +|---|---|---|---| +| 抗「升级+GC」 | ❌ | ✅ | ✅ | +| 悬空爆炸半径 | 小(按版本分批) | **大(全体)** | 大 | +| e2e 冲击 | 零 | ~4 个断言 PT_INTERP 的用例 | 同 B | +| 可重现性 | ✅ 完全钉死 | ⚠️ 产物行为随 subos 变,**而 fingerprint 覆盖不到**(它是 compile-side) | ⚠️ 同 B | +| 同一轴上的默认值个数 | 1 | 1 | **2** | + +**推荐 A(维持现状),除非同时做完这两件事**——做完之后再切 B: + +1. **doctor 能自动修复视图悬空**,而不只是报告(今天只 `warn`)。R3 判据:报告不是修复。 +2. **exec 失败有人话诊断**:mcpp 在 `run`/`test` 里检测到 PT_INTERP 不可达时,直接说「你的 subos 视图坏了,跑 `mcpp doctor --fix`」,而不是让用户去解读 `No such file or directory`。 + +**不推荐 C**:同一条轴上两个默认值,而「同一决策两处推导」在这个代码库里已经反复付过学费(#233/#240/#344/#336)。 + +**这条不再是「最需要拍板」的一条**——降格成机制层开关之后,它可以在 P2 独立评审,不阻塞 P0/P1。 + +--- + +## 6. 分阶段落地 + +### P0 — 零 xlings 改动 + +按「收益 ÷ 代价」排序,前两条可以立刻做且互不依赖: + +1. **文档 + `self-contained` 缺陷**(§1.6):在 `docs/02-pack-and-release.md` 写明 `/proc/self/exe` / `/proc/self/cmdline` 陷阱,并按 §3-S2 的 (i)/(ii) 择一修掉 wrapper。**这是 #375 唯一一条纯粹的既有缺陷**,不依赖本文任何架构改动。 +2. **mcpp-index**:`compat.glx-runtime` → `xim:graphics` → **#352 关闭** +3. **S3-读**:`mcpp run` / `mcpp test` 消费 `subos_info.envs`;初始化时确保 subos 有 `subos_info`(§1.3 的缺口)。第 2 条要在真实 GPU 上验成(V4),依赖这一条。 +4. **S2-可发现性**:`self-contained` 在 glibc 上不可行时的诊断逐条列出三条 hermetic 路径;`docs/02-pack-and-release.md` 与 `05-mcpp-toml.md` 并列写清 A/B/C。**不新增任何 build 期链宿主 libc 的开关。** + +### P1 — 跨仓契约 + +5. **C1**:xlings 把 `resolved_deps`/`deps_exports` 持久化(schema 版本化) +6. **S1**:`RuntimeBinding` 成为一等轴;`abi.cppm` 的 libc 维度带上版本 +7. **删除**:回答者 #1 #2 #4 #6(R3 判据:必须是**删掉**,不是**再加一条**) + +### P2 — 生态 / 需先满足前置条件 + +8. **前置**:doctor 自动修复视图悬空 + exec 失败人话诊断(§5.3 的两个前提) +9. 之后才评审:`toolchain-coupled` 是否切到视图寻址 +10. 多 glibc / platform 成员身份 —— 与 xlings 侧 platform manifest 联动(xlings 开放问题 B) + +--- + +## 7. 跨仓契约:需要 xlings 侧配合的**只有一件事** + +把 `installer.cppm` 已经算出来的记录落盘。建议形态(字段名沿用 xlings 内部已有的命名,避免第三套词汇): + +```jsonc +// /.xpkg-install.json —— 扩展,不是新文件 +{ + "schema_version": 2, + "os": "linux", "version": "2.39", "xlings_version": "2026.8.7.1", + "exports": { // 本包声明的(self_exports) + "loader": "lib64/ld-linux-x86-64.so.2", + "abi": "linux-x86_64-glibc", + "libdirs": ["lib64"] + }, + "resolved_deps": { // R1:每一个 runtime dep 都记,不只声明了的 + "xim:linux-headers@5.11.1": { + "install_dir": "…", "libdirs": ["…"], "source": "plan-exact" + } + } +} +``` + +三条要求: + +- **相对路径**(相对载荷根),不是绝对路径——绝对路径会把 home 位置烙进一个可被复制/硬链接的文件,而 mcpp 侧已经为这类问题付过学费 +- `schema_version` 必须有,且 mcpp 读到未知高版本要**降级 + 出声** +- **R1 全量**:每一个 runtime dep 都记,包括什么都没声明的那些。缺省即约定是这一族缺陷的共同上游(A2) + +**注意这不是新设计**——`.xpkg-install.json` 已存在、值已算出、字段名已定。只是把一个被丢弃的计算结果写下来。 + +--- + +## 8. 明确不做 + +- **不接管 subos 状态**。mcpp 读 subos、保证 `subos_info` 存在,**不管理** subos 生命周期、不实现 `subos use`、不写 `workspace` 版本 DB。xlings 侧已经把「recipe 里塞 build 逻辑、mcpp 反过来管 subos 状态」点名为层次未分开的症状。 +- **不在 mcpp 里探测宿主图形能力**。哨兵机制在 xlings 侧,见 §4 C3。mcpp 里出现 `if (nvidia)` 即为违规。 +- **不追 glibc 版本**。mcpp 不实现「选最新可用 libc」——那会让两台同命令的机器产出不同结果(xlings 侧对 `DEFAULT_RUNTIME` 用常量而非查表,理由相同)。 +- **不做 platform manifest**。那是 xlings 的开放问题 B,mcpp 侧只需**消费**一个 runtime 身份;platform 成员身份排 P2。 +- **不为 `/proc/self/exe` 单独设计任何东西**。见 §2.3 推论——它是 workaround 的后果,契约修好后自动消失。 +- **不动 BMI/fingerprint 的缓存身份**,除非 P2 改默认契约时另行评估。契约变化会改链接命令,**不改编译命令**,这是分期能这么切的原因。 + +--- + +## 9. 开放问题 + +- **Q1**(已在评估阶段自我下调,**不再阻塞 P0/P1**)§5.3 载荷寻址 vs 视图寻址:现推荐维持载荷寻址,先把 doctor 自动修复 + exec 失败人话诊断做完,再在 P2 独立评审是否切换。 +- **Q2**(新)`mcpp pack --mode vendored` 是 pack 的**默认**,而它把 PT_INTERP 重指到 `/lib64/ld-linux-*.so.2` —— 默认打包路径本身就是宿主耦合的,与 hermetic 策略不一致。改默认会破坏既有用户,需单独评估:改默认、打告警、还是维持并在文档里说清。 +- **Q3** 多 subos:mcpp 有 home 级 sandbox 和项目级 subos(实测 `/.xlings/subos/_/`)。S3 该读**哪一个**的 `subos_info`?倾向「构建时活动的那个」,但需要确认它在 CI 与本机的一致性。(视图寻址一旦在 P2 启用,同一个问题会变成「烙哪一个」,风险更高——这是又一条把它排到 P2 的理由。) +- **Q4** 交叉编译时 `subos_info.runtime` 描述的是宿主 subos,目标 runtime 从哪来?可能需要 `[target.].runtime` 强制显式(而不是有一个缺省)。 +- **Q5**(回答 xlings 开放问题 K)本文的答案是「**消费 subos 的身份,而不是在 subos 里跑**」——mcpp 读 `subos_info.runtime` 作为契约输入,构建仍在 mcpp 自己的 hermetic 环境里。是否与 xlings 侧的设想一致,需要跨仓确认。 +- **Q6**(需你拍板)§3-S2 的 `self-contained` wrapper:选 (i) 安装期改写 PT_INTERP(消灭问题,但 tarball 不再解开即跑),还是 (ii) 保留 wrapper + `MCPP_BUNDLE_DIR` + 文档(不破坏现有契约,但救不了第三方库)?这条**独立于**本文其余部分,可以先决先做。 + +--- + +## 10. 验证判据(每条可执行,先红后绿) + +| # | 判据 | 怎么测 | +|---|---|---| +| V1 | 三条 hermetic 路径各自在**无 mcpp** 的机器上跑通 | 干净容器里分别验:A `xlings install` 后跑;B musl 产物原样拷进去跑;C `pack --mode self-contained` 解开就跑。**必须是真实干净容器** —— 在 mcpp 沙箱里跑测不出东西,那里载荷路径恰好总是存在 | +| V2 | **每一个** pack 模式产出的程序,`/proc/self/exe` 都指向自身 | 产物内打印 `readlink("/proc/self/exe")` 并断言 == 自身路径,**四个模式全跑**。今天 `self-contained` 必红(§1.6),这条就是它的先红后绿 | +| V3 | (仅当 P2 切视图寻址)产物在载荷升级+GC 后仍可运行 | 装 glibc@2.44、`xlings use`、**删掉 2.39**,不重新构建直接跑旧产物 | +| V4 | GL 程序在 hermetic 图形栈下起窗口 | `xlings install graphics` 后跑 GLFW e2e;**断言渲染器不是 llvmpipe**——「跑起来了」是假绿,#352 的教训是要问「谁答的」 | +| V5 | mcpp 侧 loader/libdir 推导点数量单调下降 | 对 §2.1 六个位置做静态计数,CI 守住上界 | +| V6 | subos 无 `subos_info` 时**出声降级** | 删掉块,断言 stderr 有一行,且构建仍成功 | +| V7 | 产物不引用任何 `/usr/lib*` `/lib*` 下的 `.so` | 对 `mcpp build` 产物做 `ldd` 闭包扫描,断言无宿主路径命中(`allow_host_libs` 显式开启时豁免)| + +> **两条来自本仓历史的验证陷阱,必须避开**: +> - **CI 全绿不等于覆盖**(#346:18 个 job 全绿也没测到大链接)。V1/V3 必须在**真实的干净容器**里跑,不能只在 CI 的 mcpp 沙箱里跑——那里 PT_INTERP 恰好总是存在。 +> - **「有输出」不是判据**(#352 的 exit 255 无输出;`VS_VERSION_INFO` 全文搜索是假判据)。V4 必须断言**渲染器身份**,不是断言窗口出现。 + +--- + +## 11. 多维评估 + +本节的每个数字都在本机量过。**结论先行:P0 的三条(文档/wrapper、图形栈、S3-读)是低风险高收益,可以直接做;S2 中等;S1 是唯一有实质回归风险的一条,排 P1 是对的;P2 目前不该做。** + +### 11.1 实现代价(量化) + +| 项 | 数字 | 依据 | +|---|---|---| +| ~~`c_runtime` 实现面~~ | ~~9 文件~~ | **已撤销**(§5.2)。实测过:9 文件 / `flags.cppm` 约 30 处 / 12+7 单测 / 1 条 e2e 全绿 —— 记在这里是为了说明「代价可控」从来不是做不做的判据 | +| **BMI / 对象缓存失效** | **零** | fingerprint 是 **compile-side 10 字段**(`fingerprint.cppm:3-8`),不含链接侧;`target//` 目录名不变,只重链 | +| e2e 需改 | ~4 个(203 个中) | 断言 PT_INTERP 的:`30_pack_modes` `86_llvm_hermetic_link` `28_target_static` `168_build_mcpp_musl_host_static` | +| e2e 耦合载荷路径 | 58 处 / 26 文件 | 大多是工具链解析,不是 PT_INTERP;S1 落地时才受影响 | +| mcpp-index 破坏面 | **3 个包 / 81** | `compat.glfw` `compat.glx-headers` `compat.vulkan-runtime` | + +**「零缓存失效」是这套方案最大的成本优势**,和 #336 当年便宜的原因相同:契约只改链接命令。 + +**净增 / 净删**:新增 1 个 manifest 键 + 1 张 mechanism 表 + 1 个 RuntimeBinding 类型 + 2 条读文件路径;删除 §2.1 六个回答者里的 4–5 个,外加整个 `compat.glx-runtime`(约 130 行宿主探测 Lua)。满足 R3。 + +### 11.2 用户使用层 + +| 用户 | 影响 | +|---|---| +| **普通用户**(build/run/test) | P0 全部**零感知**;S3 让 GL 程序从「exit 255 无输出」变成能跑,是纯增益 | +| **分发者** | `pack --mode self-contained` 从「静默打坏 exe 相对资源解析」变成可用(§1.6),这是三条 hermetic 路径里唯一一条自己有缺陷的;另外两条(A 生态闭环 / B musl 静态)本来就能用,缺的是文档 | +| **库作者** | `compat.glx-runtime` 废弃是破坏性变更,需过渡期(保留空壳 provider 一个版本) | + +**新增认知负担:零。** 不加键、不加轴、不加词汇。用户模型仍是「target × pack mode」,只是三条分发路径终于被并列写出来了。这是选 B 而非 A 的直接收益之一。 + +### 11.3 稳定性 + +**新增失败模式三个,两个可控、一个未受控:** + +| 失败模式 | 处置 | 评价 | +|---|---|---| +| 读 `subos_info` 失败 / 缺失 | 出声降级(V6) | 可控 | +| xlings schema 演进 | `schema_version` 显式 + 降级出声 | 可控 | +| **视图寻址下产物行为随 subos 变化** | **fingerprint 覆盖不到**(compile-side) | **未受控** —— 这是把它降级到 P2 并加两个前置条件的直接原因(§5.3) | + +**P0/P1 本身不引入新的运行期失败模式**:契约层只是把已有机制(pack 的 patchelf)提前到链接期,产出的 PT_INTERP 形状是 `30_pack_modes.sh` 今天已经在断言的那些。 + +### 11.4 跨平台(最弱的一维,必须直说) + +| 平台 | 分发路径可用性 | S1 runtime 身份 | S3 env / 图形 | +|---|---|---|---| +| Linux / glibc | **3** | `glibc@X`,有意义 | ✅ 全部收益 | +| Linux / musl | 1(恒 self-contained) | `musl@X` | 部分 | +| **macOS** | **1** —— libSystem 恒为宿主(`platform/common.cppm:110` `supports_full_static = is_linux`) | 近乎常量 | **无**(无 mesa/glvnd) | +| **Windows** | 2(静/动 CRT),且与 `linkage` 共用 `-static` 拼写 | 近乎常量 | **无** | + +**收益 ≈ 全在 Linux。** macOS 上这条轴是退化的——只有一个合法值。 + +macOS/Windows 上没有 mesa/glvnd,所以 S3 的图形收益是 Linux-only;而分发路径 A/B/C 里,B(musl 静态)本身就是 Linux 概念。**这是诚实的不对称,不是退化** —— 因为本轮不新增任何全平台的轴,也就不存在「为一个平台的问题给三个平台加概念」。 + +若 review 认为 Linux-only 收益不值一条全局轴,替代是放进 `[target.'cfg(linux)']`。**我不推荐**——那会引入第二套作用域规则,而 `cxx_runtime` 已经确立了「全局轴 + 平台退化」的先例。 + +### 11.5 简洁优雅 + +**正面**:零新词汇(三值抄 `cxx_runtime`,runtime binding 抄 xlings);三层模型已存在,新轴是**填满一个已知空格**而非加一个维度;§4 的三通道按**生命周期**而非内容划分,所以 xlings 加新能力时通道数不变。 + +**负面(必须记账)**: +- 概念总数确实上升,§11.2 已列。 +- **初稿的 `subos-coupled` 第四值破坏了对称性**——这是评估阶段发现并已修正的一处真实设计缺陷(§3-S2 的框)。修正之后词汇仍是三个,且「视图 vs 载荷」回到它本来的层(Mechanism)。**如果没做这次评估,这条会带着一个多余的契约值进入实施。** + +### 11.6 兼容性 + +| 面 | 影响 | 风险 | +|---|---|---| +| 已构建产物 | 不受影响(载荷目录仍在) | 无 | +| `[pack].default_mode` / tarball 后缀 frozen wire format | **不动** | 无 | +| 老 subos 无 `subos_info` | 降级 + 提示 | 低 | +| 老 xlings 无持久化 exports | 走 §5.1 的 A 降级路径;P1 才依赖 | 低 | +| `compat.glx-runtime` 下游 | 3 个包 | **低于预期**(初判以为是索引级风险,实测是叶子簇) | +| **新 mcpp + 老 xlings** | 必须验 | **中** —— 已知「沙箱 xlings 长期落后于 pin」,这一格最容易假绿 | + +### 11.7 总评 + +| 维度 | P0(wrapper 修复 + 图形栈 + S3-读) | S2(分发路径可发现性) | S1(RuntimeBinding) | ~~c_runtime~~ / P2 视图寻址 | +|---|---|---|---|---| +| 实现代价 | 低 | 中 | 中高(动 `abi.cppm`,参与依赖解析) | 低 | +| 用户收益 | **高**(#352 从不可用变可用) | 中高 | 低(内部收敛) | 低 | +| 稳定性风险 | 低 | 低 | **中**(ABI 维度回归) | **高**(未受控) | +| 跨平台价值 | Linux-only | Linux 高 / Win 中 / mac 退化 | 全平台(收敛推导) | Linux-only | +| 兼容性风险 | 低(3 个包) | 低 | 中 | 中 | +| **建议** | **直接做** | **做** | **P1,单独评审** | **暂不做** | diff --git a/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md b/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md new file mode 100644 index 00000000..af4b08ae --- /dev/null +++ b/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md @@ -0,0 +1,159 @@ +# 编译器是能力 —— 跨仓实施计划 + +> **状态:全部完成(2026-08-08)。** +> +> mcpp 侧 T1–T7 随 PR #378 合入(`fdad165`,CI 18/18),发布 2026.8.8.2; +> T8 随 mcpp-index #181 合入(`1ba4161`)。落地过程中被 CI 推翻/收窄的判据, +> 已回写到 `2026-08-08-payload-version-and-contract-drift-design.md` §9。 +> +> 未随本计划完成、已另行记录的:#352 的 GLX vendor 选择(EGL 路径已通, +> GLX 路径在 NVIDIA 宿主上仍拿不到 FBConfig),以及 `glibc>=X` 下限语义 +> (约定为独立 PR)。 + + +> **For agentic workers:** 逐 task 执行,每个 task 自带 red→green→commit。**T1 是所有后续 task 的前提**(没有它,后面每一步的观测都不可信)。 + +**Goal:** 让 mcpp 停止依赖编译器的安装期配置,把「用哪份 libc」收敛成一个权威;顺带把已经污染的产物清干净,并让多 subos 构建真正可用。 + +**设计文档:** +- `.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`(运行时底座;S1/S3 与撤销 `c_runtime` 的理由) +- `.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md`(本计划的主体) + +**Tech Stack:** C++23 modules,gtest(`tests/unit/`),shell e2e(`tests/e2e/`),Lua(mcpp-index)。 + +## Global Constraints + +- **不改 payload**。`patchelf_walk`(让编译器自己能跑)保留;**写 payload 内配置的一律删掉**。 +- **权威唯一**:`--runtime` → `[xlings] subos` 的 `subos_info.runtime` → **报错,不猜**。删掉「扫目录挑一个」。 +- **平台差异下沉** `src/platform/`,`build/` 里不写 `#ifdef`。 +- **每一处「注释描述行为」必须有对应断言**(R-C)。本轮已发现三处注释与代码不符。 +- **跨仓 wire format 的 fixture 取自写入方**(R-A),不得手写。 +- **一条测试若为覆盖某路径而存在,必须先断言走了那条路径**(R-B)。 +- **不新增任何让产物链宿主 libc 的开关**(8-07 §5.2 已否)。 + +--- + +### T1: 三处「注释撒谎」先钉住 —— 后续观测的地基 + +**Files:** `tests/unit/test_linkmodel.cpp`(新断言)、`src/xlings.cppm`、`src/toolchain/post_install.cppm`(改注释) + +本轮发现三处注释描述了代码没有的行为。**先让它们不能再撒谎**,否则后面每一步都可能在错误的前提上推理。 + +- [x] **Step 1** 写失败单测:`find_sibling_tool` 的注释说「first (highest)」,断言**两个版本目录时返回的是排序后最高的那个** +- [x] **Step 2** 跑,确认 FAIL(今天返回 readdir 第一个) +- [x] **Step 3** 二选一并使注释与代码一致:①真的排序;②改注释为「未定义顺序」并让调用方不得依赖。**本计划选 ②**——T2 会让它不再被用于选版本 +- [x] **Step 4** 另两处:`fixup_gcc_specs` 的「Idempotent」(跨 home 不幂等)、`execute.cppm` reader 的「旧缓存视为 miss」(T5 修)——先把注释改成事实 +- [x] **Step 5** Commit + +--- + +### T2: 权威降级链 —— 没有权威就不进 PayloadFirst + +**Files:** `src/toolchain/probe.cppm`、`src/xlings/subos_info.cppm`、`src/build/prepare.cppm` +**Test:** `tests/unit/test_toolchain_probe.cpp`(新) + +**Interfaces:** +```cpp +// probe 不再自己选版本;版本由权威给出 +std::optional probe_payload_paths( + const std::filesystem::path& compilerBin, + std::string_view runtimeBinding); // "glibc@2.39";空 ⇒ 不进 PayloadFirst +``` + +- [x] **Step 1** 单测:给定 `glibc@2.39` 且 home 内有 2.39/2.44 两份 ⇒ **必须返回 2.39**;给定空 binding ⇒ **返回 nullopt**(不猜) +- [x] **Step 2** 跑,FAIL +- [x] **Step 3** 实现;`prepare.cppm` 按 `--runtime` → `[xlings] subos` 的 `subos_info.runtime` 顺序解析,末级不猜 +- [x] **Step 4** 跑,PASS +- [x] **Step 5** Commit + +--- + +### T3: fingerprint 加入 runtime 轴 —— 多 subos 的前置 + +**Files:** `src/toolchain/fingerprint.cppm`、`tests/unit/test_fingerprint.cpp` + +不加这条,切 subos 不改 fingerprint ⇒ `target//` 被复用 ⇒ 里面是对另一份 glibc 编译的对象。**比今天更坏。** + +- [x] **Step 1** 单测:两个只有 `runtimeBinding` 不同的输入 ⇒ **fingerprint 必须不同** +- [x] **Step 2/3/4** red → 加字段 → green +- [x] **Step 5** Commit + +--- + +### T4: GCC 也显式发 loader/rpath + clean specs + +**Files:** `src/toolchain/linkmodel.cppm`、`src/build/flags.cppm`、`src/toolchain/post_install.cppm`(删 `fixup_gcc_specs`) +**Test:** `tests/unit/test_linkmodel.cpp`、`tests/e2e/201_gcc_clean_specs.sh`(新) + +**已实测**:`-Wl,--dynamic-linker` 压得过 specs;`-specs=<无 `+` 定义>` 是替换;`g++ -dumpspecs` 给内建 specs(`/tmp` 路径 0 条);真实构建(模块 + `import std` + 共享库)RUNPATH 68→2、死路径 0、可运行。 + +- [x] **Step 1** e2e:gcc 构建的产物 **RUNPATH 中不得有 `/tmp/tmp.`**,且 interpreter 是权威指定的那份,且能跑 +- [x] **Step 2** 跑,FAIL(今天有 34+ 条) +- [x] **Step 3** ①去掉 `linkmodel` 的 `if (clangDriver)` 门;②构建期生成 clean specs 到 **build dir**,`-specs=` 传入;③**删掉 `fixup_gcc_specs`**(保留 `patchelf_walk`) +- [x] **Step 4** 跑,PASS +- [x] **Step 5** Commit + +--- + +### T5: 并回 #377 的两处修复 + +**Files:** `src/xlings/subos_info.cppm`、`src/build/execute.cppm`、`tests/unit/test_subos_info.cpp`、`tests/e2e/200_subos_env_reaches_program.sh` + +已在 `fix/fast-run-stale-cache-subos` 分支上完成并 18/18 CI 通过,**原样带过来**: + +- [x] **Step 1** `git cherry-pick` 该分支的三个 commit(wire format / 旧缓存 / e2e fixture) +- [x] **Step 2** 跑单测 + e2e,确认仍绿 +- [x] **Step 3** Commit(或保留 cherry-pick 的原始提交) + +--- + +### T6: D4 沙箱 xlings 版本比对 + D5 陈旧 sysroot + +**Files:** `src/fallback/xlings_binary.cppm`、`src/doctor.cppm`、`src/fallback/probe_sysroot.cppm` + +- [x] **Step 1** 单测/e2e:vendored xlings 版本**低于 pin** ⇒ 被替换;**高于 pin** ⇒ 不动 +- [x] **Step 2** red +- [x] **Step 3** 实现;doctor 增加两条 finding:①xlings 低于 pin;②`--sysroot` 指向当前项目之外 +- [x] **Step 4** green +- [x] **Step 5** Commit + +--- + +### T7: 文档 + 版本 + PR + +- [x] `docs/03-toolchains.md`:权威降级链、`[xlings] subos`、多 subos 构建 +- [x] `docs/05-mcpp-toml.md`:`[xlings] subos` 与 runtime 的关系 +- [x] 中文版同步 +- [x] `MCPP_VERSION` / `mcpp.toml` → `2026.8.8.2`;`kXlingsVersion` → 最新;`check_version_pins.sh` 通过 +- [x] 全量 `mcpp test` + 关键 e2e +- [x] 开 PR + +--- + +### T8: mcpp-index 图形栈重新落地(D1 修好之后) + +**Files(mcpp-index):** `pkgs/c/compat.glx-runtime.lua`、`pkgs/c/compat.glfw.lua` + +- [x] **Step 1** 新增 e2e/验证:**装 `xim:graphics` 前后**,各跑一次与图形无关的成员,断言产物 `PT_INTERP` 与 `readelf -V` 的 glibc 符号上界**不变**。这是上次事故真正缺失的那个测试 +- [x] **Step 2** 重新应用 `f44e896` 的两个文件改动(deps 放平台层) +- [x] **Step 3** CI 全绿后合入 + +--- + +## Self-Review + +| 设计文档条目 | Task | +|---|---| +| 8-08 §3.1 原则(编译器当能力) | T4 | +| 8-08 §3.2 权威降级链 / D1 | T2 | +| 8-08 §3.5① 去掉 clangDriver 门 | T4 | +| 8-08 §3.5② clean specs 进 build dir | T4 | +| 8-08 §3.5③ 删 `fixup_gcc_specs` / D6 | T4 | +| 8-08 §3.6 多 subos + fingerprint 前置 | T2 + T3 | +| 8-08 D4 沙箱 xlings / D5 sysroot | T6 | +| 8-08 D2 wire format / D3 旧缓存 | T5 | +| 8-08 §4.2 R-A/R-B/R-C | T1(R-C)+ 各 task 的测试形态 | +| 8-07 §1.5 图形栈迁移 | T8 | +| 8-07 §3-S2 不加链宿主 libc 的开关 | Global Constraints(不做) | + +**未覆盖且是有意的**:8-07 §7「xlings 落盘 exports」—— 8-08 已说明它不再是关键路径(权威改为 `subos_info.runtime`)。Q6(交叉/musl/MinGW 下 `-specs=` 替换)在 T4 的 e2e 里只覆盖 native;**其余平台列为 PR 中显式声明的未验项**,不假装验过。 diff --git a/.agents/docs/2026-08-08-configure-only-cdb-design.md b/.agents/docs/2026-08-08-configure-only-cdb-design.md new file mode 100644 index 00000000..8c370b65 --- /dev/null +++ b/.agents/docs/2026-08-08-configure-only-cdb-design.md @@ -0,0 +1,214 @@ +# `build --configure-only` 与可靠 CDB 设计 + +## 1. 背景 + +普通 `mcpp build` 已在启动 Ninja 前生成 `compile_commands.json`。因此源码存在 +语法错误时仍能得到 CDB;真正缺失的是一个只完成项目解析、工具链解析、构建计划 +生成和 CDB 发布,而不编译普通翻译单元、不链接最终目标的入口。 + +PR #372 同时实现了 IDE snapshot、NDJSON 生命周期、内容寻址 reply、发布状态机和 +CDB 可靠性。RFC #379 决定拆分这些关注点:本设计只保留只有 mcpp 能可靠完成的 +构建配置能力,通用机器协议继续在 RFC 中讨论。 + +## 2. 目标 + +新增: + +```text +mcpp build --configure-only [现有 build selectors] +``` + +成功时必须满足: + +1. 复用真实 `prepare_build()` 和 `BuildPlan`,不复制编译参数推导。 +2. 根 CDB 覆盖所选 package 的普通源码与 `tests/**/*.cpp`。 +3. 测试 TU 带有 dev-dependencies 和匹配的 `[build].flags`。 +4. 准备标准库 BMI,并只物化已经命中全局缓存的依赖 BMI。 +5. 不启动 Ninja,不生成普通对象文件,不链接可执行文件或库。 +6. CDB 通过跨平台原子替换发布;失败时旧 CDB 保持不变。 +7. 仅当新 CDB 已成功发布时返回 0。 + +该命令不是只读操作。`prepare_build()` 仍可能执行 `build.mcpp`、安装缺失依赖或 +工具链、写 `mcpp.lock`、resolution、缓存及构建目录元数据。IDE 插件必须继续受 +workspace trust 约束。 + +## 3. 非目标 + +本 PR 不实现: + +- JSON 或 NDJSON stdout、wire envelope、protocol version。 +- `ide snapshot`、`ide configure` 或 daemon。 +- project/configuration/snapshot ID。 +- `.mcpp/ide/current.json`、内容寻址 reply、跨文件发布事务或发布锁。 +- `invalidatedBy`、manifest metadata 或依赖图。 +- 构建未缓存的项目或依赖模块 BMI。 +- 新的 toolchain、profile、feature、capability 或 workspace selector 语义。 +- `.xlings.json` pin 更新。 + +## 4. 方案选择 + +### 方案 A:复用 Ninja backend 的内部 dry-run,推荐 + +`prepare_build()` 生成包含普通目标和测试目标的计划,再以 +`BuildOptions::dryRun=true` 调用现有 Ninja backend。backend 仍生成 `build.ninja` +和 CDB,但在启动 Ninja 前返回。 + +优点:编译命令只有一个生成路径;现有 selector、工具链和平台逻辑全部复用。 +风险:不能调用普通 `run_build_plan()`,否则会错误填充 BMI cache 和 build fast-path +cache。需要独立的 `run_configure_plan()` 收口配置模式。 + +### 方案 B:在 CLI 直接调用 `emit_compile_commands()` + +代码更短,但会绕过 backend 的 manifest、命令长度检查和后续编译命令演进,形成 +第二条 CDB 路径。不采用。 + +### 方案 C:保留 #372 的 `ide configure` + +能提供更丰富状态,但需要 NDJSON、snapshot 发布和长期协议兼容,且插件当前只消费 +CDB 路径与成功结果。不采用。 + +## 5. CLI 与执行流 + +`build` 增加布尔选项 `--configure-only`,其余选项继续由现有 parser 和 +`BuildOverrides` 处理:`-p/--package`、`--workspace`、`--profile`、`--target`、 +`--features`、`--cap`、`--cache`、`--offline`、`--strict` 和 `--static`。 + +执行流: + +```text +cmd_build + -> workspace_fanout_members(沿用现有语义) + -> discover_test_targets(按 member 发现测试) + -> prepare_build(includeDevDeps = !tests.empty(), extraTargets = tests) + -> stage_cached_module_prerequisites + -> run_configure_plan + -> NinjaBackend::build(dryRun=true, requireCompileDatabase=true) + -> 生成 build.ninja + -> 原子发布 compile_commands.json + -> 在 spawn Ninja 前返回 +``` + +配置模式必须跳过 build fast path。它也不得: + +- 调用 `bmi_cache::populate_from()`; +- 写 `target/.build_cache`; +- 输出 `Finished ...` 这种表示已完成编译的状态; +- 把不存在的最终产物报告为 produced artifacts。 + +workspace fan-out 继续逐 member 调用相同流程。每个 member 的新 CDB 内容与已有根 +CDB 合并,fresh entry 优先,最终得到一个覆盖 workspace 的根 CDB。任一 member +失败时沿用现有 continue-on-failure 和首个非零结果规则。 + +## 6. 测试目标发现 + +从 `run_tests()` 抽取 `discover_test_targets()`,由 `mcpp test` 和配置模式共同调用。 +该函数负责: + +- 将 package selector 解析到同一个 member 根目录; +- 展开 `tests/**/*.cpp`; +- 用相对 `tests/` 的无扩展路径生成稳定测试名; +- 检测重复测试名; +- 把匹配的 `[build].flags` 中 defines、cflags、cxxflags 附到合成 target。 + +`mcpp test --list` 现有的 best-effort 行为必须保留:源码或 manifest 尚未可构建时, +只要能够发现测试文件就继续列出;严格 manifest 与依赖校验仍由后续 +`prepare_build()` 完成。 + +配置模式只有在发现测试 target 时启用 dev-dependencies。没有测试的项目不应仅因 +IDE 配置而安装无关开发依赖。 + +## 7. 模块前置产物 + +`prepare_build()` 已保证当前工具链需要的标准库模块可用。配置模式还会把计划中 +`servedFromCache && providesModule` 的依赖 BMI 从全局缓存物化到计划引用的构建目录。 + +该步骤只复制现有 BMI,不复制缓存对象文件,也不编译任何缺失模块。物化失败发生在 +CDB 发布前,并保留旧 CDB。项目自身模块或未缓存依赖模块保持 pending;用户显式 +执行普通 `mcpp build` 后才获得完整模块语义。 + +## 8. CDB 发布与错误处理 + +新增平台原语 `platform::fs::replace_file(source, destination, error_code)`: + +- POSIX 使用同文件系统 `rename`; +- Windows 使用 `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`; +- Windows 遇到短暂 sharing violation 时有限退避重试; +- 不先删除 destination; +- 函数为 `noexcept` 风格,通过 `error_code` 报错。 + +CDB 写入流程: + +1. 生成并解析 JSON,要求顶层为数组。 +2. 与现有有效条目合并并删除已不存在源文件的旧条目。 +3. 内容未变化时不写文件,避免无意义触发 clangd 重索引。 +4. 若根 CDB 是文件符号链接,解析并原子更新其目标,保留链接本身。 +5. 在同目录完成带跨进程随机量的临时文件写入和 flush。 +6. 通过 `replace_file` 原子替换目标。 +7. 替换失败时清理临时文件,返回错误,旧文件保持不变。 + +`write_compile_commands()` 改为返回结构化成功或错误。为保持普通构建兼容性: + +- 普通 build/test 遇到 CDB 发布失败时输出 warning,继续真实构建; +- `--configure-only` 设置 `requireCompileDatabase=true`,发布失败直接返回非零。 + +不加入 `FileLock`。单文件原子替换已保证 clangd 不会读到半截 JSON;并发配置采用 +last-writer-wins,但每个可见版本都是完整文件。只有未来重新引入多文件事务时才需要 +发布锁。 + +## 9. 输出契约 + +本 PR 只提供人类输出和进程退出码,不承诺机器可读 stdout。建议成功信息为: + +```text +Configured ( compile commands) +``` + +错误继续通过现有 stderr/UI 通道报告。插件稳定依赖仅有: + +- 进程退出码; +- 工程或 workspace 根目录的 `compile_commands.json`。 + +未来 RFC 可以向同一命令增加 `--format json`,默认人类输出不需要变化。 + +## 10. 测试策略 + +### 单元测试 + +- CDB 合并继续覆盖 fresh-wins、删除失效文件、损坏旧 JSON 回退。 +- 原子 writer 拒绝非数组 JSON。 +- 内容不变时不改 mtime。 +- `replace_file` 成功替换旧文件。 +- source 不存在时替换失败且 destination 内容保持不变。 +- 测试发现覆盖 nested names、重复名、member scoping 和 `[build].flags`。 +- 损坏 manifest 下 `test --list` 仍保持 best-effort inventory。 + +### E2E + +- 语法错误源码:配置成功、CDB 含源码、没有普通对象或最终二进制。 +- 测试 TU:CDB 含 `tests/**/*.cpp`,并具有 dev-dependency include/define。 +- workspace:默认 virtual workspace fan-out 和 `-p ` 均生成正确条目。 +- selector:profile、target、features、capability 与普通 build 进入同一 BuildPlan。 +- 失败保留:新 CDB 发布失败时旧 CDB 内容不变且命令返回非零。 +- Windows:真实 MSVC 配置与 `MoveFileExW` 替换由 Windows CI 覆盖。 + +## 11. 对核心构建的影响 + +共享变化限定为三处: + +1. 测试发现从 `run_tests()` 抽为共享模块,行为由原有测试锁定。 +2. CDB 从截断写改为临时文件加原子替换;普通构建失败策略保持非致命 warning。 +3. backend 增加 `requireCompileDatabase` 选项,默认 false;所有现有调用语义不变。 + +配置模式使用独立执行函数,不写 fast-path cache 或全局 BMI cache,因此不会让后续 +真实 build 错误命中不存在的产物。 + +## 12. 后续工作 + +以下事项留在 RFC #379,不阻塞本 PR: + +1. 未知 format 的统一退出码、stdout 和诊断结构。 +2. `--format json` 与旧 `--json` 的兼容规则。 +3. `self env` JSON、`xpkg parse` schema version。 +4. read-only metadata 与 manifest 合法键/枚举词汇表。 +5. resolved metadata、依赖图与 CDB `invalidatedBy`。 +6. 至少三个稳定消费者出现后再抽取通用 `mcpp.wire`。 diff --git a/.agents/docs/2026-08-08-configure-only-cdb-implementation-plan.md b/.agents/docs/2026-08-08-configure-only-cdb-implementation-plan.md new file mode 100644 index 00000000..8c43526b --- /dev/null +++ b/.agents/docs/2026-08-08-configure-only-cdb-implementation-plan.md @@ -0,0 +1,1211 @@ +# Configure-Only Compile Database Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 `mcpp build --configure-only`,在不编译普通翻译单元、不链接目标且不写构建成功缓存的前提下,基于真实 `BuildPlan` 原子发布包含源码与测试 TU 的 `compile_commands.json`。 + +**Architecture:** CLI 继续使用现有 build selectors 和 workspace fan-out;共享的测试发现模块为 `mcpp test` 与 configure-only 合成相同测试 target;`prepare_build()` 仍是解析工具链、依赖、feature、模块图与编译参数的唯一入口。独立的 `run_configure_plan()` 只物化 clangd 立即需要的已存在 BMI,然后以 Ninja backend dry-run 生成 `build.ninja` 和 CDB;CDB 发布失败在普通 build/test 中警告继续,在 configure-only 中返回失败。 + +**Tech Stack:** C++23 named modules、`std::expected`、nlohmann JSON (`mcpp.libs.json`)、Ninja backend、GoogleTest、跨平台 Bash E2E、GitHub Actions Linux/macOS/Windows。 + +--- + +## Scope Guard + +本计划只实现 `.agents/docs/2026-08-08-configure-only-cdb-design.md` 已确认的 A 部分: + +- `mcpp build --configure-only`; +- 普通源码和 `tests/**/*.cpp` 的真实 CDB; +- test TU 的 dev-dependencies 与 `[build].flags`; +- std BMI 和已命中缓存的依赖 BMI 物化; +- 单文件原子 CDB 发布; +- 普通 build/test 警告继续、configure-only 严格失败; +- 人类输出和退出码。 + +不得加入:JSON/NDJSON、`ide` 子命令、snapshot/ID/envelope、`current.json`、`FileLock`、`invalidatedBy`、metadata/dependency graph、`.xlings.json` pin、新 toolchain/profile/feature/capability/workspace 语义。 + +## Execution Setup + +所有命令从 worktree 根目录执行: + +```bash +cd /Users/cltx/projects/mcpp/mcpp/target/worktrees/configure-only-cdb +git status --short --branch +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" --version +``` + +预期:分支为 `codex/configure-only-cdb`;只包含本计划已知改动;fresh binary 输出当前 `mcpp.toml` 中的版本。 + +## File Map + +| File | Responsibility | +|---|---| +| `src/build/test_targets.cppm` | 共享 `tests/**/*.cpp` 发现、member scoping、稳定命名和 `[build].flags` 合成;不打印 UI,不解析 dev-dependencies。 | +| `src/build/configure.cppm` | 物化 std/cached dependency BMI,并运行只配置的 Ninja dry-run;不触碰 BMI populate 或 `.build_cache`。 | +| `src/build/execute.cppm` | `mcpp test` 改用共享发现 API;保留 list、build、run 和 summary 行为。 | +| `src/platform/fs.cppm` | 提供不先删除 destination 的跨平台 `replace_file()`。 | +| `src/build/compile_commands.cppm` | 校验、合并、稳定排序、同目录临时文件写入和原子发布 CDB。 | +| `src/build/backend.cppm` | 增加默认关闭的 `requireCompileDatabase`,并在结果中携带最终 CDB 条目数。 | +| `src/build/ninja_backend.cppm` | 将 CDB writer 结果落实为普通构建 warning 或 configure-only fatal;dry-run 在 spawn Ninja 前返回。 | +| `src/cli/cmd_build.cppm` | 解析 configure-only 模式,复用 workspace fan-out 与 selectors,并绕过 build fast path。 | +| `src/cli.cppm` | 注册并展示 `--configure-only`。 | +| `tests/unit/test_test_targets.cpp` | 共享测试发现契约。 | +| `tests/unit/test_platform_fs.cpp` | replace-existing 与失败保留 destination。 | +| `tests/unit/test_compile_commands.cpp` | 原子 writer、JSON 校验、mtime、失败保留和稳定排序。 | +| `tests/unit/test_configure.cpp` | 只物化 BMI、不物化 cached object 的配置前置步骤。 | +| `tests/unit/test_test_options.cpp` | `requireCompileDatabase` 默认值回归。 | +| `tests/e2e/202_configure_only_cdb.sh` | CLI、坏源码、测试/dev-dep flags、无对象/链接产物、workspace、失败策略和 build-cache 契约。 | +| `tests/e2e/01_help_and_version.sh` | 顶层 help 暴露 configure-only。 | +| `README.md`、`docs/00-getting-started.md`、`docs/zh/00-getting-started.md` | 用户入口、行为边界和 trust/副作用说明。 | + +### Task 1: Extract Shared Test Target Discovery + +**Files:** +- Create: `src/build/test_targets.cppm` +- Create: `tests/unit/test_test_targets.cpp` +- Modify: `src/build/execute.cppm:1026-1157` + +- [ ] **Step 1: Write failing discovery tests** + +新增 `tests/unit/test_test_targets.cpp`,先导入尚不存在的模块并覆盖嵌套命名、glob flags、member scoping 和损坏 manifest 的 best-effort 行为: + +```cpp +#include + +import std; +import mcpp.build.test_targets; + +namespace fs = std::filesystem; +using mcpp::build::discover_test_targets; + +namespace { + +struct TempProject { + fs::path path = fs::temp_directory_path() / + std::format("mcpp-test-targets-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + TempProject() { fs::create_directories(path); } + ~TempProject() { std::error_code ec; fs::remove_all(path, ec); } +}; + +void write(const fs::path& path, std::string_view text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + out << text; +} + +} // namespace + +TEST(TestTargets, DiscoversNestedNamesAndMatchingBuildFlags) { + TempProject p; + write(p.path / "mcpp.toml", R"( +[package] +name = "app" +version = "0.1.0" + +[build] +flags = [{ glob = "tests/tagged/**/*.cpp", defines = ["TAGGED=1"], cxxflags = ["-Wextra"] }] +)"); + write(p.path / "tests/tagged/nested/smoke.cpp", "int main() { return 0; }"); + write(p.path / "tests/plain.cpp", "int main() { return 0; }"); + + auto found = discover_test_targets(p.path, ""); + ASSERT_TRUE(found.has_value()) << found.error(); + ASSERT_EQ(found->targets.size(), 2u); + auto tagged = std::ranges::find(found->targets, "tagged/nested/smoke", + &mcpp::manifest::Target::name); + ASSERT_NE(tagged, found->targets.end()); + EXPECT_EQ(tagged->defines, std::vector({"TAGGED=1"})); + EXPECT_EQ(tagged->cxxflags, std::vector({"-Wextra"})); +} + +TEST(TestTargets, ScopesDiscoveryToSelectedWorkspaceMember) { + TempProject p; + write(p.path / "mcpp.toml", "[workspace]\nmembers = [\"a\", \"b\"]\n"); + write(p.path / "a/mcpp.toml", "[package]\nname=\"a\"\nversion=\"0.1.0\"\n"); + write(p.path / "b/mcpp.toml", "[package]\nname=\"b\"\nversion=\"0.1.0\"\n"); + write(p.path / "a/tests/main.cpp", "int main() { return 0; }"); + write(p.path / "b/tests/main.cpp", "int main() { return 0; }"); + + auto found = discover_test_targets(p.path, "a"); + ASSERT_TRUE(found.has_value()) << found.error(); + ASSERT_EQ(found->targets.size(), 1u); + EXPECT_EQ(found->packageRoot, p.path / "a"); + EXPECT_EQ(found->targets.front().main, "tests/main.cpp"); +} + +TEST(TestTargets, InvalidManifestStillProvidesBestEffortInventory) { + TempProject p; + write(p.path / "mcpp.toml", "[package\nthis is invalid\n"); + write(p.path / "tests/broken.cpp", "this does not parse"); + + auto found = discover_test_targets(p.path, ""); + ASSERT_TRUE(found.has_value()) << found.error(); + ASSERT_EQ(found->targets.size(), 1u); + EXPECT_EQ(found->targets.front().name, "broken"); +} +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +```bash +mcpp test --no-color -- --gtest_filter='TestTargets.*' +``` + +预期:编译失败,明确提示找不到 `mcpp.build.test_targets`;不能因为没有匹配测试而显示成功。 + +- [ ] **Step 3: Implement the shared discovery module** + +新增 `src/build/test_targets.cppm`,公开一个无 UI 副作用的结果类型和函数: + +```cpp +export module mcpp.build.test_targets; + +import std; +import mcpp.manifest; +import mcpp.modgraph.scanner; +import mcpp.project; + +export namespace mcpp::build { + +struct TestTargetSet { + std::filesystem::path packageRoot; + std::vector targets; +}; + +std::expected +discover_test_targets(const std::filesystem::path& manifestRoot, + std::string_view packageFilter); + +} // namespace mcpp::build +``` + +实现严格复用当前 `run_tests()` 的算法:有效 workspace manifest 下调用 `resolve_member_dir()`;manifest 无法解析时保持 manifestRoot 以支持 `test --list` best-effort;展开 `tests/**/*.cpp`;用 tests-relative 去扩展名路径作为 name;重复 name 返回错误;匹配 package manifest 的 base `[build].flags`,把 defines/cflags/cxxflags 放入合成 `Target::TestBinary`。 + +核心循环必须保持如下字段映射: + +```cpp +mcpp::manifest::Target target; +target.name = relative.replace_extension("").generic_string(); +target.kind = mcpp::manifest::Target::TestBinary; +target.main = std::filesystem::relative(file, packageRoot).string(); +``` + +只使用必要中文注释解释两个非直观约束:损坏 manifest 下的 best-effort inventory,以及 member root 必须与 `prepare_build()` 的 package filter 一致。 + +- [ ] **Step 4: Rewire `run_tests()` without changing list semantics** + +在 `src/build/execute.cppm` 导入 `mcpp.build.test_targets`,把 1056-1126 的发现/合成代码替换为: + +```cpp +auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); +if (!root) { + mcpp::ui::error("no mcpp.toml found in current directory or any parent"); + return 2; +} + +auto discovered = discover_test_targets(*root, overrides.package_filter); +if (!discovered) { + mcpp::ui::error(discovered.error()); + return 2; +} +auto testRoot = discovered->packageRoot; +auto testTargets = std::move(discovered->targets); +if (testTargets.empty()) { + std::println("no tests found in tests/"); + return 0; +} +``` + +`--list` 后面的过滤、JSON record、summary、`prepare_build(includeDevDeps=true)` 和运行逻辑保持原样。 + +- [ ] **Step 5: Run unit and existing E2E tests and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" test --no-color -- --gtest_filter='TestTargets.*' +MCPP="$FRESH_MCPP" bash tests/e2e/159_test_list.sh +MCPP="$FRESH_MCPP" bash tests/e2e/157_test_glob_flags.sh +MCPP="$FRESH_MCPP" bash tests/e2e/90_workspace_test.sh +``` + +预期:unit 通过;三个 E2E 均输出 `OK`;损坏源码仍可 list;workspace 中同名测试不冲突。 + +- [ ] **Step 6: Commit the extraction** + +```bash +git add src/build/test_targets.cppm src/build/execute.cppm tests/unit/test_test_targets.cpp +git commit -m "refactor(test): share test target discovery" +``` + +### Task 2: Add Cross-Platform Atomic Replacement Primitive + +**Files:** +- Modify: `src/platform/fs.cppm` +- Create: `tests/unit/test_platform_fs.cpp` + +- [ ] **Step 1: Write failing filesystem tests** + +新增 `tests/unit/test_platform_fs.cpp`: + +```cpp +#include + +import std; +import mcpp.platform.fs; + +namespace fs = std::filesystem; + +namespace { +struct TempDir { + fs::path path = fs::temp_directory_path() / + std::format("mcpp-platform-fs-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + TempDir() { fs::create_directories(path); } + ~TempDir() { std::error_code ec; fs::remove_all(path, ec); } +}; +void write(const fs::path& p, std::string_view s) { std::ofstream(p) << s; } +std::string read(const fs::path& p) { std::ifstream in(p); return {std::istreambuf_iterator(in), {}}; } +} // namespace + +TEST(PlatformFs, ReplaceFileAtomicallyReplacesExistingFile) { + TempDir t; + auto source = t.path / "new.tmp"; + auto destination = t.path / "compile_commands.json"; + write(source, "new"); + write(destination, "old"); + std::error_code ec; + EXPECT_TRUE(mcpp::platform::fs::replace_file(source, destination, ec)) << ec.message(); + EXPECT_FALSE(fs::exists(source)); + EXPECT_EQ(read(destination), "new"); +} + +TEST(PlatformFs, ReplaceFailureKeepsExistingDestination) { + TempDir t; + auto destination = t.path / "compile_commands.json"; + write(destination, "last-known-good"); + std::error_code ec; + EXPECT_FALSE(mcpp::platform::fs::replace_file(t.path / "missing.tmp", destination, ec)); + EXPECT_TRUE(ec); + EXPECT_EQ(read(destination), "last-known-good"); +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +```bash +mcpp test --no-color -- --gtest_filter='PlatformFs.*' +``` + +预期:编译失败,`replace_file` 尚未声明。 + +- [ ] **Step 3: Implement `replace_file()`** + +在 `src/platform/fs.cppm` 的 exported namespace 中增加: + +```cpp +bool replace_file(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::error_code& ec); +``` + +Windows 实现必须直接调用: + +```cpp +if (MoveFileExW(source.wstring().c_str(), destination.wstring().c_str(), + MOVEFILE_REPLACE_EXISTING)) { + ec.clear(); + return true; +} +ec = std::error_code(static_cast(GetLastError()), std::system_category()); +return false; +``` + +POSIX 实现使用同文件系统 rename: + +```cpp +std::filesystem::rename(source, destination, ec); +return !ec; +``` + +禁止在任何平台先 `remove(destination)`;添加中文注释说明这是 last-known-good CDB 的原子性边界。 + +- [ ] **Step 4: Run focused tests and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" test --no-color -- --gtest_filter='PlatformFs.*' +``` + +预期:两项测试通过;Windows CI 真实执行 `MoveFileExW` 分支,Linux/macOS 执行 rename 分支。 + +- [ ] **Step 5: Commit the filesystem primitive** + +```bash +git add src/platform/fs.cppm tests/unit/test_platform_fs.cpp +git commit -m "feat(fs): add atomic file replacement" +``` + +### Task 3: Publish Compile Databases Atomically + +**Files:** +- Modify: `src/build/compile_commands.cppm` +- Modify: `src/build/backend.cppm` +- Modify: `src/build/ninja_backend.cppm` +- Modify: `tests/unit/test_compile_commands.cpp` +- Modify: `tests/unit/test_test_options.cpp` + +- [ ] **Step 1: Add failing writer and option tests** + +在 `tests/unit/test_compile_commands.cpp` 增加临时目录 helper,并新增: + +```cpp +TEST(CompileCommandsWriter, RejectsNonArrayFreshJson) { + TempDir t; + auto result = publish_compile_commands( + t.path / "compile_commands.json", R"({"file":"not-an-array"})", + [](const std::filesystem::path&) { return true; }); + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().message.find("JSON array"), std::string::npos); +} + +TEST(CompileCommandsWriter, UnchangedContentKeepsMtime) { + TempDir t; + auto path = t.path / "compile_commands.json"; + auto content = cdb({entry((t.path / "a.cpp").string(), "-DOK")}); + std::ofstream(path) << content; + auto before = std::filesystem::last_write_time(path); + auto result = publish_compile_commands( + path, content, [](const std::filesystem::path&) { return true; }); + ASSERT_TRUE(result.has_value()) << result.error().message; + EXPECT_FALSE(result->changed); + EXPECT_EQ(std::filesystem::last_write_time(path), before); +} + +TEST(CompileCommandsWriter, ReplacementFailurePreservesOldDatabase) { + TempDir t; + auto path = t.path / "compile_commands.json"; + auto oldContent = cdb({entry((t.path / "old.cpp").string(), "-DOLD")}); + auto newContent = cdb({entry((t.path / "new.cpp").string(), "-DNEW")}); + std::ofstream(path) << oldContent; + auto failReplace = [](const std::filesystem::path&, + const std::filesystem::path&, + std::error_code& ec) { + ec = std::make_error_code(std::errc::permission_denied); + return false; + }; + auto result = publish_compile_commands( + path, newContent, [](const std::filesystem::path&) { return true; }, failReplace); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(read_file(path), oldContent); + EXPECT_EQ(std::distance(std::filesystem::directory_iterator(t.path), + std::filesystem::directory_iterator{}), 1); +} + +TEST(CompileCommandsMerge, SortsFinalEntriesByFile) { + auto fresh = cdb({entry("/p/z.cpp", "-DZ"), entry("/p/a.cpp", "-DA")}); + auto merged = merge_compile_commands( + fresh, "[]", [](const std::filesystem::path&) { return true; }); + EXPECT_LT(merged.find("/p/a.cpp"), merged.find("/p/z.cpp")); +} +``` + +在 `tests/unit/test_test_options.cpp` 增加: + +```cpp +TEST(BuildOptions, CompileDatabaseIsOptionalByDefault) { + BuildOptions options; + EXPECT_FALSE(options.requireCompileDatabase); +} +``` + +- [ ] **Step 2: Run focused tests and verify RED** + +```bash +mcpp test --no-color -- --gtest_filter='CompileCommandsWriter.*:CompileCommandsMerge.SortsFinalEntriesByFile:BuildOptions.CompileDatabaseIsOptionalByDefault' +``` + +预期:编译失败,因为 writer 结果类型、`publish_compile_commands()` 和 `requireCompileDatabase` 尚不存在。 + +- [ ] **Step 3: Add structured writer result and atomic publication** + +在 `src/build/compile_commands.cppm` 导入 `mcpp.platform.fs`,公开: + +```cpp +struct CompileCommandsWriteResult { + bool changed = false; + std::size_t commandCount = 0; +}; + +struct CompileCommandsWriteError { + std::string message; +}; + +using ReplaceFile = std::function; + +std::expected +publish_compile_commands( + const std::filesystem::path& path, + std::string_view fresh, + const std::function& fileExists, + ReplaceFile replaceFile = mcpp::platform::fs::replace_file); + +std::expected +write_compile_commands(const BuildPlan& plan, const CompileFlags& flags); +``` + +`publish_compile_commands()` 按以下顺序实现: + +1. parse fresh,拒绝 discarded 或非数组; +2. 若旧文件可读则调用 `merge_compile_commands()`; +3. parse 最终文本并再次要求顶层数组; +4. 按 `file` 稳定排序; +5. 与旧文本相同则返回 `{false, size}`; +6. 在同目录写唯一 sibling temp,`flush()`、`close()` 并检查 stream 状态; +7. 调用 `replaceFile(temp, path, ec)`; +8. 失败时删除 temp 并返回带 path 和 OS error 的错误;不得删除 path; +9. 成功返回 `{true, size}`。 + +临时文件名使用时间戳加进程内原子序号,避免同一进程并发碰撞: + +```cpp +static std::atomic sequence{0}; +auto temp = path.parent_path() / + std::format(".{}.tmp.{}.{}", path.filename().string(), + std::chrono::steady_clock::now().time_since_epoch().count(), + sequence.fetch_add(1, std::memory_order_relaxed)); +``` + +- [ ] **Step 4: Propagate required/optional CDB policy through the backend** + +在 `src/build/backend.cppm` 增加: + +```cpp +struct BuildOptions { + bool requireCompileDatabase = false; + // existing fields unchanged +}; + +struct BuildResult { + std::size_t compileCommands = 0; + // existing fields unchanged +}; +``` + +在 `src/build/ninja_backend.cppm` 替换裸调用: + +```cpp +auto cdb = write_compile_commands(plan, flags); +if (!cdb) { + if (opts.requireCompileDatabase) { + return std::unexpected(BuildError{ + std::format("cannot publish compile_commands.json: {}", cdb.error().message), + plan.compileDbPath.empty() ? plan.projectRoot / "compile_commands.json" + : plan.compileDbPath}); + } + mcpp::ui::warning(std::format( + "compile_commands.json was not updated: {}", cdb.error().message)); +} +``` + +创建 dry-run 和真实 build 的 `BuildResult` 时都设置: + +```cpp +r.compileCommands = cdb ? cdb->commandCount : 0; +``` + +普通调用点不设置 `requireCompileDatabase`,所以现有 build/test 在 CDB 失败时继续 Ninja;只有后续 configure runner 将其设为 true。 + +- [ ] **Step 5: Run focused and regression tests and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" test --no-color -- --gtest_filter='CompileCommands*:BuildOptions.CompileDatabaseIsOptionalByDefault' +MCPP="$FRESH_MCPP" bash tests/e2e/76_compile_commands_generated.sh +MCPP="$FRESH_MCPP" bash tests/e2e/77_cdb_preserves_test_entries.sh +``` + +预期:writer tests 通过;现有 build 仍生成合法 CDB;test entries 的 merge/prune 行为不变。 + +- [ ] **Step 6: Commit atomic CDB publication** + +```bash +git add src/build/compile_commands.cppm src/build/backend.cppm src/build/ninja_backend.cppm tests/unit/test_compile_commands.cpp tests/unit/test_test_options.cpp +git commit -m "fix(build): publish compile database atomically" +``` + +### Task 4: Add Configure Prerequisite Staging and Runner + +**Files:** +- Create: `src/build/configure.cppm` +- Create: `tests/unit/test_configure.cpp` + +- [ ] **Step 1: Write failing BMI staging tests** + +新增 `tests/unit/test_configure.cpp`。fixture 构造最小 `BuildPlan`,准备 std BMI、cached dependency BMI 和 cached object: + +```cpp +#include + +import std; +import mcpp.build.configure; +import mcpp.toolchain.model; + +namespace fs = std::filesystem; + +TEST(ConfigurePrerequisites, StagesOnlyBmisNeededByLanguageTools) { + TempDir t; + mcpp::build::BuildPlan plan; + plan.outputDir = t.path / "out"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + plan.stdBmiPath = t.path / "cache/std.pcm"; + plan.stdCompatBmiPath = t.path / "cache/std.compat.pcm"; + write_file(plan.stdBmiPath, "std-bmi"); + write_file(plan.stdCompatBmiPath, "std-compat-bmi"); + + mcpp::build::CompileUnit dep; + dep.servedFromCache = true; + dep.providesModule = "demo.dep"; + dep.cachedBmi = t.path / "cache/demo.dep.pcm"; + dep.cachedObject = t.path / "cache/demo.dep.o"; + dep.object = "obj/demo.dep.o"; + write_file(dep.cachedBmi, "dep-bmi"); + write_file(dep.cachedObject, "dep-object"); + plan.compileUnits.push_back(dep); + + auto staged = mcpp::build::stage_configure_prerequisites(plan); + ASSERT_TRUE(staged.has_value()) << staged.error(); + EXPECT_TRUE(fs::exists(plan.outputDir / "pcm.cache/std.pcm")); + EXPECT_TRUE(fs::exists(plan.outputDir / "pcm.cache/std.compat.pcm")); + EXPECT_TRUE(fs::exists(plan.outputDir / "pcm.cache/demo.dep.pcm")); + EXPECT_FALSE(fs::exists(plan.outputDir / dep.object)); +} + +TEST(ConfigurePrerequisites, MissingCachedBmiFailsBeforePublication) { + TempDir t; + mcpp::build::BuildPlan plan; + plan.outputDir = t.path / "out"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + mcpp::build::CompileUnit dep; + dep.servedFromCache = true; + dep.providesModule = "demo.dep"; + dep.cachedBmi = t.path / "missing/demo.dep.pcm"; + plan.compileUnits.push_back(dep); + + auto staged = mcpp::build::stage_configure_prerequisites(plan); + ASSERT_FALSE(staged.has_value()); + EXPECT_NE(staged.error().find("demo.dep"), std::string::npos); +} +``` + +`TempDir`、`write_file()` 与 Task 2 fixture 同形,但在本文件本地定义,避免测试间隐藏依赖。 + +- [ ] **Step 2: Run focused tests and verify RED** + +```bash +mcpp test --no-color -- --gtest_filter='ConfigurePrerequisites.*' +``` + +预期:编译失败,模块与函数尚不存在。 + +- [ ] **Step 3: Implement `stage_configure_prerequisites()`** + +新增 `src/build/configure.cppm`,在 module fragment 中包含 ``,导入 `mcpp.build.prepare`、`mcpp.build.stage`、`mcpp.build.ninja`、`mcpp.diag`、`mcpp.toolchain.model`、`mcpp.toolchain.registry` 和 `mcpp.ui`。公开: + +```cpp +std::expected +stage_configure_prerequisites(const BuildPlan& plan); + +int run_configure_plan(BuildContext& ctx, bool verbose); +``` + +staging 使用 `stage::StageOptions{.verify = stage::Verify::Size}`,并严格限定为: + +```cpp +// std 与 std.compat 只物化 BMI;clangd 不需要对应 object。 +if (!plan.stdBmiPath.empty()) { + stage_one(plan.stdBmiPath, + mcpp::toolchain::staged_std_bmi_path(plan.toolchain, plan.outputDir), + "std"); +} +if (!plan.stdCompatBmiPath.empty()) { + stage_one(plan.stdCompatBmiPath, + mcpp::toolchain::staged_std_compat_bmi_path(plan.toolchain, plan.outputDir), + "std.compat"); +} + +auto traits = mcpp::toolchain::bmi_traits(plan.toolchain); +for (const auto& unit : plan.compileUnits) { + if (!unit.servedFromCache || !unit.providesModule || unit.cachedBmi.empty()) continue; + std::string fileName; + for (char ch : *unit.providesModule) fileName.push_back(ch == ':' ? '-' : ch); + fileName += traits.bmiExt; + stage_one(unit.cachedBmi, plan.outputDir / traits.bmiDir / fileName, + *unit.providesModule); +} +``` + +任何 staging 失败都返回带 module 名和 `stage_file` 原始诊断的 error;此函数不得复制 `cachedObject`、`stdObjectPath` 或 `stdCompatObjectPath`。 + +- [ ] **Step 4: Implement independent `run_configure_plan()`** + +执行顺序必须是: + +```cpp +int run_configure_plan(BuildContext& ctx, bool verbose) { + auto staged = stage_configure_prerequisites(ctx.plan); + if (!staged) { + mcpp::ui::error(staged.error()); + return 1; + } + + auto backend = mcpp::build::make_ninja_backend(); + BuildOptions options; + options.verbose = verbose; + options.dryRun = true; + options.requireCompileDatabase = true; + auto result = backend->build(ctx.plan, options); + if (!result) { + mcpp::ui::error(result.error().message); + if (!result.error().diagnosticOutput.empty()) + std::fputs(result.error().diagnosticOutput.c_str(), stderr); + return 1; + } + if (!mcpp::diag::flush(ctx.strict)) return 1; + + mcpp::ui::status("Configured", std::format( + "{} ({} compile command{})", ctx.manifest.package.name, + result->compileCommands, result->compileCommands == 1 ? "" : "s")); + return 0; +} +``` + +此函数中不得出现 `bmi_cache::populate_from()`、`write_build_cache()`、`ui::finished()`、`producedArtifacts` 或对 `target/.build_cache` 的写入。不要调用 `run_build_plan()`。 + +- [ ] **Step 5: Run focused tests and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" test --no-color -- --gtest_filter='ConfigurePrerequisites.*' +``` + +预期:只出现 BMI destination;cached object 和 std object 不存在;缺失 cached BMI 明确失败。 + +- [ ] **Step 6: Commit the configure execution layer** + +```bash +git add src/build/configure.cppm tests/unit/test_configure.cpp +git commit -m "feat(build): add configure-only execution" +``` + +### Task 5: Add `build --configure-only` CLI Routing + +**Files:** +- Modify: `src/cli.cppm:49-90,229-255` +- Modify: `src/cli/cmd_build.cppm:11-104` +- Create: `tests/e2e/202_configure_only_cdb.sh` +- Modify: `tests/e2e/01_help_and_version.sh` + +- [ ] **Step 1: Write the failing CLI E2E shell** + +新增 `tests/e2e/202_configure_only_cdb.sh`,line 2 保持空 capability: + +```bash +#!/usr/bin/env bash +# requires: +set -euo pipefail + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/app/src" "$TMP/app/tests" +cat > "$TMP/app/mcpp.toml" <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[build] +flags = [{ glob = "tests/**/*.cpp", defines = ["TEST_CDB_FLAG=1"], cxxflags = ["-DTEST_CXX_FLAG=1"] }] +EOF +cat > "$TMP/app/src/main.cpp" <<'EOF' +int main( { this source is intentionally invalid +EOF +cat > "$TMP/app/tests/smoke.cpp" <<'EOF' +int main() { return 0; } +EOF + +cd "$TMP/app" +out=$("$MCPP" build --configure-only 2>&1) || { + echo "configure-only rejected syntax-error source: $out"; exit 1; +} +[[ "$out" == *"Configured app"* ]] || { echo "missing configured status: $out"; exit 1; } +[[ -s compile_commands.json ]] || { echo "compile_commands.json missing"; exit 1; } +grep -q 'src/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'tests/smoke\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'TEST_CDB_FLAG=1' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'TEST_CXX_FLAG=1' compile_commands.json || { cat compile_commands.json; exit 1; } + +# 只允许配置元数据和 BMI;不得出现普通目标 object、binary 或成功缓存。 +if find target -type f \( -name '*.o' -o -name '*.obj' -o -path '*/bin/*' \) | grep -q .; then + echo "configure-only produced compile/link artifacts"; find target -type f; exit 1 +fi +[[ ! -e target/.build_cache ]] || { echo "configure-only wrote target/.build_cache"; exit 1; } + +echo OK +``` + +在 `tests/e2e/01_help_and_version.sh` 增加: + +```bash +[[ "$out" == *"--configure-only"* ]] || { echo "--help missing --configure-only"; exit 1; } +``` + +- [ ] **Step 2: Run E2E and verify RED** + +```bash +MCPP="$FRESH_MCPP" bash tests/e2e/202_configure_only_cdb.sh +MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh +``` + +预期:`202` 以 unknown option 失败,help test 因缺少 flag 失败。 + +- [ ] **Step 3: Register the CLI flag and help text** + +在 `src/cli.cppm` 的 build subcommand 增加: + +```cpp +.option(cl::Option("configure-only") + .help("Resolve the build plan and write compile_commands.json without compiling or linking")) +``` + +在顶层 `print_usage()` 的 Build options 增加同名行;不要增加 JSON format 选项或 `ide` 子命令。 + +- [ ] **Step 4: Route single-package and workspace configure requests** + +在 `src/cli/cmd_build.cppm` 导入 `mcpp.build.configure` 和 `mcpp.build.test_targets`,读取: + +```cpp +const bool configureOnly = parsed.is_flag_set("configure-only"); +``` + +抽出 cmd-local lambda,确保 workspace 与单 package 使用同一流程: + +```cpp +auto configure = [&](mcpp::build::BuildOverrides selected) -> int { + auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); + if (!root) { + mcpp::ui::error("no mcpp.toml found in current directory or any parent"); + return 2; + } + auto tests = mcpp::build::discover_test_targets(*root, selected.package_filter); + if (!tests) { + mcpp::ui::error(tests.error()); + return 2; + } + const bool includeDevDeps = !tests->targets.empty(); + auto ctx = mcpp::build::prepare_build( + print_fp, includeDevDeps, std::move(tests->targets), selected); + if (!ctx) { + mcpp::ui::error(ctx.error()); + return 2; + } + return mcpp::build::run_configure_plan(*ctx, verbose); +}; +``` + +workspace fan-out 中按现有 continue-on-failure/首个非零规则调用 `configure(mo)`;单 package 在 fast-path 判断之前处理 `configureOnly` 并直接返回。必须保证 configure-only 永远不调用 `try_fast_build()` 或 `run_build_plan()`。 + +普通 build 路径保持 `includeDevDeps=false` 与现有 fast-path 条件不变。 + +- [ ] **Step 5: Build fresh binary and verify GREEN** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +MCPP="$FRESH_MCPP" bash tests/e2e/202_configure_only_cdb.sh +MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh +``` + +预期:坏源码不触发编译但 CDB 含 source/test/flags;无 `.o/.obj`、无 bin、无 `.build_cache`;help 展示 flag。 + +- [ ] **Step 6: Commit CLI routing** + +```bash +git add src/cli.cppm src/cli/cmd_build.cppm tests/e2e/202_configure_only_cdb.sh tests/e2e/01_help_and_version.sh +git commit -m "feat(cli): add build configure-only mode" +``` + +### Task 6: Complete Dev-Dependency, Workspace, and Publication-Failure Coverage + +**Files:** +- Modify: `tests/e2e/202_configure_only_cdb.sh` + +- [ ] **Step 1: Extend E2E with a local dev-dependency include path** + +在脚本创建 app 前新增本地 dev package: + +```bash +mkdir -p "$TMP/devkit/include" "$TMP/devkit/src" +cat > "$TMP/devkit/mcpp.toml" <<'EOF' +[package] +name = "devkit" +version = "0.1.0" + +[build] +include_dirs = ["include"] +EOF +cat > "$TMP/devkit/src/devkit.cppm" <<'EOF' +export module devkit; +EOF +echo '#define DEVKIT_MARKER 1' > "$TMP/devkit/include/devkit.hpp" +``` + +给 app manifest 增加: + +```toml +[dev-dependencies] +devkit = { path = "../devkit" } +``` + +测试源码改为 `#include `,并用 Python 在 CDB 中精确找到 test entry,断言 arguments 含 devkit include path 和两个 test flags,而 main entry 不含 `TEST_CDB_FLAG`: + +```bash +python3 - compile_commands.json "$TMP/devkit/include" <<'PY' +import json, os, sys +entries = json.load(open(sys.argv[1], encoding="utf-8")) +test = next(e for e in entries if e["file"].replace("\\", "/").endswith("/tests/smoke.cpp")) +main = next(e for e in entries if e["file"].replace("\\", "/").endswith("/src/main.cpp")) +test_args = test["arguments"] +assert any(os.path.normpath(sys.argv[2]) in os.path.normpath(a) for a in test_args), test_args +assert any("TEST_CDB_FLAG=1" in a for a in test_args), test_args +assert any("TEST_CXX_FLAG=1" in a for a in test_args), test_args +assert not any("TEST_CDB_FLAG=1" in a for a in main["arguments"]), main["arguments"] +PY +``` + +- [ ] **Step 2: Extend E2E with workspace fan-out and member selection** + +同一脚本创建 virtual workspace `ws/{a,b}`,每个 member 有独立 `src/main.cpp` 和 `tests/main.cpp`。执行: + +```bash +cd "$TMP/ws" +"$MCPP" build --configure-only > configure-workspace.log +grep -q 'a/src/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'b/src/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'a/tests/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +grep -q 'b/tests/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } + +rm compile_commands.json +"$MCPP" build --configure-only -p a > configure-a.log +grep -q 'a/src/main\.cpp' compile_commands.json || { cat compile_commands.json; exit 1; } +if grep -q 'b/src/main\.cpp' compile_commands.json; then + echo "-p a leaked member b into CDB"; cat compile_commands.json; exit 1 +fi +``` + +manifest 使用现有 `[workspace] members = ["a", "b"]` 和最小 package metadata,不引入新的 selector 语义。 + +- [ ] **Step 3: Extend E2E with strict versus warning CDB publication policy** + +创建语法正确的 `publish-policy` 工程,把 `compile_commands.json` 预先建为含 sentinel 的非空目录,使 temp 写成功但 replace 到 directory 失败: + +```bash +mkdir compile_commands.json +echo keep > compile_commands.json/last-known-good + +out=$("$MCPP" build 2>&1) || { echo "normal build failed on optional CDB: $out"; exit 1; } +[[ "$out" == *"compile_commands.json was not updated"* ]] || { + echo "normal build did not warn: $out"; exit 1; +} +[[ -f compile_commands.json/last-known-good ]] || { + echo "normal build removed prior destination"; exit 1; +} + +rc=0 +out=$("$MCPP" build --configure-only 2>&1) || rc=$? +[[ $rc -ne 0 ]] || { echo "configure-only accepted failed CDB publication"; exit 1; } +[[ -f compile_commands.json/last-known-good ]] || { + echo "configure-only removed prior destination"; exit 1; +} +``` + +普通 build 还要断言最终 executable 存在,证明 warning 后 Ninja 确实继续;configure-only 不得输出 `Configured`。 + +- [ ] **Step 4: Run the extended E2E and fix only contract failures** + +```bash +MCPP="$FRESH_MCPP" bash tests/e2e/202_configure_only_cdb.sh +``` + +预期:输出 `OK`。若失败,只修 Task 1-5 已定义的行为;不得为测试新增协议、selector 或 toolchain 分支。 + +- [ ] **Step 5: Run adjacent regression E2Es** + +```bash +MCPP="$FRESH_MCPP" bash tests/e2e/18_devdeps_isolation.sh +MCPP="$FRESH_MCPP" bash tests/e2e/35_workspace.sh +MCPP="$FRESH_MCPP" bash tests/e2e/76_compile_commands_generated.sh +MCPP="$FRESH_MCPP" bash tests/e2e/77_cdb_preserves_test_entries.sh +MCPP="$FRESH_MCPP" bash tests/e2e/157_test_glob_flags.sh +MCPP="$FRESH_MCPP" bash tests/e2e/159_test_list.sh +``` + +预期:全部 `OK`;普通 build 仍不解析 dev-dependencies;现有 workspace/test/CDB 行为无回归。 + +- [ ] **Step 6: Commit complete E2E coverage** + +```bash +git add tests/e2e/202_configure_only_cdb.sh +git commit -m "test(build): cover configure-only workflows" +``` + +### Task 7: Document the User Contract + +**Files:** +- Modify: `README.md:217-223` +- Modify: `docs/00-getting-started.md:74-113` +- Modify: `docs/zh/00-getting-started.md:72-107` + +- [ ] **Step 1: Add concise English and Chinese usage docs** + +README feature list改为明确区分 build 与 configure-only: + +```markdown +- `compile_commands.json` generated automatically; `mcpp build --configure-only` + refreshes it for clangd/ccls without compiling ordinary translation units or + linking final targets +``` + +英文 Getting Started 在 build/run 示例后加入: + +```markdown +For editor setup before the source is buildable, run: + +```bash +mcpp build --configure-only +# Configured hello (... compile commands) +``` + +This resolves the same package, workspace member, profile, features, capability +providers, target and toolchain as a real build, and writes a CDB containing +regular sources plus `tests/**/*.cpp`. It may still run `build.mcpp`, install +missing dependencies/toolchains, and update lock/resolution metadata, so IDEs +must invoke it only in trusted workspaces. It does not compile ordinary TUs, +link final artifacts, or mark the project as successfully built. +``` + +中文文档加入语义等价段落,使用“只配置”而不是“只读”;明确插件只应依赖退出码与根 `compile_commands.json`,不承诺机器 stdout。 + +- [ ] **Step 2: Verify documentation matches help and design** + +```bash +rg -n "configure-only|只配置|trusted workspaces|可信工作区" README.md docs/00-getting-started.md docs/zh/00-getting-started.md +rg -n "NDJSON|snapshot|envelope|current.json|invalidatedBy" README.md docs/00-getting-started.md docs/zh/00-getting-started.md +``` + +预期:第一条命令命中三份文档;第二条无输出,避免把 RFC B/C 内容混入用户契约。 + +- [ ] **Step 3: Verify examples against the fresh binary** + +```bash +"$FRESH_MCPP" --help | grep -F -- '--configure-only' +MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh +MCPP="$FRESH_MCPP" bash tests/e2e/202_configure_only_cdb.sh +``` + +预期:help 和文档使用同一 flag;两个 E2E 均 `OK`。 + +- [ ] **Step 4: Commit documentation** + +```bash +git add README.md docs/00-getting-started.md docs/zh/00-getting-started.md +git commit -m "docs: explain configure-only compile database generation" +``` + +### Task 8: Full Verification, Diff Review, Push, and A-Part PR + +**Files:** +- Review all files changed from `origin/main` + +- [ ] **Step 1: Rebuild from the current branch and select the fresh binary** + +```bash +mcpp build --no-color +FRESH_MCPP="$(find "$PWD/target" -type f \( -path '*/bin/mcpp' -o -path '*/bin/mcpp.exe' \) -print0 | xargs -0 ls -1t | head -1)" +"$FRESH_MCPP" --version +``` + +预期:build 成功,fresh binary 版本与 `mcpp.toml` 一致。 + +- [ ] **Step 2: Run the complete unit/integration suite** + +```bash +"$FRESH_MCPP" test --no-color +``` + +预期:所有 test binaries 通过;不能把 cache hit、未发现测试或旧 binary 当作成功证据。 + +- [ ] **Step 3: Run focused cross-cutting E2Es with the fresh binary** + +```bash +for test_script in \ + tests/e2e/01_help_and_version.sh \ + tests/e2e/18_devdeps_isolation.sh \ + tests/e2e/35_workspace.sh \ + tests/e2e/76_compile_commands_generated.sh \ + tests/e2e/77_cdb_preserves_test_entries.sh \ + tests/e2e/90_workspace_test.sh \ + tests/e2e/157_test_glob_flags.sh \ + tests/e2e/159_test_list.sh \ + tests/e2e/202_configure_only_cdb.sh; do + MCPP="$FRESH_MCPP" bash "$test_script" +done +``` + +预期:每个脚本输出 `OK` 或其既有成功文本。 + +- [ ] **Step 4: Review the diff for scope and cache safety** + +```bash +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git diff origin/main...HEAD -- src/build src/platform src/cli.cppm tests README.md docs/00-getting-started.md docs/zh/00-getting-started.md +rg -n "populate_from|write_build_cache|\.build_cache|ui::finished|run_build_plan" src/build/configure.cppm src/cli/cmd_build.cppm +rg -n "ide |NDJSON|snapshot|envelope|current.json|invalidatedBy|FileLock" src tests/e2e/202_configure_only_cdb.sh README.md docs/00-getting-started.md docs/zh/00-getting-started.md +``` + +预期:`git diff --check` 无输出;configure module 不含被禁止的 build-success side effects;第二个范围扫描不出现 A 部分外协议实现。`cmd_build.cppm` 中普通 build 原有 `run_build_plan()` 命中属于预期,必须人工确认 configure-only 分支不可达。 + +- [ ] **Step 5: Confirm branch state and commit any review-only corrections** + +```bash +git status --short --branch +git log --oneline --decorate origin/main..HEAD +``` + +若自审只发现注释或窄小修正,修改后重新运行对应 focused tests,并提交: + +```bash +git add \ + src/build/test_targets.cppm \ + src/build/configure.cppm \ + src/build/execute.cppm \ + src/build/compile_commands.cppm \ + src/build/backend.cppm \ + src/build/ninja_backend.cppm \ + src/platform/fs.cppm \ + src/cli/cmd_build.cppm \ + src/cli.cppm \ + tests/unit/test_test_targets.cpp \ + tests/unit/test_platform_fs.cpp \ + tests/unit/test_compile_commands.cpp \ + tests/unit/test_configure.cpp \ + tests/unit/test_test_options.cpp \ + tests/e2e/01_help_and_version.sh \ + tests/e2e/202_configure_only_cdb.sh \ + README.md docs/00-getting-started.md docs/zh/00-getting-started.md +git commit -m "fix(build): tighten configure-only guarantees" +``` + +上述命令仅列出本计划拥有的路径;`git add` 会忽略其中未变化的文件。提交前仍需用 +`git diff --cached --name-only` 确认没有用户或其他任务的改动,禁止 `git add -A`。 + +- [ ] **Step 6: Rebase current `origin/main` only after preserving unrelated work** + +```bash +git fetch origin +git status --short +git rebase origin/main +``` + +预期:worktree clean 后 rebase;若出现用户或其他任务的未提交修改,先停止并区分归属,不得丢弃。rebase 后重复 Step 1-4 的 build、full test、focused E2E 与 diff review。 + +- [ ] **Step 7: Push only the feature branch to the fork** + +```bash +git push -u fork codex/configure-only-cdb +``` + +不得 push `main`,不得覆盖 `.xlings.json` pin。 + +- [ ] **Step 8: Create the A-part PR** + +PR title: + +```text +feat: generate compile database without building +``` + +PR body: + +```markdown +## Summary +- add `mcpp build --configure-only` using the real `prepare_build()` / `BuildPlan` path +- include regular sources and `tests/**/*.cpp`, with dev-dependencies and matching `[build].flags` +- stage only std/cached dependency BMIs needed by language tooling +- publish `compile_commands.json` atomically while keeping normal build failures non-fatal + +Part of #379. + +## Scope +- human output and exit code only +- no IDE wire protocol, snapshots, IDs, metadata graph, file lock, or `.xlings.json` change + +## Test plan +- [x] `mcpp build --no-color` +- [x] fresh binary `mcpp test --no-color` +- [x] focused CDB/test/workspace E2Es including `202_configure_only_cdb.sh` +- [ ] GitHub Actions Linux/macOS/Windows required checks +``` + +创建命令: + +```bash +gh pr create \ + --repo mcpp-community/mcpp \ + --head wellwei:codex/configure-only-cdb \ + --base main \ + --title "feat: generate compile database without building" \ + --body-file /tmp/mcpp-configure-only-pr.md +``` + +`/tmp/mcpp-configure-only-pr.md` 只作为临时 PR body,不提交仓库;内容必须与上方正文一致。 + +- [ ] **Step 9: Monitor CI and classify failures before editing** + +```bash +gh pr checks --repo mcpp-community/mcpp --watch +``` + +任一失败先读取本分支最新 run 的失败日志: + +```bash +RUN_ID="$(gh run list --repo mcpp-community/mcpp --branch codex/configure-only-cdb \ + --limit 1 --json databaseId --jq '.[0].databaseId')" +gh run view --repo mcpp-community/mcpp "$RUN_ID" --log-failed +``` + +比较本分支相关测试、同平台 main baseline、capability gating 和缓存状态。只修可归因于 +本 PR 的失败;不把既有环境/索引/toolchain 故障伪装成 configure-only 代码问题。 + +## Final Acceptance Checklist + +- [ ] 坏 C++ 源码仍可成功生成 CDB。 +- [ ] CDB 同时覆盖普通 source 和 tests,并携带 dev-dep include/defines 与 `[build].flags`。 +- [ ] 无测试项目不解析无关 dev-dependencies。 +- [ ] configure-only 不 spawn Ninja、不生成普通 object/bin、不写 `target/.build_cache`、不 populate BMI cache。 +- [ ] std BMI 与已缓存 dependency BMI 在 CDB 发布前物化;缺失/失败时旧 CDB 不变。 +- [ ] CDB 内容未变化不改 mtime。 +- [ ] Windows replacement 不先删除旧文件;Linux/macOS 使用同文件系统 rename。 +- [ ] 普通 build/test 的 CDB failure 只 warning 并继续;configure-only 同类 failure 返回非零。 +- [ ] workspace bare fan-out 与 `-p` 选择沿用既有语义。 +- [ ] 文档明确命令不是只读操作,IDE 必须遵守 workspace trust。 +- [ ] 没有 A 部分外 wire protocol、snapshot、metadata 或 pin 改动。 diff --git a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md new file mode 100644 index 00000000..e8d5665a --- /dev/null +++ b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md @@ -0,0 +1,411 @@ +# 机器可读输出协议 —— 对 RFC #379 的核对与修正 + +> 状态:设计待 review。核对基准 mcpp 2026.8.8.3(本机构建),xlings 2026.8.8.1。 +> 本文不复述 #379,只写**核对结果**与**它需要改的地方**。 + +## 0. 结论先行 + +RFC #379 的分析基本成立,逐条实测核对见 §1。但它有一个**结构性遗漏**,会让阶段 0 +到阶段 2 全部落空: + +> **未知选项的报错走 stdout,退出码 1,stderr 为空 —— 而且这段代码在依赖包 +> `mcpplibs.cmdline` 里,不在 mcpp。** + +RFC 花了整个阶段 0 去规定「未知**格式值**怎么办」,但客户端在真实使用中先撞到的是 +「未知**选项**」——因为它要探测的正是「这个版本认不认 `--format`」。而那条路径今天 +把人类文本打进了协议要独占的通道。 + +由此推出的第二件事更关键:**RFC 提议的协商入口 `mcpp --protocol-version`,解决不了 +它被设计来解决的那个问题**(§2.2)。 + +--- + +## 1. 逐条核对 + +方法:全部在本机对已构建的 mcpp 实跑,不看代码推断。一处我用 grep 得出的结论是错的 +(以为 `cache list --json` 不存在),实跑后推翻 —— flag 注册不带横线,grep `"--json"` +搜不到。**下面每一条都以实跑为准。** + +| RFC 主张 | 核对 | 结果 | +|---|---|---| +| §1.1 `--json` 是已发布的事实拼写 | `xpkg parse --json` ✓、`cache list --json` ✓ 均有输出 | **成立** | +| §1.3 `self env` 无 JSON | `--format json` / `--json` 均报 unknown option | **成立** | +| §1.4 `xpkg parse --json` 无 schemaVersion | 输出 `{"namespace":"","name":"7zip","form":"A"}` | **成立** | +| §1.5 `xlings interface` 的 outputSchema 全空 | `--list` 20 个 capability,**20/20** 只有 `exitCode` | **成立** | +| §1.6 CDB 早于 ninja 写出 | `ninja_backend.cppm:1549` `write_compile_commands`,其后才起 ninja | **成立** | +| §1.1 `pack --format` 语义冲突 | `pack --format bogus` → rc=2,人类文本走 **stderr** | **成立** | + +### 1.1 新发现 F1:未知选项污染 stdout + +``` +$ mcpp cache list --format json +stdout: Error: unknown option: --format +stderr: (空) +rc: 1 +``` + +对照 mcpp 自己的错误路径: + +``` +$ mcpp xpkg parse /nonexistent.lua +stdout: (空) +stderr: error: cannot open '/nonexistent.lua' +``` + +**mcpp 自己的 `ui::error` 是对的**(`src/ui.cppm:327`,写 stderr)。洞恰好开在参数解析 +的边界上,而且代码在依赖里: + +``` +mcpplibs-x-cmdline/0.0.2/cmdline-0.0.2/src/cmdline.cppm:127 + std::println("Error: {}", result.error().message); // ← stdout +``` + +于是当前有**四种**未知输入行为,而 RFC 只识别出后两种(且它们都还只在 #372 里): + +| 场景 | 通道 | rc | +|---|---|---| +| 未知**选项**(`cache list --format …`) | **stdout** | 1 | +| 未知**值**(`pack --format bogus`) | stderr | 2 | +| #372 `ide snapshot --format yaml` | stdout | 3 | +| #372 `ide configure --format json` | stderr | 2 | + +--- + +## 2. 这改变了什么 + +### 2.1 阶段 0 的范围划错了 + +RFC 的阶段 0 只规定未知**值**。但客户端探测能力时发的第一个请求,在旧版本上命中的 +是未知**选项**。只规定值,等于把最常走的那条路留在污染状态。 + +**修正:阶段 0 必须覆盖选项层,而这意味着要动 `mcpplibs.cmdline`**(改成写 stderr, +或让 mcpp 不用它内建的错误打印)。这是 RFC 的行数估算里没有的一项跨包工作。 + +### 2.2 协商入口解决不了它被设计来解决的问题 + +RFC 给 `--protocol-version` 的理由是: + +> 客户端启动时判断该走哪条路,不用先 spawn 一个可能失败的命令再解析错误消息 + +实测,在**任何尚未实现它的 mcpp** 上: + +``` +$ mcpp --protocol-version +stdout: Error: unknown option: --protocol-version +stderr: (空) +rc: 1 +``` + +它自己就是「那个可能失败的命令」,而且失败与成功**同通道**。客户端仍然只能靠解析 +stdout 内容来判断 —— 正是要避免的事。 + +换 `--json` 拼写也一样:两种拼写在旧版本上都走同一条未知选项路径。**所以拼写之争 +(`--format` vs `--json`)对发现机制毫无影响**,RFC 用「布尔无法表达未知值分支」来论证 +`--format` 是对的,但不要把它当成解决了发现问题。 + +**修正:唯一对旧版本稳健的客户端规则是「正向识别」**—— + +> 读 stdout,尝试 JSON 解析;解析成功**且**含 `schemaVersion` 与 `kind` 才认为拿到了 +> 协议输出。**不得**以退出码 0、或「命令没报错」作为判据。 + +这条规则要写进 `docs/spec/`,并且是 envelope 必须**自识别**的理由:客户端没有别的 +可靠信号。`--protocol-version` 仍值得有(新版本上省一次 spawn),但它是优化,不是 +契约地基。 + +### 2.3 `destructive` 字段:同意,但它落在错误的一侧 + +RFC 提议在 envelope 里放 `destructive`。这对**已经拿到输出**的客户端有用,但 +VSCode 的 untrusted-workspace 门需要在**执行之前**知道 —— 而拿到 envelope 的时候, +副作用已经发生了。 + +**修正:`destructive` 必须同时出现在两个地方**—— + +- envelope 里(事后自述,便于日志与审计) +- `mcpp --protocol-version` 的输出里,以「命令 → destructive」的静态表形式给出 + +后者才是 untrusted 门能用的东西。这也顺带回答 RFC §七 给 @sunrisepeak 的第 4 问: +接受这个字段,但只放 envelope 不够。 + +--- + +## 3. 修正后的阶段划分 + +改动集中在阶段 0 与阶段 1,阶段 2 之后与 RFC 一致。 + +### 阶段 0(前置)—— stdout 归属,而不只是「未知格式」 + +1. **`mcpplibs.cmdline` 的解析错误改写 stderr**,并给出非 0 退出码。跨包改动,需要 + 发一个 cmdline 版本;mcpp 侧同步 bump。 +2. mcpp 侧统一:未知**值**与未知**选项**都走 stderr + rc=2(用法错误)。 + —— RFC 原文提议未知格式「走 stdout + envelope」。**建议改为 stderr + rc=2**: + 一个还不知道自己该输出什么格式的请求,不该往协议通道里写东西;客户端按 §2.2 的 + 正向识别规则,stdout 无 JSON 即判定为「不支持」,语义完整且不需要额外约定。 +3. 加一条 e2e:对**每个**声明支持 `--format` 的命令,断言未知值与未知选项在通道、 + 退出码上一致。这是防止 §1.1 那张表重新长出来的唯一办法。 + +### 阶段 1 —— `mcpp.wire` envelope + +与 RFC 一致(从 #372 的 `src/ide/model.cppm` / `snapshot.cppm` 提升),补两点: + +- envelope **必须自识别**:`schemaVersion` + `kind` 是客户端唯一的正向判据(§2.2) +- `--protocol-version` 的输出**包含命令 → destructive 的静态表**(§2.3) + +### 阶段 2–4 + +与 RFC 一致。`--json` 永久别名、入口一处归一、不打 deprecation 警告 —— 这套做法在 +本仓库有先例(`src/toolchain/compat.cppm`),照搬即可。 + +`pack --format tar|dir`:建议走 (a)**声明为例外**。它没有 stdout 机器输出,不参与本 +协议;加 `--layout` 别名是为一个不存在的一致性付迁移成本。 + +--- + +## 4. 必须守住的纪律(RFC §四)—— 同意,补一条 + +RFC 说「envelope 的价值全在 `data` 被真正版本化和文档化上」,并以 xlings 的 +20/20 空 outputSchema 为反例。核对属实。 + +补一条**可执行**的约束,否则「只增不改」会像那 20 个 schema 一样退化成口号: + +> 每个 `kind` 的 golden fixture 必须**在测试里被反向验证**:改一个字段名要让测试变红。 +> 只有「跑通了」而没有「改坏了会红」的 fixture,和没有 fixture 等价。 + +这一条不是文风要求。本轮工作里我自己就写过三个「因为错误的理由而通过」的测试: +一个 e2e 分支够不到它要测的模式,一个判据选成「跑不跑得起来」(在任何有宿主工具链 +的机器上恒真),一个 `ldd` 解释器行造成的误报。三个都是靠**主动把实现改坏、看测试 +会不会红**才发现的。 + +--- + +## 4.5 追加优化项:CDB 的 `arguments` 带着 shell 引号(实测复现,不限 Windows) + +用户报告 Windows 上 CDB 里的 `-fmodule-file=` 带多余双引号,每次构建后要手工删掉才能 +配好 clangd。**是真问题,而且比报告的更严重** —— 在 Linux 上用带空格的项目路径复现了 +同一形状: + +``` +'-fmodule-file=std=/tmp/.../my project/.../std.pcm <- 闭合引号没了 +'-fmodule-file=std.compat=/tmp/.../my project/.../ +'-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' +``` + +前两条的**闭合引号被切掉了**:一个参数被从中间劈成两半,还带着一个孤立的开引号。 +clangd 逐字 exec `arguments`(不经 shell),于是它拿到的是 +`'-fmodule-file=std=/tmp/.../my` 和 `project/.../std.pcm'` 两个参数。 + +### 机制 + +四步,每一步单独看都合理: + +1. `flags.cppm::shell_quote_arg` 的 `kNeedsQuote` 含反斜杠和空格 —— + **Windows 路径必然含反斜杠**,所以在 Windows 上*每个*带路径的 flag 都被加引号; + Linux 上只有路径含空格时才触发。这解释了为什么它被当成 Windows 专属问题。 +2. 加引号是为了让 flag 在 ninja 的 `sh -c "<整条命令>"` 里存活 —— 对 ninja 是**对的**。 +3. `compile_commands.cppm::split_flags` 把那条字符串重新切成 token,并撤销 ninja 的 + `$ ` / `$:` / `$$` 转义。 +4. 但它**不认引号**:既不剥掉,也不把引号内的空格当作非分隔符。 + +### 这是同一个「注释写对了一半」 + +`split_flags` 自己的注释把原则写对了: + +> CDB consumers like clangd exec the `arguments` array literally — no ninja +> involved — so escaped chars must be undone + +它识别出**ninja 转义**要撤销,却漏了**同一个理由下**的 shell 引号。代码实现了原则的 +一半。与本文档 §2 是同一形状。 + +### 判据与修法 + +**判据(必须是这个,不能是「clangd 能用了」)**:CDB 的 `arguments` 里,任何 token 都 +不得以引号开头或结尾;且带空格的路径必须是**一个** token。前者防引号泄漏,后者防这次 +发现的「劈成两半」。 + +两条路: + +- **(a) 让 `split_flags` 认引号**(建议)。它本来就是那个分词器,分词器认引号是本分。 + 注意顺序:ninja 的 `$ ` 反转义必须发生在**引号内**,否则被引号包住的空格会先把 token + 切断 —— 这正是现在发生的事。 +- (b) 让 CDB 不再从「拼好的 shell 字符串」重新分词,而是让 argv 以 `vector` + 一路传下来。更干净,但要动 `compute_flags` 的返回形状,范围大得多。 + +**回归测试**:带空格路径的项目 + llvm 工具链(gcc 不发 `-fmodule-file`,覆盖不到), +断言上述判据。这条在 Linux 上就能跑,不需要 Windows 机器 —— 而它至今没被发现,正是 +因为所有 e2e 的项目路径都不含空格。 + +### 与本协议的关系 + +CDB 是 mcpp 已经在发的**机器可读输出**,只是没走 envelope。它今天就在破坏一个真实 +客户端(clangd),而且用户在手工修补它。**建议把它排在阶段 1 之前**:协议做得再好, +也救不了一个内容本身就坏掉的出口。 + +## 5. 与 #372 的关系 + +同意 RFC §五 的 A/B/C 拆分。补充一点:A 部分里的 +`write_fresh_compile_commands` + 原子替换,**独立于本协议就应该合入** —— 现状是 +`ofstream` 截断写,clangd 可能读到半截 JSON。那是既有缺陷,不该等协议设计定稿。 + +--- + +## 6. 待确认(在 RFC 七问之外) + +1. **`mcpplibs.cmdline` 的解析错误改走 stderr** —— 这是跨包改动且影响所有使用者, + 是否接受?若不动依赖,替代方案是 mcpp 不用 `App::run()` 的内建错误打印、自己接管 + `ParseResult`(mcpp 侧约 20 行,但要覆盖每个命令入口)。 +2. `destructive` 静态表放进 `--protocol-version` 输出 —— 是否接受(§2.3)? + +## 7. 已定(2026-08-08) + +- **未知格式走 stderr + rc=2**(不是 stdout + envelope)。客户端按 §2.2 的正向识别 + 规则,stdout 无 JSON 即判定为「不支持」,语义完整,且一个还不知道该输出什么格式的 + 请求不往协议通道写东西。 +- **`pack --format tar|dir` 声明为例外**,不加 `--layout` 别名。它没有 stdout 机器 + 输出,不参与本协议;为一个不存在的一致性付迁移成本不值。 + +--- + +# 第二轮 review(2026-08-08,综合 @wellwei / @Ximiaw 反馈) + +## R0. 先承认一件事:第一轮**没有做需求侧分析** + +第一轮全部是供给侧 —— 核对 mcpp 现在的行为,找实现层缺陷。**没有**读 +mcpp-vscode#8 / #5 的实际需求,也没有验证「提议的接口是否闭合了它们」。 + +wellwei 指出的四条里,**两条正是需求侧分析才会抓到、而第一轮漏掉的**(R1、R3)。 +这一节存在的意义不是自责,而是记下判据:**契约设计里,「我方能提供什么」和 +「对方需要什么」是两次独立的核对,做了前者不等于做了后者。** + +## R1. `--json` 的 payload 兼容 —— 第一轮把它当纯拼写别名,错了 + +实测: + +``` +mcpp cache list --json 顶层键 = ["entries", "root"] ← 没有 envelope +mcpp xpkg parse --json 顶层 = {"namespace","name","form"} ← 没有 envelope +``` + +第一轮跟着 RFC 写「入口一处归一化,核心只见 `--format`」,默认了**拼写兼容 ⇒ payload +兼容**。不成立:如果核心随后统一输出 `{schemaVersion, kind, data, diagnostics}`,任何 +依赖旧顶层结构的消费者都会断,**包括本仓库的 e2e**。 + +**修正 —— 三选一必须先定,否则 wire v1 不能冻结:** + +| 方案 | 含义 | 代价 | +|---|---|---| +| **(A) `--json` 永久 legacy payload,`--format json` 才是 envelope** | 两条出口,语义不同 | 每个命令两份序列化;但**没有任何现存消费者会断** | +| (B) 老命令保留旧顶层,只做增量字段;envelope 只用于新命令 | 老命令永远拿不到 `schemaVersion` | mcpp-vscode#8 §7.1 的请求落空 | +| (C) 有意 breaking,给迁移窗口 | 干净 | 与「`--json` 永久保留、不打警告」的既定纪律冲突 | + +**倾向 (A)**,理由是它与本仓库既有范式一致(`compat.cppm`:旧拼写永久接受,核心只见 +规范形式),而这里要永久接受的是**旧 payload**,不是旧拼写。代价是明确且有界的。 + +## R2. `destructive` —— 与第一轮结论一致,但 wellwei 补了一刀 + +第一轮已指出它做不了 preflight 门(§2.3),wellwei 独立确认,并补充: +**单个 bool 也不够** —— 「只写 CDB」「写全局缓存」「可能触网」「执行工作区代码」是四种 +不同的边界,VSCode 的门需要区分。 + +**修正**:`--protocol-version` 的静态表不是 `destructive: bool`,而是**效应集合**: + +```jsonc +"commands": { + "self env": { "effects": [] }, + "xpkg parse": { "effects": [] }, + "metadata": { "effects": ["read-project"] }, + "metadata --resolved": { "effects": ["read-project","write-cache","network"] }, + "build --configure-only": { "effects": ["read-project","write-project","write-cache","exec-build-script"] } +} +``` + +`exec-build-script` 单独成项,因为 `build.mcpp` 会执行工作区里的代码 —— 那是 untrusted +门唯一真正在乎的一条。 + +## R3. `self env` 首次运行会初始化 —— 真问题是**作用域**,不是真假 + +wellwei 指出 `env_report()` 调 `config::load_or_init()`,不是只读。核实,并把两次测法 +都记下来,因为**第一次测法不足以定责**。 + +第一次:只在全新 `MCPP_HOME` 上跑一次 `self env`,看到 6 个文件就下结论 —— 没有排除 +「任何 mcpp 命令的首次初始化」。加对照后: + +| 命令 | 全新 home 上创建项数 | +|---|---| +| `mcpp --version` | 0 | +| **`mcpp xpkg parse --json`** | **0** | +| **`mcpp cache list --json`** | **0** | +| `mcpp self env` | **6**(`config.toml` `registry` `cache` `bin` `build-cache` `log`) | +| 已初始化 home + `self env` | **无变化,只读** | + +两条结论: + +1. **「首次运行必然初始化」不成立。** mcpp-vscode#8 实际消费的两个命令 + (`xpkg parse` / `cache list`)**什么都不建**。所以按「任何写盘都算」的口径, + `destructive` **仍然携带信息** —— 它不会退化成恒真。 +2. `self env` 是首次运行触发一次性初始化,**不是每次都写**。 + +**因此真正的分叉是作用域,不是真假。** untrusted-workspace 门在乎的是「碰不碰用户的 +项目」「执不执行项目代码」;mcpp 建**自己的 home** 不属于这一类。两条路: + +- **(a) 效应集合,作用域写在名字里**(倾向)。`self env` = `["init-mcpp-home"]`, + 客户端自己决定要不要在意;既不把它打成「危险」,也不让 `false` 被读成「什么都不写」。 +- (b) 直接定「作用域 = 工作区」,`self env` 标 `false`。更简单,但 spec 必须写死 + 「本字段不涵盖 mcpp 自身 home 的初始化」,否则 `false` 有歧义。 + +方法教训单独记:**「跑一次看有没有文件」不足以给副作用定责,必须有一个不走同一路径的 +对照命令。** 这一处若不加对照,就会得出「所有命令都会初始化,所以这个字段没意义」—— +一个由测法而非事实支撑的结论。 + +## R4. 阶段 0 的边界比第一轮画的更宽 + +实测退出码: + +``` +未知子命令 rc=127 (cli.cppm:610,stderr,正确) +未知选项 rc=2 ← 本轮 W1 已修(原 rc=1 + stdout) +未知值(pack) rc=2 (stderr,原本就对) +未捕获异常 rc=70 (main.cpp,stderr) +``` + +W1 修掉了最坏的一条。但 wellwei 说得对:**光接管 parse error 不够**,还要把 +usage / runtime / internal 的 rc 映射写成契约,并覆盖异常边界 —— 否则客户端仍然要靠 +猜。这条现在是 `docs/spec/` 的内容,不是代码。 + +## R5. 全局 `schemaVersion` 把不相关的 kind 耦合在一起 + +wellwei 的观察成立。一个全局版本号意味着 `mcpp.env` 的字段变更会推高 `mcpp.xpkg` 的 +版本,客户端无从判断哪个 kind 真的变了。 + +**修正**:envelope 版本与 kind 数据版本分开;并且 `--protocol-version` 不能只回 +`{min,max}`,要回**它支持哪些 kind 及各自版本**: + +```jsonc +{ "envelope": { "min": 1, "max": 1 }, + "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 } } +``` + +## R6. Ximiaw 的实测修正:supported-key 词汇表只覆盖部分段 + +他实测 24 段 + 源码核对,结论是「parser 已掌握每段 supported keys」**只对少数段成立**: + +- 有白名单的:`[build]`、`[resources]` 等 6 处常量(`src/manifest/toml.cppm`) +- 开放词汇段(dependencies / indices / toolchain …):无键清单,只校验值形态 +- **其余固定段(package / profile / runtime / xlings / workspace / pack):未知键被静默 + 吞掉**,合法键散在解析逻辑里 + +所以「顺带导出词汇表」不是现成的序列化。**但第三档的收敛有独立价值**:把「打错键名 +毫无提示」变成警告,对 mcpp 自身就是健壮性收益 —— 这一条应当独立于本协议推进,不该 +被 wire v1 阻塞。 + +## R7. 修正后的范围 + +| | 第一轮 | 本轮修正 | +|---|---|---| +| W0 CDB 引号 | 做 | **不变**(已完成) | +| W1 stdout 归属 | 做 | **已完成**,但 rc 映射契约要补进 spec(R4) | +| W2 `mcpp.wire` | envelope + `destructive: bool` | envelope + **效应集合**;`--protocol-version` 回 **kinds 及各自版本**(R2/R5) | +| W3 `--format` 归一 | 纯拼写别名 | **先定 payload 兼容策略(R1),否则不能冻结 v1** | +| W4 接入 | self env ~15 行 | **先拆只读 resolver**(R3);`cache list` / `xpkg parse` 按 R1 的结论决定走哪条 | +| 新增 | — | manifest 固定段的 supported-key 收敛(R6),**独立推进** | + +**结论:W2/W3/W4 在 R1 定案前不应动手** —— 那是唯一会决定 wire v1 形状的分叉。W0 与 +W1 与它正交,已完成。 diff --git a/.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md b/.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md new file mode 100644 index 00000000..0ebe4dcd --- /dev/null +++ b/.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md @@ -0,0 +1,379 @@ +# 载荷版本与契约漂移:四个缺陷,一条线 + +**日期**:2026-08-08 +**性质**:设计提案,待 review。本文不改代码。 +**触发**:2026.8.8.1 发布当天暴露的四个缺陷,其中三个是我在这一轮自己引入或自己漏掉的。 + +**关联**: +- `.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`(本轮的上游设计) +- mcpp#352 / #375、mcpp-index#179(已 revert,PR#180)、mcpp#377 +- xlings `.agents/docs/2026-08-06-subos-architecture-proposal.md` 的 R1–R7 + +--- + +## 0. TL;DR + +四个缺陷,**同一条线**: + +> **mcpp 对 xlings 的每一项事实,都是「自己猜一个」而不是「读一个」;而它猜的那个,和它烙进产物的那个,是两个独立的答案。** + +| # | 缺陷 | 谁引入 | 现状 | +|---|---|---|---| +| **D1** | 载荷探测取**目录序第一个**(注释说是最高,代码没排序),与 gcc 烙定的不是同一份 | 早已存在,本轮触发 | **main 曾变红,已 revert** | +| **D2** | subos_info **wire format 读错**,功能完全不工作 | 本轮我引入 | PR#377 已修 | +| **D3** | 升级后**复用升级前缓存**,修复静默不生效 | 本轮我引入 | PR#377 已修 | +| **D4** | 沙箱 xlings **永不升级**,且 mcpp 打印偏差却不作为 | 早已存在 | **未修** | + +D1 和 D4 是同一件事的两面:**mcpp 把一个可变的外部世界当成了装机时的快照**。D2 和 D3 是另一件事的两面:**我用与实现同源的理解去写测试,于是测试证明的是「我和我自己一致」。** + +--- + +## 1. D1:载荷探测与 gcc 烙定的版本是两个独立答案 + +### 1.1 实测 + +`compat.glx-runtime` 加了一条 `xim:graphics` 依赖(为修 #352)。`mesa` 声明: + +```lua +"xim:glibc@>=2.38", -- 下界,不是钉死 +``` + +解析器装了 **glibc 2.44**,与既有的 2.39 并存。然后: + +``` +.../xim-x-glibc/2.39/lib64/libc.so.6: version `GLIBC_2.42' not found + (required by <测试二进制>) +``` + +**在 `asio-module`、`core` 上炸 —— 与图形毫无关系的包。** + +### 1.2 机制 + +`probe.cppm` 调 `find_sibling_tool(compilerBin, "glibc")`。那个函数的注释说它取最高版本 —— **代码没有排序**: + +```cpp +// Return the first (highest) version dir that exists. +for (auto& v : std::filesystem::directory_iterator(root, ec)) { + if (v.is_directory(ec)) return v.path(); +} +``` + +`directory_iterator` 是无序的,所以真实行为是**取 readdir 碰巧给出的第一个**。本机实测顺序是 `2.44, 2.39`,于是选中 2.44 —— 但**换台机器、装个别的包、甚至删一个目录,结果都可能变**。这是本文档记录的第三处「注释描述了代码没有的行为」(另两处见 §3.3)。 + +于是: + +| | 谁决定 | 结果 | +|---|---|---| +| **编译/链接** 对着哪份 glibc | mcpp 的探测(readdir 第一个) | **2.44**(本机;换台机可能不同) | +| **运行时** PT_INTERP / specs | gcc 载荷装机时 elfpatch 烙定 | **2.39** | + +两个独立的答案,**在只装一份 glibc 时恒相同**,所以从未被迫达成一致 —— 这正是 xlings 侧 R1/R2 诊断出的 P1「一个问题多个回答者」,原样出现在 mcpp。 + +### 1.2b 为什么「新 glibc 向后兼容」救不了这一格 + +glibc 的兼容性是**单向**的:对 2.39 构建的产物在 2.44 上跑 ✅;对 2.44 构建的在 2.39 上跑 ❌。本次报错正是第二种 —— 加载了 2.39 的 `libc.so.6`,而二进制要 `GLIBC_2.42` 的符号。**问题不是运行时太老,是编译期跑到前面去了而运行期没跟上。** + +### 1.2c 参照系:发行版、Nix、和 mcpp 现在在哪 + +| | 发行版(Arch / Fedora) | Nix / xlings | **mcpp 今天** | +|---|---|---|---| +| 同时几份 glibc | **1** | 多份并存 | 多份并存 | +| 路径 | **无版本** `/lib64/ld-linux-x86-64.so.2` | 带版本 `…/2.44/lib64/…` | 带版本 | +| 升级 | **就地替换同一个文件** | 在旁边装一份新的 | 在旁边装一份新的 | +| 编译期与运行期的一致性 | **构造上不可能分歧** | 每个消费者冻结自己的选择 | **两个独立决定者** ❌ | + +滚动发行版之所以能一直换 glibc 而不炸,是因为**编译侧和运行侧是同一个文件**;新 glibc 保留全部旧符号版本,所以既有二进制照跑。 + +**Arch 上唯一会出这个错的场景,与本次同型:部分升级**(`pacman -Sy pkg` 不带 `-u`)—— 装了一个对着更新 glibc 构建的包,而 glibc 没跟着升。Arch 明文禁止部分升级,理由一模一样。 + +**结论:mcpp 用了 Nix 的布局(多版本并存 + 版本化路径),但没有兑现那个布局的前提 —— 每个消费者的选择必须只有一个决定者。** 这不是「要不要支持多 glibc」的问题,而是既有布局的前提没被满足。 + +### 1.2d xlings 早就预见了,而一条下界绕开了它 + +`glibc.lua` 明写 `latest` 故意停在 2.39: + +> Moving `latest` to 2.44 would be safe for the same reason — but it would also **silently change which glibc every existing home resolves to**, and that is a decision to make deliberately rather than as a side effect of adding a version. + +而 `mesa` 的 `xim:glibc@>=2.38` **绕开了 `latest` 指针**,把 2.44 直接拉了进来。一个刻意做出的「不动 latest」的决定,被一条依赖的下界从侧面推翻。 + +**这一条值得同步给 xlings**:范围解析取最高满足者,与 `latest` 别名承载的「这个 home 该用哪一份」是两个不同的意图,而前者会静默压过后者。是否该让范围解析在 `latest` 满足区间时优先取 `latest`,是 xlings 侧的开放问题。 + +### 1.3 判据:它不是「装了新 glibc」的问题 + +**任何**让 home 里多出一份更高版本 glibc 的操作都会触发它 —— 一条新依赖、一次 `xlings install`、一个新包。**这个雷一直在,只是这次我踩了。** + +### 1.4 方案 + +| | A. 探测改为「与 gcc 烙定的那份一致」 | B. 探测取最高但校验一致性,不一致就报错 | C. 维持现状 | +|---|---|---|---| +| 编译/运行是否可能分裂 | **不可能** | 可能,但会被拦下 | 会,且静默 | +| 需要什么输入 | gcc 烙定的 glibc 版本 | 同左 | — | +| 多 glibc 场景 | 天然正确(跟工具链走) | 需要人工介入 | 坏 | +| 结果是否确定 | ✅ | ✅ | ❌ **随目录序变** | + +**推荐 A,B 作为过渡期的护栏。** + +「gcc 烙定的是哪份」不需要猜:`post_install.cppm` 已经有 `extract baked loader from clang cfg` 的能力(扫 `/ld-linux-`),而 gcc 侧的 specs 里也写着。**更好的做法是让 xlings 落盘**(见 §5),但即使不落盘,从 gcc 自己的产物反推也比「取最高」正确 —— 因为它反推的正是运行期会用的那一份。 + +**必须同时做的一件事**:`mcpp doctor` 增加一条 —— *home 里有多份 glibc 且编译期选中的不是 gcc 烙定的那份*。这是 D1 唯一的可观测入口;没有它,下一次触发同样只会表现为一个与 glibc 无关的包炸掉。 + +--- + +## 2. D4:沙箱 xlings 永不升级,而 mcpp 知道却不说 + +### 2.1 实测 + +``` +$ mcpp self env +xlings binary = /home/speak/.mcpp/registry/bin/xlings ← 2026.8.2.1 +xlings pinned = 2026.8.6.3 +``` + +**mcpp 把两个数字并排打印出来,不作任何评价。** + +`fallback/xlings_binary.cppm:22`: + +```cpp +if (std::filesystem::exists(destBin)) return destBin; // 无任何版本检查 +``` + +### 2.2 两种布局,只有一种会更新 + +| 布局 | xlings 从哪来 | 升级 mcpp 时 | +|---|---|---| +| **tarball 相对**(`MCPP_HOME=<解包目录>/registry`) | release 自带的 `registry/bin/xlings` | ✅ 随之更新 | +| **默认 home**(`MCPP_HOME=~/.mcpp`) | 首次 `self init` 时 acquire 的那份 | ❌ **永不更新** | + +CI 走第一种(所以 CI 一直是新的),开发机与普通用户走第二种。**「我记得已经修过」和「实测是旧的」都对,只是说的不是同一种布局。** + +### 2.3 后果:整条修复链断在这里 + +`#352` 需要四环:mcpp 读 `subos_info` → index 拉 `xim:graphics` → **graphics 把声明写进 subos** → subos 有 `subos_info`。第三环需要沙箱 xlings ≥ 2026.8.5.1;`graphics.lua` 显式探测 `type(subos.env) ~= "function"`,**老客户端上声明被静默丢弃**。 + +实测本机沙箱 xlings 2026.8.2.1 的二进制里,`subos_info` 这个字符串出现 **0 次**。 + +### 2.4 方案 + +| | A. 版本低于 pin 就自动替换 | B. doctor 报告 + 一条显式命令 | C. 每次启动检查 | +|---|---|---|---| +| 无感 | ✅ | ❌ 用户要动手 | ✅ | +| 风险 | 换掉用户正在用的 xlings | 低 | 每次 exec 多一次版本读 | +| 与「mcpp 不接管 subos 状态」的边界 | ⚠️ 擦边 | 一致 | ⚠️ | + +**推荐 A + B**:acquire 时比对版本,低于 pin 则替换(这是 mcpp 自己 vendored 的那份,不是用户的系统 xlings,替换它不越界);同时 doctor 把偏差列为一条 finding。 + +**A 的判据必须是「低于 pin」而不是「不等于 pin」** —— 用户手动放了一份更新的进去,不该被降级。 + +> **⚠️ 这条要小心**:替换沙箱 xlings 会改变依赖解析的行为。参考 `index-floor-must-degrade` 的教训——**发布数据不得让程序失效**。所以替换后必须能回退,且 doctor 要能说出「现在用的是哪一份、为什么」。 + +--- + +## 3. D2/D3:测试证明的是「我和我自己一致」 + +### 3.1 D2 —— wire format 读错,功能完全不工作 + +盘上真实的 `envs` 是**以 binding 为键的对象**;我的 reader 期望**数组**。`is_array()` 为假 ⇒ 循环一次不执行 ⇒ 对着真实 subos **一个变量都不应用**。 + +**10 个单测 + 1 条 e2e 一路全绿**,因为每一份 fixture 都是我按同一个臆想手写的。 + +### 3.2 根因不是笔误,是方法 + +> **用与实现同源的理解去造 fixture,抓不出对 wire format 的误解。只有取自写入方的 fixture 能抓。** + +已加 `RealXlingsCapture`(真实输出逐字副本),parser 改为从 xlings 的 reader **誊写**而非建模 —— 这顺带抓到第二处分歧:xlings 丢弃 `op` 不是 `set`/`prepend` 的声明,我原来全收。 + +### 3.3 D3 —— 自证断言,以及注释撒的谎 + +我原本的 e2e 只跑一次 `mcpp run`,PASS —— 而它存在的目的是覆盖**缓存快路径**,那次根本没走。加上「这次必须走快路径,否则报错」之后,立刻暴露快路径丢环境变量,**同一条断言接着又抓到第二个**(缓存行读写顺序不匹配)。 + +更难看的是:reader 的注释**声称**它把旧缓存当 miss —— 那个检查从来没写过。**注释描述了代码没有的行为,比漏掉更糟:它让下一个读代码的人不去查。** + +### 3.4 提议:三条可执行规则 + +- **R-A 跨仓 wire format 的 fixture 必须取自写入方**,不得手写。形式:golden 文件 + 注明来源与采集时间。 +- **R-B 一条测试如果存在的目的是覆盖某条路径,必须先断言「这次确实走了那条路径」。** 否则它可能一直 PASS 而零覆盖。 +- **R-C 注释若描述一条行为,该行为必须有对应断言。** 「reader 把旧缓存当 miss」这句话本身就该是一个测试名。 + +--- + +## 4. 四个缺陷的公共上游 + +``` +D1 载荷版本 ── 猜(取最高) ─┐ +D4 xlings ── 快照(装机时那份) ─┼─→ 把可变的外部世界当成不变的 +D2 wire fmt ── 猜(臆想格式) ─┤ +D3 缓存契约 ── 猜(注释当实现) ─┘ +``` + +**D1/D4 是「外部世界会变,而我记的是装机那一刻」;D2/D3 是「外部契约有确定形状,而我记的是我以为的形状」。** + +两者的解药是同一句话:**凡是别人拥有的事实,读它,并且用它自己的产物来验证你读对了。** + +--- + +## 5. 与上游设计的关系:这轮验证了什么、推翻了什么 + +`2026-08-07-xlings-as-runtime-substrate-design.md` 的核心论点是「mcpp 把 xlings 当目录约定而不是可查询的运行时」。**这一轮四个缺陷全部是那个论点的实例**,而且其中两个是我一边写着那份设计一边犯的 —— 这本身是最强的证据。 + +它也**修正**了那份设计的一处轻描淡写:§7 把「xlings 落盘 exports」排在 P1,理由是「A 降级路径足以工作」。**D1 证明那不成立** —— 没有权威落盘,mcpp 只能靠目录扫描猜一个,而猜出来的那个与 gcc 烙定的那个是两个独立答案。**`exports` 落盘应升到 P0**,且内容要包含「gcc 这份载荷烙定的 glibc 是哪一份」。 + +--- + +## 6. 分期 + +**P0(main 已红过,先止血)** +1. ✅ revert mcpp-index#179(PR#180) +2. ✅ PR#377:wire format + 旧缓存(D2/D3) +3. **D1 护栏**:`mcpp doctor` 报告「多份 glibc 且编译期选中的不是 gcc 烙定的那份」 + +**P1(让 #352 真正到达用户)** +4. **D4**:acquire 时版本比对 + 替换,doctor 报告偏差 +5. **D1 正解**:载荷探测改为跟随 gcc 烙定的版本 +6. 之后才重新落 mcpp-index 的图形栈迁移 + +**P2** +7. xlings 侧落盘 `exports`(含 gcc↔glibc 的绑定),mcpp 改读它 +8. R-A/R-B/R-C 写进贡献规范 + +--- + +## 7. 明确不做 + +- **不在 recipe 里回避 D1**(比如把 mesa 的 glibc 下界改成钉死)。那是把引擎缺陷摊给包作者,而且下一个用下界的包会再犯 —— 与 `aarch64-native-gcc-payload-is-musl` 那次「归因成包缺 arch 轴是错的」同型。 +- **不让 mcpp 接管 subos 生命周期**。D4 的方案只替换 mcpp 自己 vendored 的那份二进制,不碰用户的系统 xlings、不管理 subos。 +- **不追 glibc 版本**。D1 的正解是「跟随工具链烙定的那一份」,不是「总用最新」。 + +--- + +## 8. 开放问题 + +- **Q1** D1 的「gcc 烙定的是哪份 glibc」:从 gcc 产物反推(specs / PT_INTERP),还是等 xlings 落盘?前者能立刻做,后者更正确 —— 是否两者都要,反推作为降级? +- **Q2** D4 的替换时机:`self init` 时?每次 acquire 时?还是只在 doctor --fix?自动替换与「用户手动放了一份新的」如何共存(判据「低于 pin 才换」是否足够)? +- **Q3** 多 glibc 是 xlings 明确要支持的方向(`subos new --runtime glibc@2.44`)。D1 修好之后,mcpp 对「一个 home 里多份 glibc」的正式立场是什么 —— 跟随工具链,还是跟随 subos 的 `runtime`?后者更符合上游设计的 S1,但需要 D4 先修。 +- **Q4** 本轮 revert 掉的图形栈迁移,重新落地时如何验证「装这个栈不会影响 home 的其余部分」?**这一条是这次事故真正缺失的那个测试。** 一个可执行形状:装栈前后各跑一次与图形无关的成员(`asio-module`、`core`),断言产物的 `PT_INTERP` 与 `readelf -V` 的 glibc 符号版本上界不变。 +- **Q5**(xlings 侧,§1.2d)范围解析取最高满足者,压过了 `latest` 别名承载的「这个 home 该用哪一份」。是否该让 `latest` 在满足区间时优先?这决定了「加一个新版本进索引」是不是一个安全操作。 + +--- + +## 9. 实施回写(2026-08-08,PR #378) + +方案落地过程中,有三条是设计当时没看见的,其中第一条**修正了本方案自己的一个假设**。 + +### 9.1 兼容回落读的是「我刚删掉的机制的产物」 + +§3.5 写的兼容路径是:subos 不自述时,读工具链自身烙入的值 —— gcc 的 `specs`、 +clang 的 `.cfg`。这在**已有机器**上成立,因为那些文件是**旧 fixup 写出来的**,还留在 +盘上。 + +在**全新安装**的机器上不成立:mcpp 已经不写它们了,所以它们不存在。链条是 +`无 binding → 无 payloadPaths → 无 --dynamic-linker → 产物拿宿主 loader`,hermetic +检查如实报出 `/lib64/ld-linux-x86-64.so.2 (outside the sandbox)`。 + +**每台开发机都还留着那些文件,所以本机验证永远是绿的,CI 才红。** 这条应当写进判据: + +> 删掉一个机制时,先列出谁在读它的产物。兼容回落尤其危险 —— 它读的往往正是旧机制 +> 留下的东西。 + +补的第三个来源是**编译器自身的 `PT_INTERP`**:由 patchelf 走查写入(该机制仍在运行), +指向的正是这套工具链对齐的那个 glibc payload,与 specs 曾经持有的是同一个事实,但 +**不依赖 mcpp 写过任何东西**。自己读 ELF 头而不用 `patchelf --print-interpreter`—— +本函数在 prepare 期运行,那里不保证 patchelf 已解析。 + +验证方式是把 `specs` 文件真的挪开再跑,而不是读代码。 + +### 9.2 归属谓词加在了一个到不了的函数里 + +§3.5 的 D5 把 `remap_xlings_baked_sysroot` 的判据从「存在」改为「归属」。但调用方 +`probe_sysroot` 的**第一步**是「存在且可用 ⇒ 直接 return」,只有在路径**不存在**时才 +往下走到 remap —— 而该函数存在的全部意义,正是处理「存在、可用、却属于别人」。 + +本机实测:这个仓库的 gcc 报的 sysroot 指向 `xim-pkgindex-fromsource` 下的 +`.xlings/subos/default`,每次构建都在吃另一个仓库的头文件。修法是把归属问在可用性 +**之前**;「外来但可用」保留为最后兜底,因为没有 registry subos 可映射的机器上,取 +「什么都不要」会直接坏掉。 + +### 9.3 生成的 spec 放在了缓存能删的目录里 + +`--no-cache` 会 `remove_all(outputDir)`,而 spec 是 prepare 期写进那个目录的 ⇒ **每次 +冷构建 + gcc 必挂**。修法不是调顺序,而是把「ninja 跑之前该文件必须存在」当作**不变量 +在消费点持有** —— 这样也扛得住用户手动 `rm -rf target`。 + +值得记的是它**怎么被发现的**:e2e 201 是专为这套机制写的测试,但它只跑暖构建,而暖 +构建下文件从上一次留着。**是全量本地 e2e 扫出来的,不是定向子集。** + +### 9.4 链接行去重 + +C-runtime 的那组 flags 同时经 `link_toolchain_flags` 与 `payload_ld` 发出,整组在链接行 +上出现两次。对正确性无害,但链接行有 128KiB 硬上限(`MAX_ARG_STRLEN`),真实 +workspace 已经用掉 43%。`payload_ld` 现在只服务 clang-with-cfg 的 PayloadFirst,其余组合 +本就已经有了。 + +### 9.5 一条跨平台低级错误 + +`rel.native().rfind("..", 0)` 在 Windows 上**编译不过**(`native()` 是 `wstring`),所有 +Windows job 直接挂;而且它在能编译的地方语义也错 —— 名叫 `..cache` 的目录并不是逃逸。 +包含关系是**路径分量**的问题,`path_is_under` 按分量问。 + +### 9.6 「拒绝」的边界:一个 payload 是答案,两个才是问题 + +§3.2 把判据写成「没有权威就拒绝走 payload-first」。落地后发现这条**收得过紧**。 + +所有兼容来源(gcc 的 `specs`、clang 的 `.cfg`、编译器自身的 `PT_INTERP`)都是**某个更早 +的机制写出来的东西**,而一台机器可以合法地一个都没有。这时按 §3.2 的字面判据就该拒绝, +产物于是拿宿主 loader —— 比「猜错版本」更糟,因为它连沙箱都出去了。 + +真正的轴不是「mcpp 能不能去看 payload 目录」,而是**有没有得选**: + +- **恰好一个 glibc payload** ⇒ 没有选择可言。它是这套工具链产出的任何产物唯一可能绑定的 + 运行时,拒绝等于拒答一个只有一个答案的问题。 +- **两个或以上** ⇒ 沉默。这正是事故本身的形状,必须由 subos 来定。 + +这与被移除的旧规则的区别是决定性的:旧规则在**有得选**的时候按 `readdir` 顺序选,而且 +一直到装进第二个 payload 之前都看起来是对的。 + +另外,记录下来的 loader 路径可能指向 subos **视图**(`/subos/default/lib/ld-linux-…`) +而不是 payload —— 视图路径里根本没有版本段。解析前先 canonical 化(R6:产物绑 payload, +不绑可变视图)。 + +验证方式:把 gcc 的 `specs` 挪开**并且**把 gcc 自身的 `PT_INTERP` 改指宿主 loader,即三条 +兼容来源全部失效,binding 仍解析得出,产物仍拿 payload loader。 + +### 9.7 兼容来源是**记录**,不是权威 + +§9.6 补上单例回落后 CI 依然红,日志直说了原因: + +``` +probe: runtime 'glibc@2.39' is not installed in this home +``` + +那台机器上只有一个 glibc payload:**2.44**;而兼容记录烙的是 **2.39**。 + +三条兼容来源(`specs`、`.cfg`、`PT_INTERP`)都是**过去某个时刻的记录** —— 装机时写的、 +fixup 时写的 —— 而 payload 被替换(升级、回收)时**没有任何东西回头去改它们**。§3.2 的 +「精确匹配、绝不回落」于是如实地拒绝了 2.39,结果是没有 payloadPaths、没有 +`--dynamic-linker`、产物拿宿主 loader。 + +**判据要补一句:一条记录只有在它指的东西还在,才算数。** + +现在每条记录都对「实际安装了哪些 payload」核对一遍: + +- 记录指的 payload **还在** ⇒ 采信(即使装了多个,它就是产物会加载的那个) +- 记录指的 payload **不在了** ⇒ 跳过,继续往下(单例回落往往能答) +- 什么都答不上来 ⇒ 沉默 + +这条与 §9.6 是同一件事的两面:**§9.6 说「没得选就不算猜」,§9.7 说「化石不算权威」。** +两条都是在收窄「拒绝」的适用范围 —— 拒绝该留给**真正有歧义**的情况。 + +### 9.8 musl 目标不碰 glibc payload + +`sysroot_mode` 里补 loader / lib 目录时漏了 musl 护栏。musl 的 sysroot 是自包含的,而 +同时被探测到的 glibc payload 属于**宿主**工具链 —— 把它放进链接路径后,ld 会把 glibc 的 +静态 `libc.a` 拉进一次 musl 链接: + +``` +undefined reference to `_DYNAMIC' (dl-reloc-static-pie.o) +hidden symbol `_DYNAMIC' isn't defined +``` + +同一函数里紧接着的 header 分支**本来就有** `is_musl_target` 判断 —— 那句话对库和 loader +一样成立,只是当时没写上。由 e2e 103 抓到,同样是全量跑扫出来的。 diff --git a/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md b/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md new file mode 100644 index 00000000..f807b0c5 --- /dev/null +++ b/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md @@ -0,0 +1,95 @@ +# 机器可读输出协议 —— 拆分实施计划 + +> 设计:`2026-08-08-machine-readable-output-protocol-design.md`(对 RFC #379 的核对与修正) +> 已定:未知格式走 **stderr + rc=2**;`pack --format` **声明为例外**。 + +## 顺序与理由 + +设计文档 §4.5 把 CDB 引号缺陷排在阶段 1 之前,理由是「协议做得再好,也救不了一个内容 +本身就坏掉的出口」。照此排: + +``` +W0 CDB 引号(§4.5) —— 今天就在坏,且与协议正交,先修 +W1 stdout 归属(阶段 0) —— 未知选项/未知值统一 stderr + rc=2 +W2 mcpp.wire 模块(阶段 1) —— envelope + destructive + --protocol-version +W3 --format 归一(阶段 2) —— --json 永久别名,入口一处归一 +W4 接入(阶段 3a) —— self env / xpkg parse / cache list +``` + +W4 之后的 `metadata` / `--configure-only`(阶段 3b/3c)**不在本次范围** —— 它们是新增 +能力而非契约统一,且依赖 #372 的拆分结论。 + +## W0 —— CDB 的 `arguments` 带 shell 引号 + +**File:** `src/build/compile_commands.cppm` + +- [ ] **Step 1** 红:带空格路径 + llvm 的单测,断言 + ① 任何 token 不以引号开头/结尾;② 带空格的路径是**一个** token +- [ ] **Step 2** 跑,确认 FAIL(今天两条都不满足) +- [ ] **Step 3** 让 `split_flags` 认引号。**顺序陷阱**:ninja 的 `$ ` 反转义必须发生在 + 引号**内**,否则被引号包住的空格先把 token 切断 —— 那正是现在的 bug +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +判据不能写成「clangd 能用了」——在不含空格的路径上恒真,正是它至今没被发现的原因。 + +## W1 —— stdout 归属 + +**Files:** `src/cli.cppm`、`src/main.cpp`(+ 依赖 `mcpplibs.cmdline` 的处置) + +- [ ] **Step 1** 红:e2e 断言未知**选项**与未知**值**在通道(stderr)与退出码(2)上一致 +- [ ] **Step 2** 跑,FAIL(今天:未知选项 → stdout/rc=1;未知值 → stderr/rc=2) +- [ ] **Step 3** 实现。`mcpplibs.cmdline:127` 用 `std::println` 写 stdout,在依赖里 —— + **本次不改依赖**,改为 mcpp 侧接管 `ParseResult`:不走 `App::run()` 的内建错误 + 打印,自己输出到 stderr 并返回 2 +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +## W2 —— `mcpp.wire`(独立模块) + +**New:** `src/wire.cppm` + +- [ ] **Step 1** 单测:envelope 形状(`schemaVersion` / `kind` / `destructive` / + `mcpp.version` / `mcpp.protocol{min,max}` / `data` / `diagnostics`) +- [ ] **Step 2** 实现。`Diagnostic/Position/Range/Severity` 与 envelope 构造从 #372 的 + `src/ide/model.cppm`、`src/ide/snapshot.cppm` 提升(设计文档 §5-B) +- [ ] **Step 3** `mcpp --protocol-version`:输出 `{min,max}` **加命令 → destructive 静态表** + (设计 §2.3 —— untrusted 门要在执行前知道) +- [ ] **Step 4** golden fixture,且**反向验证过**(改字段名要变红,设计 §4) +- [ ] **Step 5** Commit + +## W3 —— `--format` 归一 + +**Files:** `src/cli.cppm`(入口归一)、各 `cmd_*.cppm` + +- [ ] **Step 1** 单测:`--json` 与 `--format json` 产出**逐字节相同** +- [ ] **Step 2/3** 入口一处 `--json` → `--format json`;核心只见 `--format` +- [ ] **Step 4** 绿;`--json` 永久保留、**不打 deprecation 警告** +- [ ] **Step 5** Commit + +`pack --format tar|dir` 不动(已定为例外),文档写明。 + +## W4 —— 接入 envelope + +**Files:** `cmd_self.cppm`(新增 `self env --format json`)、`cmd_xpkg.cppm`、`cmd_cache.cppm` + +- [ ] **Step 1** 每个 `kind` 一个 golden fixture(反向验证过) +- [ ] **Step 2** `self env` 覆盖 mcpp-vscode#8 §2.4:MCPP_HOME / registry / xlings home / + index repos / default toolchain +- [ ] **Step 3** `xpkg parse` / `cache list` 包进 envelope(`schemaVersion` 随之而来) +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +## 平台特化的去处 + +按要求:平台差异进 `src/platform/`,协议本体独立 `.cppm`。 + +- 协议本体 → **`src/wire.cppm`**(新模块,不依赖任何命令) +- 路径/引号的平台差异 → 已在 `src/platform/`(`env::path_list_separator` 等);W0 的 + 引号识别是**平台无关**的(引号本身两平台都要剥),不新增平台分支 + +## 交付 + +- 全部并入 **PR #385**(与 docs 一起) +- 版本已是 2026.8.8.3(刚发布)⇒ 本次**跳过版本 bump**,除非 CI 要求 +- xlings pin 已是 2026.8.8.1(索引最新)⇒ 无需再抬 diff --git a/.agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md b/.agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md new file mode 100644 index 00000000..954ad6de --- /dev/null +++ b/.agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md @@ -0,0 +1,840 @@ +# xlings 运行时底座 —— 实施计划 + +> **For agentic workers:** 逐 task 执行,每个 task 自带 red→green→commit 循环。 + +**Goal:** 让 mcpp 把 xlings 当作可查询的运行时底座:补上 libc 轴的分发契约(`c_runtime`)、消费 subos 的运行时身份与环境声明、修掉 `self-contained` 打包模式自产的 `/proc/self/exe` 陷阱。 + +**Architecture:** 三条缝。S2 在既有 `mcpp.build.distribution` 的三层模型(Role→Contract→Mechanism)上**复用词汇、新增 libc 机制表**,由 `linkmodel` 单点渲染;S3 新增 `mcpp.xlings.subos_info` 读取 xlings 的 `subos_info` 块,`run`/`test` 应用其 env;S1 让 runtime 身份成为显式轴。P2(视图寻址)不做。 + +**Tech Stack:** C++23 modules,gtest(`tests/unit/`),shell e2e(`tests/e2e/`),nlohmann::json(`mcpp.libs.json`)。 + +设计文档:`.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md` + +## Global Constraints + +- **词汇不新造**:契约三值 `self-contained` | `toolchain-coupled` | `host-coupled`,与 `[build] cxx_runtime` 逐字相同。runtime binding 拼写 `@`,与 xlings `subos_info.runtime` 逐字相同。 +- **默认值不变**:`c_runtime` 缺省 = `toolchain-coupled` = 今天的行为。引入这条轴本身**不改变任何现有产物**。 +- **Mechanism 必须是 total function**:每格要么给 flags,要么 `degraded=true` + 非空 `diagnostic`。静默无操作是禁止的结果。 +- **不引入新的宿主探测**:mcpp 里不得出现探测 GPU / 图形能力的代码。 +- **schema 缺失/未知版本必须出声**:一行 stderr,构建继续。 +- **P2 不做**:`toolchain-coupled` 保持载荷寻址,不切视图寻址。 +- **平台特殊处理下沉**:平台差异放 `src/platform/` 对应模块,不在 `build/` 里写 `#ifdef`。 + +--- + +### Task 1: `self-contained` 打包模式的 `/proc/self/exe` 陷阱 + +**Files:** +- Modify: `src/pack/pack.cppm:431-446`(`write_bundle_all_wrappers`) +- Modify: `docs/02-pack-and-release.md`(§ Mode `self-contained` 之后) +- Test: `tests/e2e/30_pack_modes.sh` + +**Interfaces:** +- Consumes: 无 +- Produces: 分发包契约新增环境变量 `MCPP_BUNDLE_DIR`(bundle 根目录绝对路径),由 `run.sh` / 顶层同名脚本导出。 + +**背景**:ELF 规范禁止 `PT_INTERP` 用 `$ORIGIN`,所以 `self-contained` 只能经 `ld.so --library-path` 启动;代价是内核把 `/proc/self/exe` 指向 loader、`/proc/self/cmdline` 混入 `--library-path`。本 task 不消除这个约束(那需要安装期改写 PT_INTERP,见设计文档 Q6 选项 i),而是**把它变成一个有声明、可编程的契约**,并写进文档。 + +- [ ] **Step 1: 写失败的 e2e 断言** + +在 `tests/e2e/30_pack_modes.sh` 的 Mode B(bundle-all)段落后追加: + +```sh +# self-contained: the wrapper must export MCPP_BUNDLE_DIR so an application +# can resolve resources despite /proc/self/exe pointing at the loader. +grep -q 'MCPP_BUNDLE_DIR' "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" || { + echo "Mode B: run.sh does not export MCPP_BUNDLE_DIR"; exit 1; } +out=$("$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" 2>&1) || true +echo "$out" | grep -q 'Hello' || { echo "Mode B: wrapper broke execution: $out"; exit 1; } +``` + +- [ ] **Step 2: 跑,确认失败** + +```bash +bash tests/e2e/30_pack_modes.sh +``` +Expected: FAIL,`run.sh does not export MCPP_BUNDLE_DIR` + +- [ ] **Step 3: 改 wrapper** + +`src/pack/pack.cppm` 的 `write_bundle_all_wrappers` body 改为: + +```cpp + auto body = std::format( + "#!/bin/sh\n" + "# Auto-generated by `mcpp pack --mode self-contained`. Launches the\n" + "# bundled binary through the bundled dynamic linker so the package\n" + "# is portable across glibc versions.\n" + "#\n" + "# TRAP, and why this variable exists: launching through the loader\n" + "# makes the kernel set /proc/self/exe to the LOADER, not to the\n" + "# program, and /proc/self/cmdline carries --library-path. Any\n" + "# \"find my resources next to the executable\" logic silently\n" + "# resolves against the loader's directory instead. MCPP_BUNDLE_DIR\n" + "# is the answer that survives: resolve against it first and fall\n" + "# back to /proc/self/exe only when it is unset.\n" + "here=$(cd \"$(dirname \"$0\")\" && pwd)\n" + "MCPP_BUNDLE_DIR=\"$here\"; export MCPP_BUNDLE_DIR\n" + "exec \"$here/lib/{}\" --library-path \"$here/lib\" \"$here/bin/{}\" \"$@\"\n", + loaderName, binaryName); +``` + +- [ ] **Step 4: 跑,确认通过** + +```bash +bash tests/e2e/30_pack_modes.sh +``` +Expected: PASS + +- [ ] **Step 5: 文档** + +在 `docs/02-pack-and-release.md` 的 self-contained loader 说明之后追加一节: + +```markdown +#### Trap: `/proc/self/exe` under the bundled loader + +Launching through the loader means the kernel sets `/proc/self/exe` to the +**loader**, not to your program, and `/proc/self/cmdline` carries the +`--library-path` argument. Every "find my resources next to the executable" +path silently resolves against the wrong directory — GUI toolkits looking for +fonts or `assets/`, and helper binaries shipped alongside the program. + +The wrapper exports `MCPP_BUNDLE_DIR` (the bundle root) for this. Resolve +against it first: + +```c +const char *base = getenv("MCPP_BUNDLE_DIR"); /* set by run.sh */ +/* fall back to /proc/self/exe only when unset */ +``` + +If your application cannot be changed, use `--mode vendored` instead: it +repoints `PT_INTERP` at the host loader, so `/proc/self/exe` is correct — at +the cost of requiring the host's glibc to be at least as new as the one the +artifact was built against. +``` + +同一节的中文版加到 `docs/zh/` 下对应文件(若存在)。 + +- [ ] **Step 6: Commit** + +```bash +git add src/pack/pack.cppm docs/02-pack-and-release.md tests/e2e/30_pack_modes.sh +git commit -m "fix(pack): the self-contained wrapper broke /proc/self/exe and never said so" +``` + +--- + +### ~~Task 2–4: `c_runtime` 契约轴~~ —— 已撤销 + +**做过,全绿,然后整条 reset。** 设计文档 §3-S2 与 §5.2 记录了理由;摘要: + +- 它是照着 #375 提报者**提出的解法**做的,不是解他的**问题**(问题是「产物没法分发」) +- 它唯一的新能力是 `host-coupled` = 把产物链到**宿主 libc**,而 hermetic 策略禁止穿越的第一条就是 `/lib*` 下的 libc。**mcpp 能不用 host 就不用 host** +- 去掉那个值之后这条轴不剩任何能力(`self-contained` 已由 `--target ...-musl` 表达) +- 它会造出「可不可以用宿主 libc」的**第二个回答者**(既有的是 `[build] allow_host_libs`) + +**代替它的是 Task 4'(下)**:#375 的真问题有三条 hermetic 答案,全都已存在;缺口在「其中一条自己是坏的」(Task 1 已修)加「三条都不可发现」。 + +--- + +### Task 4': 让三条 hermetic 分发路径可发现 + +**Files:** +- Modify: `docs/02-pack-and-release.md` + `docs/zh/02-pack-and-release.md` +- Modify: `docs/00-getting-started.md` 或 `10-publishing-a-library.md`(择一放「怎么分发」的入口) +- Test: 文档改动,无自动化断言;判据是 §10 的 V1(三条路径各自在干净容器里跑通) + +**要写清的三条,并列、等价、都不用宿主 libc:** + +| 路径 | 命令 | libc 从哪来 | 什么时候选它 | +|---|---|---|---| +| A. 生态闭环 | `mcpp emit xpkg` → `xlings install` | 目标机自己的 xlings 载荷,**elfpatch 装机期重指 PT_INTERP/RUNPATH** | 目标机在生态内 | +| B. 静态单文件 | `--target x86_64-linux-musl` | 自带,静态 | 任何 Linux,零运行期依赖 | +| C. 自带运行时 | `mcpp pack --mode self-contained` | 自带这套工具链的 glibc + loader | 任何 Linux,含比构建机更老的 | + +**A 要特别写明一件容易误读的事**:产物里烙的 `PT_INTERP` 指向构建机路径**不构成分发障碍** —— 走 A 时目标机的 xlings 会重写它。#375 观察到的「路径不存在所以起不来」,前提是绕开生态直接拷贝二进制。 + +**不写**:任何「让产物链到系统 libc」的做法。已有的那个决定只有一个入口(`[build] allow_host_libs`),文档不给它第二个说法。 + +- [ ] **Step 1** 在 `docs/02-pack-and-release.md` 顶部「Two axes」之前加一节 "Three ways to ship, none of which use the host's libc",内容如上表 +- [ ] **Step 2** 中文版同步 +- [ ] **Step 3** Commit + +--- + +### Task 5: 读 xlings 的 subos 自描述 + +**Files:** +- Create: `src/xlings/subos_info.cppm`(模块 `mcpp.xlings.subos_info`) +- Test: `tests/unit/test_subos_info.cpp` + +**Interfaces:** +- Consumes: `mcpp.libs.json` +- Produces: + ```cpp + namespace mcpp::xlings::subos { + inline constexpr int kSupportedSchema = 1; + struct EnvDecl { std::string var, op, value; }; + struct Provider { std::string binding; std::vector decls; }; + struct Info { + int schema = 0; + std::string runtime; // "glibc@2.39" + std::vector providers; // sorted by binding + bool present = false; // the block existed at all + std::string note; // non-empty ⇒ caller MUST print it + }; + Info read(const std::filesystem::path& subosDir); + std::string family_of(std::string_view runtime, std::string_view arch = "x86_64"); + std::vector> + resolve_env(const Info&, const std::filesystem::path& subosDir); + } + ``` + +- [ ] **Step 1: 写失败的单测** + +创建 `tests/unit/test_subos_info.cpp`: + +```cpp +#include + +import std; +import mcpp.xlings.subos_info; + +namespace { + +namespace su = mcpp::xlings::subos; + +struct Tmp { + std::filesystem::path dir; + Tmp() { + dir = std::filesystem::temp_directory_path() + / ("mcpp_subos_" + std::to_string(std::random_device{}())); + std::filesystem::create_directories(dir); + } + ~Tmp() { std::error_code ec; std::filesystem::remove_all(dir, ec); } + void write(std::string_view body) { + std::ofstream(dir / ".xlings.json") << body; + } +}; + +TEST(SubosInfo, ReadsRuntimeAndEnvs) { + Tmp t; + t.write(R"({ + "workspace": {}, + "subos_info": { + "schema_version": 1, + "runtime": "glibc@2.39", + "envs": [ + { "binding": "mesa@25.0.7.1", "decls": [ + { "var": "LIBGL_DRIVERS_PATH", "op": "prepend", + "value": "${subosdir}/usr/lib/dri" } + ]} + ] + } + })"); + auto info = su::read(t.dir); + EXPECT_TRUE(info.present); + EXPECT_EQ(info.schema, 1); + EXPECT_EQ(info.runtime, "glibc@2.39"); + ASSERT_EQ(info.providers.size(), 1u); + ASSERT_EQ(info.providers[0].decls.size(), 1u); + EXPECT_EQ(info.providers[0].decls[0].var, "LIBGL_DRIVERS_PATH"); + EXPECT_TRUE(info.note.empty()); +} + +// ${subosdir} is expanded against the subos this block was read from. +TEST(SubosInfo, ResolvesSubosdirPlaceholder) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39", + "envs":[{"binding":"mesa@1","decls":[ + {"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}]}})"); + auto env = su::resolve_env(su::read(t.dir), t.dir); + ASSERT_EQ(env.size(), 1u); + EXPECT_EQ(env[0].first, "LIBGL_DRIVERS_PATH"); + EXPECT_EQ(env[0].second, (t.dir / "usr/lib/dri").string()); +} + +// A subos made before xlings grew the block: degrade, and SAY SO. Silence +// here is what made mcpp#352 hard to find in the first place. +TEST(SubosInfo, MissingBlockDegradesWithANote) { + Tmp t; + t.write(R"({"workspace":{}})"); + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_TRUE(info.runtime.empty()); + EXPECT_FALSE(info.note.empty()); +} + +// A schema newer than we understand: use what we can, and say we are behind. +TEST(SubosInfo, NewerSchemaDegradesWithANote) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":99,"runtime":"glibc@2.44","envs":[]}})"); + auto info = su::read(t.dir); + EXPECT_TRUE(info.present); + EXPECT_EQ(info.runtime, "glibc@2.44"); + EXPECT_FALSE(info.note.empty()); +} + +TEST(SubosInfo, NoFileAtAllIsNotACrash) { + Tmp t; // nothing written + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_FALSE(info.note.empty()); +} + +// The family mapping must agree with xlings's own, verbatim. +TEST(SubosInfo, FamilyOfMirrorsXlings) { + EXPECT_EQ(su::family_of("glibc@2.39"), "linux-x86_64-glibc"); + EXPECT_EQ(su::family_of("musl@1.2.5"), "linux-x86_64-musl"); + EXPECT_EQ(su::family_of("glibc@2.39", "aarch64"), "linux-aarch64-glibc"); + EXPECT_EQ(su::family_of("wasi-libc@1"), "wasm32-wasi"); + EXPECT_EQ(su::family_of("nonsense@1"), "unknown"); +} + +// Malformed JSON must not take the build down. +TEST(SubosInfo, MalformedJsonDegrades) { + Tmp t; + t.write("{ this is not json"); + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_FALSE(info.note.empty()); +} + +} // namespace +``` + +- [ ] **Step 2: 跑,确认失败** + +```bash +mcpp test --filter SubosInfo +``` +Expected: FAIL,模块不存在 + +- [ ] **Step 3: 实现** + +创建 `src/xlings/subos_info.cppm`: + +```cpp +// mcpp.xlings.subos_info — read the `subos_info` block xlings writes into a +// subos's own `.xlings.json`. +// +// WHAT THIS IS FOR +// +// A program needs three things: bootstrap (PT_INTERP + CRT + libc), discovery +// (PATH + RPATH), and configuration (env vars). xlings had the first two — +// glibc + elfpatch, xvm + shims — and until it grew this block, nothing for +// the third. Its own module comment names the consequence: mcpp#352, a GLFW +// binary that links fine and exits 255 because nothing told it where the GL +// drivers are. +// +// mcpp is the consumer of the third one. This module ONLY reads and resolves; +// it never writes the block and never manages subos lifecycle — that is +// xlings's layer, and mcpp reaching into it is the layering inversion the +// ecosystem design calls out by name. +// +// A note on silence: every degradation here fills `note`, and callers are +// required to print it. "It did not happen" and "it succeeded" producing the +// same output is the property that made #352 expensive to find. +// +// Design: .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md §3-S3 + +export module mcpp.xlings.subos_info; + +import std; +import mcpp.libs.json; + +export namespace mcpp::xlings::subos { + +// The schema this build understands. A higher one on disk is usable — we read +// the fields we know — but the caller is told it is reading a newer format. +inline constexpr int kSupportedSchema = 1; + +inline constexpr std::string_view kBlock = "subos_info"; + +struct EnvDecl { + std::string var; + std::string op; // "set" | "prepend" + std::string value; // may contain ${subosdir} +}; + +struct Provider { + std::string binding; // "@" + std::vector decls; +}; + +struct Info { + int schema = 0; + std::string runtime; // "glibc@2.39" + std::vector providers; // sorted by binding + bool present = false; + // Non-empty ⇒ the caller MUST surface it. Never a hard error: a missing + // or newer block degrades the experience, it does not invalidate a build. + std::string note; +}; + +// The runtime string is self-describing: "glibc@2.39" says Linux/glibc +// without a second field that could disagree with it. Mirrors xlings's +// `subos::manifest::family_of` — the mapping is small, stable and part of the +// cross-repo contract, so it is asserted in tests rather than left implicit. +std::string family_of(std::string_view runtime, + std::string_view arch = "x86_64") { + const auto at = runtime.find('@'); + const auto name = runtime.substr(0, at == std::string_view::npos + ? runtime.size() : at); + if (name == "glibc") return std::format("linux-{}-glibc", arch); + if (name == "musl") return std::format("linux-{}-musl", arch); + if (name == "wasi-libc") return "wasm32-wasi"; + if (name == "macos_sdk") return std::format("darwin-{}", arch); + if (name == "ucrt") return std::format("windows-{}-ucrt", arch); + return "unknown"; +} + +Info read(const std::filesystem::path& subosDir) { + Info info; + auto path = subosDir / ".xlings.json"; + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + info.note = std::format( + "subos '{}' has no .xlings.json, so it cannot say which runtime it " + "is or what environment its programs need", subosDir.string()); + return info; + } + std::ifstream is(path); + auto doc = nlohmann::json::parse(is, nullptr, /*allow_exceptions=*/false); + if (doc.is_discarded() || !doc.is_object()) { + info.note = std::format("subos manifest {} is not readable JSON", + path.string()); + return info; + } + auto it = doc.find(std::string(kBlock)); + if (it == doc.end() || !it->is_object()) { + info.note = std::format( + "subos '{}' does not describe itself (no `{}` block): its runtime " + "identity and environment declarations are unavailable. A newer " + "xlings writes this block; `xlings self update` adds it", + subosDir.string(), kBlock); + return info; + } + info.present = true; + if (auto v = it->find("schema_version"); + v != it->end() && v->is_number_integer()) + info.schema = v->get(); + if (auto v = it->find("runtime"); v != it->end() && v->is_string()) + info.runtime = v->get(); + + if (auto envs = it->find("envs"); envs != it->end() && envs->is_array()) { + for (auto& p : *envs) { + if (!p.is_object()) continue; + Provider prov; + if (auto b = p.find("binding"); b != p.end() && b->is_string()) + prov.binding = b->get(); + if (auto ds = p.find("decls"); ds != p.end() && ds->is_array()) { + for (auto& d : *ds) { + if (!d.is_object()) continue; + EnvDecl e; + if (auto x = d.find("var"); x != d.end() && x->is_string()) + e.var = x->get(); + if (auto x = d.find("op"); x != d.end() && x->is_string()) + e.op = x->get(); + if (auto x = d.find("value"); x != d.end() && x->is_string()) + e.value = x->get(); + if (!e.var.empty()) prov.decls.push_back(std::move(e)); + } + } + info.providers.push_back(std::move(prov)); + } + } + // Sorted by binding, matching xlings's own ordering, so two reads of the + // same subos produce the same environment in the same order. + std::ranges::sort(info.providers, + [](auto const& a, auto const& b) { return a.binding < b.binding; }); + + if (info.schema > kSupportedSchema) + info.note = std::format( + "subos '{}' declares schema {}, newer than the {} this mcpp " + "understands; reading the fields it knows and ignoring the rest", + subosDir.string(), info.schema, kSupportedSchema); + return info; +} + +// Resolve the declarations into concrete (var, value) pairs, `${subosdir}` +// expanded. `prepend` entries for the same variable are joined with ':' in +// provider order; a `set` replaces whatever came before it, which is xlings's +// own precedence. +std::vector> +resolve_env(const Info& info, const std::filesystem::path& subosDir) { + std::vector> out; + auto expand = [&](std::string v) { + constexpr std::string_view kPh = "${subosdir}"; + for (auto pos = v.find(kPh); pos != std::string::npos; + pos = v.find(kPh, pos)) + v.replace(pos, kPh.size(), subosDir.string()); + return v; + }; + for (auto const& p : info.providers) { + for (auto const& d : p.decls) { + auto value = expand(d.value); + auto hit = std::ranges::find(out, d.var, &std::pair::first); + if (hit == out.end()) { out.emplace_back(d.var, value); continue; } + if (d.op == "set") { hit->second = value; continue; } + // prepend, de-duplicated: a doubled entry must not accumulate + // across nested invocations. + if (hit->second != value + && !hit->second.starts_with(value + ":") + && hit->second.find(":" + value) == std::string::npos) + hit->second = value + ":" + hit->second; + } + } + return out; +} + +} // namespace mcpp::xlings::subos +``` + +- [ ] **Step 4: 跑,确认通过** + +```bash +mcpp test --filter SubosInfo +``` +Expected: PASS(7 组全绿) + +- [ ] **Step 5: Commit** + +```bash +git add src/xlings/subos_info.cppm tests/unit/test_subos_info.cpp +git commit -m "feat(xlings): read the subos's own description instead of inferring it" +``` + +--- + +### Task 6: `mcpp run` / `mcpp test` 应用 subos 环境 + +**Files:** +- Modify: `src/build/plan.cppm`(把解析好的 env 放进 plan) +- Modify: `src/build/execute.cppm:279-300, 1210-1230`(应用到子进程) +- Test: `tests/e2e/196_subos_env_reaches_program.sh`(新建) + +**Interfaces:** +- Consumes: `mcpp::xlings::subos::{read, resolve_env}` +- Produces: `BuildPlan` 新增 `std::vector> subosEnv;` + +**为什么不是自己拼图形变量**:`LIBGL_DRIVERS_PATH` / `__EGL_VENDOR_LIBRARY_DIRS` / `XDG_DATA_DIRS` 的值由 xlings 的图形包声明,mcpp 只是把它们传下去。mcpp 里出现任何图形相关的字面量都是这条设计的违反。 + +- [ ] **Step 1: 写失败的 e2e** + +创建 `tests/e2e/196_subos_env_reaches_program.sh`: + +```sh +#!/usr/bin/env bash +# requires: linux +# A variable a package declared into the subos must reach the program `mcpp +# run` launches. Until this landed, subos env declarations were applied only +# by `xlings subos use`, so a program started by mcpp saw none of them — the +# reason a GL binary could link fine and exit 255 (mcpp#352). +set -euo pipefail +. "$(dirname "$0")/_common.sh" + +proj=$(mktemp -d) +trap 'rm -rf "$proj"' EXIT + +# A subos that declares one variable, in xlings's own schema. +subos="$proj/subos" +mkdir -p "$subos/usr/lib/dri" +cat > "$subos/.xlings.json" <<'EOF' +{ "workspace": {}, + "subos_info": { "schema_version": 1, "runtime": "glibc@2.39", + "envs": [ { "binding": "probe@1", "decls": [ + { "var": "MCPP_E2E_PROBE", "op": "prepend", + "value": "${subosdir}/usr/lib/dri" } ] } ] } } +EOF + +cd "$proj" +"$MCPP" new hello >/dev/null +cd hello +cat > src/main.cpp <<'EOF' +#include +#include +int main() { + const char* v = std::getenv("MCPP_E2E_PROBE"); + std::printf("PROBE=%s\n", v ? v : "(unset)"); +} +EOF + +out=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1) +echo "$out" | grep -q "PROBE=$subos/usr/lib/dri" || { + echo "subos env did not reach the program:"; echo "$out"; exit 1; } +echo "PASS: subos env reaches the program" +``` + +`chmod +x tests/e2e/196_subos_env_reaches_program.sh` + +> `MCPP_SUBOS_DIR` 是本 task 引入的**测试与覆盖用**入口:默认解析仍是活动 subos。它让这条 e2e 不需要改动用户真实环境——记住 memory 里那条教训:破坏性脚本不在真机上试。 + +- [ ] **Step 2: 跑,确认失败** + +```bash +bash tests/e2e/196_subos_env_reaches_program.sh +``` +Expected: FAIL,`PROBE=(unset)` + +- [ ] **Step 3: 实现** + +`src/build/plan.cppm`,在 `runtimeLibraryDirs` 那段之后: + +```cpp + // The subos's own environment declarations. mcpp reads them and passes + // them on; it does not author them and does not know what they mean. That + // is the whole point: when xlings adds a Vulkan loader or a new driver + // bridge, this code does not change. + { + auto subosDir = mcpp::xlings::subos_dir_for_build(cfg); + auto info = mcpp::xlings::subos::read(subosDir); + if (!info.note.empty()) mcpp::ui::warning(info.note); + plan.subosEnv = mcpp::xlings::subos::resolve_env(info, subosDir); + } +``` + +`src/xlings.cppm` 加一个解析器(单点,不让每个消费者各猜一次): + +```cpp + // The subos whose environment a program built here should run under. + // + // MCPP_SUBOS_DIR overrides it outright — that exists so tests can exercise + // this path without touching the developer's real environment, and so a + // user can point a run at another subos without switching the active one. + std::filesystem::path subos_dir_for_build(const config::GlobalConfig& cfg); +``` + +`src/build/execute.cppm`:在构造子进程环境的两处(`run` 的 279-300 与 ninja 侧 1210-1230)把 `plan.subosEnv` 合并进去,`prepend` 语义与既有 `prepend_path_list` 一致。 + +- [ ] **Step 4: 跑,确认通过** + +```bash +bash tests/e2e/196_subos_env_reaches_program.sh +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/build src/xlings.cppm tests/e2e/196_subos_env_reaches_program.sh +git commit -m "feat(run): a program mcpp launches gets its subos's environment" +``` + +--- + +### Task 7: 运行时身份成为显式轴 + +**Files:** +- Modify: `src/toolchain/abi.cppm`(libc 维度带版本) +- Modify: `src/toolchain/model.cppm`(`Toolchain` 加 `runtimeBinding`) +- Modify: `src/build/prepare.cppm`(解析优先级) +- Test: `tests/unit/test_abi.cpp` + +**Interfaces:** +- Consumes: `subos::read(...).runtime`、`subos::family_of` +- Produces: `AbiProfile` 新增 `std::string libcVersion;`(空 = 未知,**不参与匹配**),`Toolchain::runtimeBinding` + +**收窄的实现范围与理由**:`abi.cppm` 的 `libc` 维度参与依赖解析,给它加一个**参与匹配**的版本轴会立刻改变全索引的解析结果(memory 里 `index-floor-must-degrade` 是同一族事故)。所以本 task 只做两件事:①把版本**记录**下来并在 `mcpp doctor` / `--verbose` 里可见;②`runtimeBinding` 的解析优先级落地。**匹配语义不变**,留给 platform manifest 那一轮。 + +- [ ] **Step 1: 写失败的单测** + +在 `tests/unit/test_abi.cpp` 追加: + +```cpp +// The libc dimension gains a version, and it is RECORDED, not matched. A +// dependency that says `abi:glibc` must keep matching a glibc@2.39 toolchain +// exactly as it did before — changing that would re-resolve the whole index. +TEST(Abi, LibcVersionIsRecordedButNotMatched) { + mcpp::toolchain::Toolchain tc; + tc.targetTriple = "x86_64-linux-gnu"; + tc.runtimeBinding = "glibc@2.39"; + auto p = mcpp::toolchain::abi_profile(tc); + EXPECT_EQ(p.libc, "glibc"); + EXPECT_EQ(p.libcVersion, "2.39"); + + auto c = mcpp::toolchain::parse_abi_capability("abi:glibc"); + ASSERT_TRUE(c.has_value()); + EXPECT_TRUE(mcpp::toolchain::satisfies(p, *c)); +} + +// No binding resolved: the dimension still answers, without a version. +TEST(Abi, LibcVersionEmptyWithoutABinding) { + mcpp::toolchain::Toolchain tc; + tc.targetTriple = "x86_64-linux-gnu"; + auto p = mcpp::toolchain::abi_profile(tc); + EXPECT_EQ(p.libc, "glibc"); + EXPECT_TRUE(p.libcVersion.empty()); +} +``` + +- [ ] **Step 2: 跑,确认失败** + +```bash +mcpp test --filter Abi +``` +Expected: FAIL,`runtimeBinding` / `libcVersion` 不存在 + +- [ ] **Step 3: 实现** + +`model.cppm` 的 `Toolchain` 加: + +```cpp + // The runtime this toolchain's output is built against, in xlings's own + // spelling ("glibc@2.39"). Resolved in prepare, in this order, every step + // explicit — a "default is the convention" step is what grows a second + // answerer (see the design doc §S1): + // 1. --runtime + // 2. [target.].runtime / [build].runtime + // 3. the active subos's `subos_info.runtime` + // 4. the payload actually resolved, with a note + std::string runtimeBinding; +``` + +`abi.cppm` 的 `AbiProfile` 加 `std::string libcVersion;`,并在 `abi_profile()` 的两条路径里从 `tc.runtimeBinding` 的 `@` 之后取值。**`satisfies()` 不动。** + +`prepare.cppm` 按上述四级解析并写进 `tc.runtimeBinding`;第 4 级发一条 `ui::info`。 + +- [ ] **Step 4: 跑,确认通过** + +```bash +mcpp test --filter Abi +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/toolchain src/build/prepare.cppm tests/unit/test_abi.cpp +git commit -m "feat(toolchain): the runtime a build targets is read, not inferred" +``` + +--- + +### Task 8: 文档 + +**Files:** +- Modify: `docs/05-mcpp-toml.md`(`c_runtime`) +- Modify: `docs/02-pack-and-release.md`(contract × mode 的关系) +- Modify: `docs/03-toolchains.md`(runtime binding) +- Modify: `docs/zh/` 下的对应文件 +- Modify: `.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`(标注已实施) + +- [ ] **Step 1: `05-mcpp-toml.md` 加 `c_runtime`** + +紧邻既有 `cxx_runtime` 一节,写明三值、默认值、以及与 `mcpp pack --mode` 的函数关系表(设计文档 §3-S2 那张)。**必须写清 `host-coupled` 的语义是「宿主 glibc ≥ 构建时的那份」**,不是「任何 Linux」。 + +- [ ] **Step 2: `02-pack-and-release.md` 加一节 "Contract vs mode"** + +说明 mode 管「带多少东西」、contract 管「承诺什么」,并给出不可分发契约在 pack 时会被拒绝的行为。 + +- [ ] **Step 3: `03-toolchains.md` 加 runtime binding** + +说明四级解析顺序与 `--runtime`。 + +- [ ] **Step 4: 校验中文版同步** + +```bash +ls docs/zh/ +``` +对每个改过的英文文档,同步中文版。 + +- [ ] **Step 5: Commit** + +```bash +git add docs .agents/docs +git commit -m "docs: the two runtime contracts, and what each promises" +``` + +--- + +### Task 9: 版本 + xlings pin + PR + +**Files:** +- Modify: `src/version.cppm` +- Modify: `src/xlings.cppm`(`kXlingsVersion`) + +- [ ] **Step 1: 查最新 xlings 版本** + +```bash +gh api repos/openxlings/xlings/releases/latest --jq .tag_name +``` + +- [ ] **Step 2: bump 两个常量** + +`MCPP_VERSION` → `2026.8.8.1`;`kXlingsVersion` → 上一步的值。 + +> **只动这两个。** bootstrap pin(`.xlings.json` 那组)是自举起点,不随发布走 —— 一起 bump 会让全部 CI 去装一个还不存在的版本。 + +- [ ] **Step 3: 机器校验 pin 一致** + +```bash +.github/tools/check_version_pins.sh +``` +Expected: 通过 + +- [ ] **Step 4: 全量本地验证** + +```bash +mcpp build && mcpp test +for t in 30_pack_modes 195_c_runtime_host_coupled 196_subos_env_reaches_program; do + bash "tests/e2e/$t.sh" || echo "FAIL: $t" +done +``` + +- [ ] **Step 5: 开 PR** + +```bash +git checkout -b feat/xlings-runtime-substrate +git push -u origin feat/xlings-runtime-substrate +gh pr create --title "feat: xlings 作为运行时底座 —— libc 分发契约 + subos 环境 (#375, #352)" --body-file .agents/docs/pr-body.md +``` + +--- + +### Task 10: mcpp-index 图形栈迁移(独立仓、独立 PR) + +**Files(`/home/speak/workspace/github/mcpplibs/mcpp-index`):** +- Modify: `pkgs/c/compat.glfw.lua`, `pkgs/c/compat.glx-headers.lua`, `pkgs/c/compat.vulkan-runtime.lua` +- Deprecate: `pkgs/c/compat.glx-runtime.lua` + +- [ ] **Step 1: 确认下游只有三个** + +```bash +cd /home/speak/workspace/github/mcpplibs/mcpp-index && grep -rln "glx-runtime" pkgs/ +``` +Expected: 恰好 4 个文件(含它自己) + +- [ ] **Step 2: 三个消费者改为依赖 `xim:graphics`** + +`compat.glx-runtime` 保留为**空壳 provider**(仍声明 `provides = {"opengl.glx.driver","x11.display"}`,但不再 symlink 宿主库),一个版本之后再删——下游包的 manifest 已发布,不能立刻失效(与 `index-floor-must-degrade` 同一条教训)。 + +- [ ] **Step 3: 验** + +```bash +bash tests/smoke.sh 2>/dev/null || ls tests/ +``` +按该仓既有验证入口跑。**判据是渲染器身份,不是「窗口出现了」。** + +- [ ] **Step 4: PR** + +--- + +## Self-Review + +**Spec coverage** — 设计文档各节 → task 映射: + +| 设计文档 | Task | +|---|---| +| §1.6 / Q6 `self-contained` wrapper | T1 | +| §3-S2 `c_runtime` 契约 | T2 + T3 + T4 | +| §3-S3 subos 环境 | T5 + T6 | +| §3-S1 运行时身份 | T7 | +| §1.5 图形栈迁移 | T10 | +| §6 P0 文档 | T1 Step5 + T8 | +| §7 跨仓契约(xlings 落盘 exports) | **不在本 PR** —— 需 xlings 侧改动,设计文档已排 P1 尾;本 PR 的 §5.1「A 降级路径」足以工作 | +| §5.3 / P2 视图寻址 | **不做**(设计文档明确) | + +**未覆盖且是有意的**:§2.1 六个回答者里,本 PR 收敛的是 #2/#3/#5(loader 选择统一到 `link_tokens` + `distro_loader_path`)。#1/#4/#6 依赖 xlings 侧落盘 exports,留 P1。V5 的静态计数守的是「不再增加」,不是「已经归零」。 + +**Type consistency** — 跨 task 一致性已核:`dist::Contract` / `dist::Role` / `dist::Format` 三者在 T2/T4 同名;`libcdist::Addressing` 与 `tc::LibcAddressing` 是**有意的镜像对**(层次不倒置),T3 的注释与 T2 的测试都点明了这一点,并由 T4 的 `static_cast` 单点转换。`subos::Info` / `resolve_env` 在 T5 定义、T6 消费,签名一致。 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-09-mcpp-template-runtime-graphics-aur-focused-design.md b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md new file mode 100644 index 00000000..fc41f7ee --- /dev/null +++ b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md @@ -0,0 +1,1034 @@ +# mcpp 模板、运行时、图形栈与 AUR 聚焦设计 + +> 状态:Review Draft +> +> 日期:2026-08-09 +> +> 基线:mcpp main@80291ca01a98 +> +> 上位分析:.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md +> +> 本文只冻结四项产品设计,不代表实现、发布或 AUR 状态已经改变。 + +## 1. 已确认的四项产品决策 + +本文将以下内容视为已确认方向,不再把旧方案并列为推荐项: + +1. **模板 selector 与 mcpp add 保持同一风格** + - 稳定形式:ns.name@version:tname。 + - ns. 可省略;省略时使用 mcpp/mcpp.toml 的默认 namespace mcpplibs。 + - @version 可省略。 + - :tname 可省略。 + - 不引入 --variant。 +2. **xlings 拥有图形栈与运行时,mcpp 构建在该生态之上** + - xlings 负责图形 runtime/provider 的解析、安装、激活和生命周期。 + - xim-pkgindex 提供 Mesa、NVIDIA、WSL、Vulkan ICD/driver 等 package/provider recipe。 + - mcpp-index 声明 C++ 图形包到 xlings/xim 图形能力的依赖关系。 + - mcpp 只通过 mcpp-index 的包描述、模板和通用构建契约完成构建。 + - mcpp 不探测 GPU,不按厂商写构建分支。 +3. **运行时分为 mcpp 默认与项目显式 SubOS** + - 不配置时使用 mcpp 管理、发布验证过的默认运行时。 + - 项目可在 mcpp.toml[xlings] 中选择命名 SubOS。 + - SubOS 是根项目本地 build/run 环境,类似选择“在哪个 OS 开发”;它不是库的传递依赖,也不要求消费者使用同一 SubOS。 + - 多个项目可以并存于不同 SubOS/不同 glibc,缓存和产物按实际 RuntimeBinding 隔离。 +4. **AUR 自动同步聚焦 mcpp-bin** + - mcpp-bin 是唯一自动对账并纳入漂移告警的 AUR 包。 + - AUR 是 GitHub Release 的最终一致投影,AUR 故障不阻塞 GitHub Release 本身。 + - mcpp-m 本阶段完全不动:不修改 package 文件、不推送、不退役、不改变其现有状态。 + - mcpp-git 不进入主发布关键路径。 + +## 2. 目标、边界与成功定义 + +### 2.1 目标 + +- 用户只学习一种包 selector 风格;namespace 可省略,省略时默认 mcpplibs。 +- 默认用户无需理解 xlings/SubOS 即可获得稳定运行时;需要固定 ABI/环境的项目能声明 SubOS。 +- 图形包的新平台、驱动和 backend 由 xlings/xim 生态演进;mcpp-index 表达 C++ 依赖关系,不要求修改 mcpp 核心。 +- SubOS 只影响当前根项目本地构建/运行,不从库依赖向消费者传递。 +- AUR 临时故障恢复后,mcpp-bin 能自动收敛到最新稳定 GitHub Release。 +- 设计保持 CLI 简洁、内部身份明确、构建热路径无网络和 GPU 探测。 + +### 2.2 非目标 + +- 不在本轮增加 --variant#template 或新的顶层 template 命令。 +- 不让项目 mcpp.toml 写本机绝对 xlings binary/home。 +- 不在 mcpp 中实现 Mesa/NVIDIA/Vulkan/WSL 专用逻辑。 +- 不要求 mcpp-m 与每次 release 同步。 +- 不修改或重新定义 mcpp-m 的维护策略。 +- 不在本文中解决 C1–C9 的全部实现;只保留与这四项直接相关的契约。 +- 本轮不实现代码、不重跑 workflow、不推送 AUR。 + +### 2.3 成功定义 + +~~~text +mcpp new app --template ocornut.imgui@1.92.8:glfw-opengl3 + -> dotted selector 显式解析为 namespace=ocornut, name=imgui + -> 解析出完整 PackageId 与 exact version + -> 从 mcpp-index 获取模板/构建描述 + -> mcpp-index 声明 xlings/xim 图形依赖 + -> xlings 解析并物化图形运行时 + -> 使用 mcpp 默认 runtime 或 [xlings].subos + -> mcpp 只执行通用 build/link/run plan +~~~ + +发布侧: + +~~~text +latest stable GitHub Release + -> immutable release manifest + -> mcpp-bin generator and validation + -> idempotent AUR reconcile + -> remote git + AUR RPC + clean install verification +~~~ + +## 3. 方案比较 + +| 主题 | 方案 A | 方案 B | 方案 C | 选择 | +|---|---|---|---|---| +| 模板名 | --variant 独立参数 | #tname 新分隔符 | [ns.]name[@version][:tname] | **C**:与现有 CLI/TOML 风格一致 | +| runtime 配置 | 跟随全局 active SubOS | 新增 [runtime].provider | 默认 mcpp runtime;已有 [xlings].subos 覆盖 | **C**:字段最少、可复现 | +| graphics | mcpp 内置 GL/Vulkan/provider 逻辑 | mcpp 直接调用 xim 图形接口 | xlings 拥有图形 runtime;mcpp-index 声明 C++ 依赖 | **C**:职责单一 | +| AUR | 双包串行同步 | 三包 matrix 同步 | 只自动对账 mcpp-bin | **C**:主用户路径优先 | + +方案 C 的共同原则是:**用户表面复用已有概念,内部不复用含糊字符串;每层只拥有自己能证明的事实。** + +## 4. 总体架构 + +~~~mermaid +flowchart LR + U[mcpp.toml / CLI] --> M[mcpp generic resolver and builder] + M --> I[mcpp-index
package, template, build contract] + I --> X[xlings
graphics stack owner, install, SubOS] + X -->|reads provider recipes| XI[xim-pkgindex
provider recipes and sentinels] + X --> RC[resolved graphics and runtime contract] + RC --> M + M --> A[artifact] + + R[GitHub Release Manifest] --> AR[AUR mcpp-bin reconciler] + AR --> AB[AUR mcpp-bin] +~~~ + +核心依赖方向: + +- mcpp 可以理解 PackageSelector、PackageId、LinkIntent、RuntimeBinding 等通用结构。 +- mcpp 不理解 mesanvidiawslvulkan icd 的选择规则。 +- mcpp-index 声明图形包如何编译、链接,以及它依赖哪些 xlings 生态 package/capability。 +- xlings 负责解析、安装、激活和导出图形 runtime contract。 +- xim-pkgindex 是 xlings 消费的 provider/recipe 数据源,实现 capability 并记录 artifact/provenance。 +- AUR reconciler 不读取工作区临时状态,只读取不可变 release manifest。 + +## 5. 模板 selector 统一设计 + +### 5.1 用户语法 + +稳定文法: + +~~~text +TemplateSpec := PackageSelector [ "@" ExactVersion ] [ ":" TemplateName ] +PackageSelector := Name | NamespacePath "." Name +NamespacePath := Segment { "." Segment } +TemplateName := NameAtom +~~~ + +NamespacePath 可省略。省略时: + +~~~text +imgui -> namespace = mcpplibs, name = imgui +~~~ + +它不是全索引 short-name 搜索,也不是“跟随当前 index 的 namespace”;默认值固定为 mcpplibs,与 mcpp.toml 默认 dependency table 对齐。 + +分隔符唯一职责: + +| 分隔符 | 含义 | +|---|---| +| . | 与 mcpp add 相同的 dotted package selector | +| @ | exact package version | +| : | package 内的 template name | + +合法示例: + +| 输入 | package selector | version | template | +|---|---|---|---| +| imgui | mcpplibs.imgui(ns 省略) | 默认稳定版 | descriptor default | +| ocornut.imgui | ocornut.imgui | 默认稳定版 | descriptor default | +| ocornut.imgui@1.92.8 | ocornut.imgui | 1.92.8 | descriptor default | +| ocornut.imgui:glfw-opengl3 | ocornut.imgui | 默认稳定版 | glfw-opengl3 | +| ocornut.imgui@1.92.8:vulkan | ocornut.imgui | 1.92.8 | vulkan | +| mcpplibs.gui.templates@2.0.0:window | mcpplibs.gui.templates | 2.0.0 | window | + +非法示例: + +| 输入 | 错误 | +|---|---| +| ocornut.imgui@ | empty version | +| ocornut.imgui: | empty template name | +| .imgui@1.0 | empty selector segment | +| ocornut..imgui | empty namespace segment | +| ocornut.imgui@1.0:vulkan:extra | more than one template delimiter | + +pkg: 不再兼任列表语法。列举模板继续使用现有显式表面: + +~~~text +mcpp new --list-templates ocornut.imgui@1.92.8 +~~~ + +这样“省略 tname”永远表示选择 default,不会同时表示 list。 + +### 5.2 与 mcpp add / mcpp.toml 的一致性 + +模板不能只复制 mcpp add 的视觉风格,两个命令最终必须复用同一条规范化链: + +~~~text +raw selector + -> shared PackageSelector parser + -> fill default namespace mcpplibs when omitted + -> one normalized PackageId + -> IndexRoute::lookup_descriptor by exact PackageId + -> resolved PackageId + descriptor provenance +~~~ + +目标规则: + +- imgui(mcpplibs, imgui)。 +- acme.widget(acme, widget)。 +- mcpplibs.capi.lua(mcpplibs.capi, lua)。 +- 多级 selector 总是以最后一个 segment 为 name,其余为 namespace。 + +对应的 mcpp.toml 语义: + +~~~toml +[dependencies] +imgui = "1.92.8" # default namespace mcpplibs + +[dependencies.acme] +widget = "1.0.0" # explicit namespace acme +~~~ + +CLI dotted form acme.widget@1.0.0 是第二种 TOML 形式的紧凑输入,不再表示“先猜 mcpplibs.acme,再猜 acme”。 + +当前 mcpp add 的 ordered dotted candidates 属于兼容实现。为了真正一致,迁移后 add/template 都使用上述单一规范化规则;旧项目清单已解析并锁定的 dependency 不被自动重写。 + +selector 是用户输入,最终身份仍是结构化: + +~~~text +PackageSelector { + namespace?: NamespacePath, + name: NameAtom +} + +PackageId { + namespace: NamespacePath, + name: NameAtom +} + +ResolvedTemplatePackage { + id: PackageId, + version: ExactVersion, + indexRoute, + descriptorDigest, + payloadDigest, + root +} + +TemplateSelection { + package: ResolvedTemplatePackage, + templateName: NameAtom +} +~~~ + +PackageSelector 规范化时填入默认 namespace,所以进入 resolver 后不再存在“namespace 未知”状态。xlings 安装 wire address 仍由 resolved PackageId 派生为 namespace:name@version。点号只属于 mcpp 用户 selector;冒号 wire address 不反向塞回 --template。 + +### 5.3 解析顺序 + +为了避免 namespace、version 与 tname 相互抢分隔符,解析固定为: + +1. 验证最多一个 :,分离右侧 TemplateName。 +2. 在左侧验证最多一个 @,分离 ExactVersion。 +3. 将剩余部分完整交给共享 PackageSelector parser。 +4. resolver 返回 PackageId 后再进入版本和模板选择。 + +禁止当前 scaffold 的“先按短名试空 namespace/compat,命中后再反推 namespace”旁路。 + +### 5.4 version 省略规则 + +- 写出 @version 时只接受 exact version;第一阶段不引入 range。 +- 省略时选择目标平台可用的最新 stable version。 +- prerelease 不自动成为默认;只有用户显式写出 exact prerelease 才选择。 +- 解析成功后后续流程只携带 exact version。 +- 成功输出、wire result 与生成的自依赖都展示 exact version。 + +版本选择必须复用包管理 resolver,不能由模板代码排序字符串。 + +### 5.5 tname 省略规则 + +省略 :tname 时: + +1. 正好一个 template 声明 default = true:选择它。 +2. 没有显式 default,但 package 只有一个 template:该单模板自动成为 default。 +3. 没有显式 default,且存在多个 templates:失败并列出所有 template,要求用户写 :tname 或 provider 选出 default。 +4. 多于一个显式 default:descriptor/index validation 失败。 +5. package 没有 templates/:明确报告它不是 template provider。 + +单模板自动默认是当次已解析 provider/version 的确定事实;同一已锁版本不会因未来 release 新增模板而改变。新版本若增加第二个模板却未声明 default,省略 tname 会明确失败,不进行目录排序猜测。 + +### 5.6 生成与依赖注入 + +模板解析后必须保留完整 PackageId,解决当前 namespace 在 fetch 后丢失的问题。 + +RenderVars: + +~~~text +project.name +project.namespace +project.qualifiedName +template.package.namespace +template.package.name +template.package.selector +template.package.version +template.name +~~~ + +自依赖注入复用 mcpp add 的 manifest editor: + +- 不做字符串搜索。 +- 使用用户风格的 dotted selector key。 +- 写 exact resolved version。 +- 保留 namespace 与 index provenance 到 lock/build resolution。 +- 模板已经声明相同 PackageId 时不重复写入。 +- 同 short name、不同 namespace 不视为同一个 dependency。 + +### 5.7 输出与诊断 + +成功的人类输出: + +~~~text +Created app +Template ocornut.imgui@1.92.8:glfw-opengl3 +Runtime mcpp-default +~~~ + +机器输出至少包含: + +~~~json +{ + "packageSelector": "ocornut.imgui", + "resolvedPackage": { + "namespace": "ocornut", + "name": "imgui", + "version": "1.92.8" + }, + "template": "glfw-opengl3", + "runtimeSelection": "mcpp-default" +} +~~~ + +诊断规则: + +- package 不存在:展示规范化后的 exact PackageId;namespace 省略时明确注明使用了默认 mcpplibs。 +- version 不存在:列出该平台可用 stable versions。 +- template 不存在:列出 provider 内 template names 和 default。 +- 用户写 ocornut:imgui 时,按文法它表示 package ocornut 的 template imgui;若解析失败,诊断额外建议 namespace 风格 ocornut.imgui。 +- 所有失败发生在创建目标目录前,或由临时目录事务回滚。 + +### 5.8 兼容迁移 + +旧的无 namespace 形式天然兼容: + +~~~text +pkg +pkg@version +pkg:tname +pkg@version:tname +~~~ + +变化只有: + +- 新增 dotted namespace selector。 +- bare selector 明确填入默认 namespace mcpplibs。 +- mcpp add 与 template 最终收敛为“dot 表示显式 namespace、无 dot 表示默认 mcpplibs”,不再各自猜候选。 +- pkg: 的 legacy list 含义先 warning 一个 release train,之后错误;用户改用 --list-templates pkg。 +- 不再新增 --variant,上一份综合设计中的该建议由本文覆盖。 +- builtin bin/gui 可暂时保留 alias,但 package template 输出统一用 TemplateSpec。 + +当前 add 的 dotted selector 具有默认 namespace 前缀候选,例如 capi.lua 会先尝试 mcpplibs.capi:lua。新规则下: + +~~~text +capi.lua -> capi:lua +mcpplibs.capi.lua -> mcpplibs.capi:lua +lua -> mcpplibs:lua +~~~ + +迁移要求: + +1. 已有 mcpp.toml 与 lockfile 不自动改写,仍按其已记录身份工作。 +2. 一个 release train 检测“旧候选结果与新 exact 结果不同”,warning 同时给出两种完整 selector。 +3. 新 mcpp addmcpp new --template 在迁移窗口后统一采用 exact dotted 规则。 +4. mcpp-index 中所有 nested mcpplibs.* 文档示例改成完整 namespace,不能依赖隐式前缀。 + +## 6. mcpp 默认 runtime 与 mcpp.toml SubOS + +### 6.1 两种模式 + +只定义两种项目运行时选择: + +~~~text +McppDefault +NamedSubos(name) +~~~ + +不增加第三种“跟随当前全局 active subos”模式。 + +| mcpp.toml | 选择 | +|---|---| +| 没有 [xlings].subos | McppDefault | +| [xlings] subos = "dev" | NamedSubos("dev") | +| [xlings] subos = "default" | 显式选择 xlings home 中的 default SubOS | + +### 6.2 默认模式 + +McppDefault 的定义: + +- 使用 mcpp 所选择的 xlings provider。 +- 使用 mcpp home 内已初始化、release 验证过的 default SubOS/RuntimeBinding。 +- 不受用户另一个 shell 中执行 xlings subos use 的 active 状态影响。 +- 缺失时走 mcpp bootstrap,不从任意目录挑第一个 glibc。 +- xlings/runtime contract schema 不兼容时明确失败或升级,不静默回退宿主。 + +默认路径的用户体验是“安装 mcpp 后直接 build/run”,不要求用户了解 SubOS。 + +### 6.3 项目显式 SubOS + +复用当前已支持的配置: + +~~~toml +[xlings] +subos = "dev" +deps = ["cmake@3.28", "python@3.13"] + +[xlings.workspace] +clang = "20.1.7" + +[xlings.envs] +OPENBLAS_NUM_THREADS = "1" +~~~ + +语义: + +- subos 只选择当前根项目 build/run/test 共用的本地命名环境。 +- deps、workspace pins 与 envs 在该环境中物化。 +- Linux 上 RuntimeBinding 同时决定 loader/libc。 +- macOS/Windows 上仍选择一致的工具与环境契约,但不伪造 Linux glibc 语义。 +- 指定 SubOS 不存在或无法回答 runtime contract 时 hard error,不退回 default。 +- 同一台机器可同时保留 el8trixiedefault 等多个环境;每个项目按自己的 mcpp.toml 选择,互不切换全局 active 状态。 + +### 6.4 xlings binary/home 属于机器配置 + +项目清单必须可移植,所以不在 mcpp.toml 接受绝对 binary/home: + +~~~toml +# ~/.mcpp/config.toml +[xlings] +binary = "bundled" # or "system" / absolute administrator path +home = "" +~~~ + +职责分离: + +- 全局 config 决定“使用哪一个 xlings 实例和 home”。 +- 项目 mcpp.toml 决定“在该实例中使用 default 还是哪个 named SubOS”。 + +这样团队可以提交 subos = "el8",而不提交某位开发者的本地 XLINGS_HOME 路径。 + +### 6.5 选择优先级 + +稳定顺序: + +~~~text +project [xlings].subos exists + -> NamedSubos +otherwise + -> McppDefault +~~~ + +本设计不增加临时 --subos CLI override。runtime 是 build identity,命令行临时覆盖会让同一份 mcpp.toml 产生不同 ABI,并污染缓存解释。 + +global xlings binary/home 只决定 provider,不插入第三个 runtime selection rung。 + +### 6.6 一个 snapshot 贯穿生命周期 + +~~~text +RuntimeSelection + -> resolve exact SubOS + -> read RuntimeBinding contract + -> include contract hash in toolchain/build fingerprint + -> configure/link + -> post-link validation + -> run/test environment +~~~ + +build、run、test、post-install fixup 不得分别重新猜 runtime。 + +建议内部模型: + +~~~text +RuntimeSelection { + mode: McppDefault | NamedSubos, + subosName, + source: DefaultPolicy | Manifest +} + +RuntimeBinding { + schema, + providerId, + platform, + arch, + contractHash, + loader?, + libc?, + libraryDirs[], + environment[], + capabilities[], + provenance +} +~~~ + +### 6.7 [runtime] 与 [xlings] 不混用 + +现有 [runtime] 表示程序/包启动时需要的 library dirs、dlopen libs 和 capabilities;它不是“选择哪一个 SubOS”的配置。 + +~~~toml +[xlings] +subos = "dev" # 选择 build/run 环境 + +[runtime] +capabilities = ["opengl"] # 程序需要的通用 capability +~~~ + +选择环境属于 [xlings],声明程序需求属于 [runtime]。禁止再增加 [runtime] provider = "subos" 形成第二入口。 + +### 6.8 SubOS 是本地、根项目级、非传递环境 + +SubOS 的心智模型是: + +> mcpp 在一台机器上选择一个本地开发 OS 环境来 configure/build/run 当前项目。 + +它不是库依赖约束。规则如下: + +1. 只有本次构建的 root manifest/workspace root 能选择 SubOS。 +2. dependency manifest 中的 [xlings].subos 不合并、不继承、也不要求消费者创建同名 SubOS。 +3. 一个库作为独立根项目开发时,它自己的 [xlings].subos 生效;同一源码作为另一个项目的 dependency 时,使用消费者 root 选择的环境构建。 +4. workspace 整体构建时由 workspace root 选择一个环境;member 的 SubOS 不覆盖 root。member 独立构建时才成为自己的 root。 +5. SubOS name 不写入 dependency requirement,不从 lockfile 向下游传播,也不成为 mcpp-index package identity 的一部分。 + +典型源码分发: + +~~~text +application root selects subos=el8 + -> source dependency A builds inside el8 + -> source dependency B builds inside el8 + -> application runs inside el8 + +another application selects subos=trixie + -> the same A/B sources rebuild under trixie +~~~ + +因此可以在同一机器上用不同 glibc 环境开发同一组源码依赖,而不是要求所有库声明或传递 glibc=2.x。 + +间接关系只来自实际产物: + +- RuntimeBinding 必须进入当前项目 build fingerprint,防止跨 SubOS 复用 object/BMI。 +- 如果发布的是预构建 binary,发布流程应从最终 artifact 派生 ABI、loader、GLIBC symbol floor 等兼容元数据。 +- 这些是 artifact 的客观兼容属性,不是把开发时的 SubOS name 传播给消费者。 +- package 确实需要某项运行时能力时,通过 mcpp-index/xim package requirement 表达,不通过 [xlings].subos 表达。 + +### 6.9 当前 active SubOS 的迁移 + +当前未显式配置时可能读取 active SubOS。切换到稳定 default 的迁移: + +1. 一个 release train 输出 warning,显示当前 active 与未来 default 是否不同。 +2. 提供可复制配置:[xlings] subos = "current-name"。 +3. 下一 release 将 absence 固定为 McppDefault。 +4. 不自动修改用户 mcpp.toml。 + +冷 HOME、新安装可以直接采用新规则,不需要 legacy 过渡。 + +## 7. OpenGL/Vulkan 图形栈职责 + +### 7.1 强制边界 + +| 层 | 拥有 | 不拥有 | +|---|---|---| +| mcpp | 通用 PackageId、source/build graph、LinkIntent、RuntimeBinding 消费、产物验证 | OpenGL/Vulkan 包名、GPU vendor、ICD 选择、WSL 探测 | +| mcpp-index | ImGui/GLFW/OpenGL/Vulkan C++ 包、features、templates、平台 build/link 声明,以及这些包对 xlings 图形能力的依赖关系 | 安装宿主驱动、判断 NVIDIA/WSL 当前状态 | +| xlings | 图形栈 orchestration、依赖解析/安装/激活、SubOS、runtime contract、provider/sentinel 生命周期 | C++ GUI template 与项目源码 | +| xim-pkgindex | xlings 消费的 Mesa、Vulkan loader/ICD、NVIDIA/WSL sentinel recipe 与 provenance schema | 运行时自行做全局选择、mcpp 工程生成和 source graph | + +mcpp 源码中不新增以 openglvulkanmesanvidiawsl 为条件的 planner 分支。 + +### 7.2 数据流 + +~~~text +mcpp.toml dependency + -> mcpp resolves descriptor through mcpp-index + -> mcpp-index descriptor contributes sources, features, LinkIntent + and xlings graphics package/capability dependencies + -> xlings resolves and materializes the dependency closure + -> xlings activates providers/sentinels described by xim-pkgindex + -> xlings exports RuntimeArtifacts and provenance + -> mcpp consumes only generic resolved contract + -> link, validate and run +~~~ + +mcpp 不直接选择 xim:graphics。mcpp-index 的平台 package contract 声明 xlings 生态依赖;xlings 才是图形栈运行时 owner,负责从 xim-pkgindex recipe 中选择和物化具体 provider。 + +### 7.3 通用构建接口 + +mcpp-index 输出: + +~~~text +LinkIntent { + libraries[], + linkLibraryDirs[], + transitiveNeededDirs[], + runtimeSearchDirs[], + frameworks[], + deployFiles[] +} + +RuntimeRequirement { + kind: soname | capability | icd_manifest | display | host_service, + value, + phase: link | run, + requester: PackageId, + required +} +~~~ + +xlings 解析 xim-pkgindex provider recipe 后提供: + +~~~text +RuntimeArtifact { + role: loader | library | driver | manifest | host_bridge, + provider: PackageId, + path, + provenance: payload | subos_view | host_link | system_sdk, + abi, + digest?, + hostFingerprint? +} +~~~ + +mcpp 只检查这些通用结构是否完整、目标平台是否匹配、最终 artifact 是否满足 loader/ABI 物理约束。 + +### 7.4 OpenGL 收口 + +mcpp-index: + +- ocornut.imgui 声明 core 与 backend features。 +- glfw-opengl3 template/feature 引入 GLFW、OpenGL headers 与通用 runtime requirement。 +- Linux package contract 依赖由 xim 提供的 graphics capability。 +- macOS contract 使用 native frameworks。 +- Windows contract 使用 Win32/system SDK 与声明的 runtime DLL。 + +xlings(基于 xim-pkgindex recipes): + +- Mesa/GLVND 与软件/硬件 driver closure。 +- NVIDIA/WSL host-link sentinel。 +- runtime dirs、实际 libraries 和 provenance 的解析/导出。 +- sentinel 的 applicable/not-applicable/inconclusive 生命周期。 + +目标状态是移除 mcpp-index 中复制 SubOS view library 的长期 symlink bridge;runtime artifact 通过 contract 传递。 + +### 7.5 Vulkan 收口 + +mcpp-index: + +- Vulkan headers、loader-facing API、backend sources 和 templates。 +- vulkan template 声明 loader、ICD manifest、display/surface requirements。 +- 不从 /usr/lib* 自行收集宿主 ICD/DSOs。 + +xlings(基于 xim-pkgindex recipes): + +- Vulkan loader、ICD manifests 与对应 driver。 +- Mesa RADV/Intel 等 payload provider。 +- NVIDIA/WSL host-link provider。 +- macOS MoltenVK、Windows system/provider 策略由平台包契约表达。 + +验证不能止于 loader symbol: + +1. create instance。 +2. enumerate physical devices。 +3. 记录实际 ICD manifest 与 driver provider。 +4. 有窗口 lane 时创建 surface。 +5. 无硬件环境明确报告 NOT_EXERCISED,不能汇总为 GPU pass。 + +### 7.6 ImGui feature/template 形态 + +建议 mcpp-index 将图形入口拆成: + +~~~text +core +headless +backend-glfw-opengl3 +backend-vulkan +app +docking +viewports +~~~ + +示例: + +~~~text +mcpp new app --template ocornut.imgui:glfw-opengl3 +mcpp new app --template ocornut.imgui@1.92.8:vulkan +mcpp new app --template ocornut.imgui@1.92.8:docking +~~~ + +- core/headless 不应无条件拉入 GLFW/OpenGL。 +- app 可以组合默认 backend,但 resolved dependency/features 必须写入生成清单。 +- docking/viewports 与 renderer backend 正交。 + +### 7.7 可观察性 + +mcpp why runtime 可以展示 xlings 已解析完成的结果: + +~~~text +requirement + -> selected canonical provider + -> runtime artifact + -> provenance + -> ABI/loader verdict +~~~ + +它是通用 contract 的解释器,不进行 GPU 探测。GPU/driver 诊断与重探测由 xlings 统一入口及其 xim provider/sentinel 暴露,mcpp 只给出跳转提示。 + +图形依赖和 SubOS 选择是正交的: + +- mcpp-index/xim 依赖描述“当前项目需要哪些图形能力”。 +- root [xlings].subos 描述“当前项目在哪个本地环境 build/run”。 +- library dependency 不通过自己的 SubOS 要求消费者环境;源码会在消费者已选环境中构建。 +- 某个图形 provider 对实际 ABI/glibc 的要求,由 xlings 在当前环境解析并验证,而不是把 provider 的 SubOS name 传播出去。 + +## 8. AUR 只自动维护 mcpp-bin + +### 8.1 包策略 + +| AUR 包 | 策略 | +|---|---| +| mcpp-bin | 自动生成、验证、对账和漂移告警;不阻塞 GitHub Release | +| mcpp-m | 本阶段不更新;不修改 package 文件或 AUR remote,只从 mcpp-bin 自动对账路径隔离 | +| mcpp-git | 不进入 release workflow;如要发布,单独认领与设计 | + +用户主路径是安装 release 预构建产物,因此自动化可靠性集中投入 mcpp-bin。 + +### 8.2 当前事实 + +2026-08-09 的审计快照: + +- GitHub latest stable:v2026.8.8.4。 +- AUR mcpp-bin2026.8.1.1-1。 +- 连续 push 失败的直接响应是 AUR maintenance。 +- workflow 缺少 retry、schedule 与状态对账,所以临时故障变成长时间漂移。 +- mcpp-m 存在已知独立问题,但本文不处理、不推送,也不让它成为 mcpp-bin reconciler 的前置条件或后置条件。 + +旧失败 run 不应直接 rerun,因为它从旧 release commit 执行旧生成逻辑。 + +### 8.3 单一 desired state + +release 产出不可变 manifest: + +~~~json +{ + "schema": 1, + "version": "2026.8.8.4", + "tag": "v2026.8.8.4", + "commit": "", + "assets": [ + { + "platform": "linux", + "arch": "x86_64", + "name": "mcpp-2026.8.8.4-linux-x86_64.tar.gz", + "sha256": "" + }, + { + "platform": "linux", + "arch": "aarch64", + "name": "mcpp-2026.8.8.4-linux-aarch64.tar.gz", + "sha256": "" + } + ] +} +~~~ + +reconciler 只消费 latest complete、非 draft、非 prerelease manifest。 + +### 8.4 mcpp-bin reconciler + +触发: + +- successful release workflow_run:低延迟。 +- schedule:建议每 6 小时。 +- workflow_dispatch:人工恢复,默认仍指向 latest stable。 + +流程: + +~~~text +read latest stable release manifest + -> query AUR mcpp-bin version and remote git head + -> compare with Arch vercmp + -> download both Linux assets + -> recompute sha256 and compare manifest/sidecar + -> generate PKGBUILD + -> generate .SRCINFO from PKGBUILD in Arch container + -> makepkg --verifysource + -> dry-run diff + -> fast-forward push with bounded retry + -> verify AUR git head + -> bounded poll AUR RPC + -> clean Arch install and mcpp --version smoke +~~~ + +### 8.5 幂等和失败分类 + +| 状态 | 行为 | +|---|---| +| desired == current 且内容一致 | success no-op | +| desired > current | 生成、验证、push | +| desired < current | 默认拒绝降级 | +| asset/hash 缺失 | push 前 permanent failure | +| AUR maintenance/timeout | transient;指数退避,后续 schedule 补偿 | +| SSH auth/metadata invalid | permanent;立即告警 | +| git 已更新但 RPC 延迟 | poll,不重复 commit | + +已知 package clone 失败不能自动当作首次发布。禁止 force-push AUR 历史。 + +### 8.6 mcpp-m 完全不动的边界 + +本文对 mcpp-m 的要求只有隔离,不包含任何维护动作: + +- 不修改 scripts/aur/mcpp-m/**。 +- 不生成或推送 mcpp-m AUR commit。 +- 不改变其版本、checksum、maintainer、deprecated 状态或现有远端历史。 +- 不宣称它与 GitHub Release 同步。 +- mcpp-bin reconciler 的成功/失败只由 mcpp-bin desired/observed state 决定。 + +实现 mcpp-bin reconciler 时应建立独立 job/workflow;现有 combined workflow 唯一允许涉及 mcpp-m 的变化是停止自动调用其 publish leg,以保证“不推送 mcpp-m”。不得读取、生成或改写 mcpp-m 内容。 + +### 8.7 AUR SLO + +建议初始目标: + +- AUR 可用时,release 后 30 分钟内 mcpp-bin 收敛。 +- event path 失败后,6 小时 schedule 再尝试。 +- 超过 24 小时仍漂移,自动更新固定告警 issue 或发高优先级通知。 + +## 9. 四项设计如何协同 + +### 9.1 默认用户 + +~~~text +install mcpp-bin + -> bundled xlings/bootstrap default runtime + -> mcpp new app --template ocornut.imgui:glfw-opengl3 + -> mcpp-index supplies template/build contract + -> xlings/xim supplies graphics runtime + -> mcpp build + -> mcpp run using the same default RuntimeBinding +~~~ + +用户不需要手工选择 GPU provider,也不需要配置 SubOS。 + +### 9.2 固定运行时用户 + +~~~toml +[xlings] +subos = "el8" +~~~ + +~~~text +mcpp new/build/run + -> same template/package selector + -> same mcpp-index graph + -> named SubOS RuntimeBinding + -> cache fingerprint changes + -> artifact targets that runtime +~~~ + +### 9.3 平台差异 + +| 平台 | mcpp default runtime | graphics provider | +|---|---|---| +| Linux x86_64 | mcpp-managed default SubOS/runtime binding | xim Mesa or explicit host sentinel | +| Linux aarch64 | mcpp-managed native runtime | capability must fail clearly until graphics recipe supports it | +| macOS arm64/x86_64 | mcpp-managed tool/SDK environment | native frameworks/MoltenVK package contract | +| Windows x64 | mcpp-managed tool/runtime environment | Win32/system DLL or declared Vulkan provider | + +跨平台只共享抽象契约,不假装共享底层文件布局。 + +## 10. 迁移阶段 + +### Stage 0 — 文法与边界冻结 + +- 冻结 TemplateSpec grammar:namespace/version/tname 均可省略,namespace 默认 mcpplibs。 +- 冻结“单模板在没有显式 default 时自动成为 default”。 +- 冻结 absence → McppDefault、[xlings].subos → NamedSubos。 +- 冻结 SubOS 是 root-local、非传递 build/run 环境。 +- 冻结 xlings 是 graphics runtime owner,mcpp 只有 generic contract knowledge。 +- 冻结 AUR managed set = {mcpp-bin}。 +- 冻结 mcpp-m package/AUR remote 不动。 + +### Stage 1 — 模板统一 + +- 提取共享 PackageSelector parser/resolver。 +- 将 add/template 统一为 bare → mcpplibs、dotted → exact namespace。 +- scaffold 保存完整 PackageId/provenance。 +- manifest 注入复用 add editor。 +- dotted namespace/template E2E。 +- pkg: list deprecation。 + +### Stage 2 — runtime 选择统一 + +- 引入 RuntimeSelection。 +- default 与 named SubOS 生成同一 RuntimeBinding snapshot。 +- build/run/test 共用 snapshot 和 contract hash。 +- 只从 root/workspace root 读取 SubOS;dependency/member 不向消费者传播。 +- active SubOS legacy warning。 + +### Stage 3 — graphics 收口 + +- mcpp-index 调整 ImGui/OpenGL/Vulkan features/templates 及 xlings 生态依赖。 +- xlings 基于 xim-pkgindex recipes 收口 GL/Vulkan runtime artifacts 和 sentinels。 +- 移除 host ICD farm 与 SubOS view symlink bridge。 +- mcpp 保持 generic planner/validator。 + +### Stage 4 — mcpp-bin 对账 + +- release manifest。 +- 单包 generator、dry-run 与 Arch validation。 +- event + schedule reconciler。 +- 从旧状态恢复到 latest stable,并验证真实安装。 + +这些 stage 是设计级依赖顺序,不是 implementation plan;每个 stage 仍需在设计获批后拆成独立可评审 PR。 + +## 11. 验收矩阵 + +### 11.1 模板 + +| 场景 | 期望 | +|---|---| +| pkg | mcpplibs:pkg + latest stable + default/single template | +| pkg@1.2.0 | default namespace + exact version + default/single template | +| ns.pkg:t | exact namespace ns + named template | +| ns.pkg@1.2.0:t | 完整解析,输出 exact PackageId/version/template | +| capi.lua / mcpplibs.capi.lua | 分别解析为 capi:lua / mcpplibs.capi:lua | +| add/template 相同 selector | 规范化为同一个 PackageId | +| 同 short name 不同 namespace | 不按 short name 注入/缓存碰撞 | +| 单 template、无显式 default | 自动选择该 template | +| 多 templates、无 default | 失败并列出 templates | +| 多 default | provider validation 失败 | +| prerelease only 且省略版本 | 失败并要求显式 version | +| 失败渲染/I/O | 目标目录不存在或事务回滚 | + +### 11.2 runtime + +| 场景 | 期望 | +|---|---| +| fresh HOME,无 [xlings] | bootstrap mcpp default,build/run 同 binding | +| named SubOS 存在 | build/run/test 使用该 contract | +| named SubOS 不存在 | hard error,不退回 default | +| active SubOS 与 default 不同 | 迁移期 warning;最终仍选择 default | +| 切换 subos | build fingerprint 改变,不复用旧 ABI objects | +| dependency 声明自己的 SubOS | 作为 dependency 时不传递、不覆盖 root 环境 | +| 同一源码在 el8/trixie 两个 root 中构建 | 各自在自己的 RuntimeBinding 下生成独立缓存/产物 | +| 预构建库 | 传递实际 artifact ABI requirement,不传递 SubOS name | +| Linux loader/libc 混源 | post-link 失败或严格迁移阶段明确 warning | +| macOS/Windows | 不执行 Linux glibc 规则 | + +### 11.3 graphics + +| Gate | 必须证明 | +|---|---| +| mcpp source ownership | 无 GPU vendor/ICD selection branch,只消费 xlings generic contract | +| xlings ownership | 根据 mcpp-index 依赖解析 xim recipes、provider/sentinel 与 provenance | +| mcpp-index static | OpenGL/Vulkan features、templates、platform dependencies 可解析 | +| Linux software GL | Xvfb/Wayland headless + llvmpipe 创建窗口/帧 | +| Linux native GL | AMD/Intel/NVIDIA/WSL 分别记录实际 provider | +| Vulkan | instance、physical device、ICD manifest/driver provenance | +| macOS | native framework/MoltenVK 路径 | +| Windows | Win32 backend 与 Vulkan provider 路径 | +| unsupported arch | 明确 capability error,不以 SKIP 计 pass | + +### 11.4 AUR + +| 场景 | 期望 | +|---|---| +| 新 stable release | mcpp-bin 在 SLO 内收敛 | +| 重复事件 | no-op,无新 commit | +| 迟到旧事件 | 不降级 | +| 两架构任一资产/hash 错 | push 前失败 | +| AUR maintenance | transient retry + schedule 补偿 | +| mcpp-m | package 文件和 AUR remote 均不变,不参与 mcpp-bin verdict | +| push 后 | git head、RPC version、clean install 全部验证 | + +## 12. 性能与简洁性约束 + +- TemplateSpec parse 为 O(length),不访问网络。 +- resolver 与 mcpp add 共用缓存与 IndexRoute,不增加第二轮全索引扫描。 +- RuntimeBinding 每次 configure 解析一次,contract hash 进入 snapshot;hot no-op 不启动 xlings。 +- mcpp build 热路径不探测 GPU、不执行 Vulkan/OpenGL 工具。 +- graphics provider 探测发生在 xim install/doctor 生命周期,并缓存 host fingerprint。 +- AUR schedule 在 desired == current 时只做轻量查询与一致性检查。 +- 不因这四项新增顶层 CLI 命令。 + +## 13. 错误与安全边界 + +- selector、version、template name 任一非法,在网络/install/目录创建前失败。 +- package descriptor 命中后必须校验声明的完整身份。 +- 模板为纯数据,不执行 provider hooks/scripts。 +- 生成在 sibling temp dir 完成,验证后原子 rename。 +- manifest 不接收项目级 absolute xlings binary/home。 +- named SubOS 不存在时不 fallback。 +- mcpp 不读取 host GPU library 目录来补齐索引缺口。 +- AUR 先验证全部 mcpp-bin assets,再加载 SSH secret/push。 +- AUR 只 fast-forward,不改写历史。 + +## 14. 第二轮 review 后已冻结的细节 + +1. namespace 可以省略;省略固定为 mcpplibs,写出 dotted namespace 时按 exact namespace 解释。 +2. 只有一个 template 且没有 default = true 时,该单模板自动成为 default。 +3. SubOS 暂时只允许通过 root/workspace-root mcpp.toml 选择,不增加 CLI override。 +4. SubOS 是本地 build/run 开发环境,不作为库的传递要求;不同项目可选择不同 SubOS/glibc。 +5. xlings 是 graphics stack/runtime owner;mcpp-index 声明 C++ 图形包到 xlings 生态的依赖,mcpp 只做 generic build。 +6. AUR 自动对账只面向 mcpp-bin;mcpp-m package 与 AUR remote 本阶段不动。 + +## 15. 与上一份综合设计的关系 + +本文仅在以下四处覆盖上位文档: + +| 上位文档建议 | 本文最终方向 | +|---|---| +| --template namespace:name@version --variant name | --template [ns.]name[@version][:tname];默认 ns=mcpplibs | +| mcpp 参与更宽的 graphics runtime 规划 | xlings 负责 graphics runtime;mcpp-index 声明依赖;mcpp 只消费通用契约 | +| runtime contract 为大范围跨仓主线 | McppDefault + existing [xlings].subos,且 SubOS root-local/non-transitive | +| mcpp-bin/mcpp-m 都进入自动 reconciler | 自动 reconciler 只管理 mcpp-bin;mcpp-m 不动 | + +未被本文覆盖的 C1–C9、artifact physics、identity 类型化与机器输出分析继续保留在上位文档中。 + +## 16. 当前实现证据锚点 + +| 主题 | 证据 | +|---|---| +| mcpp add dotted/colon selector | tests/e2e/12_add_command.sh:82-106src/pm/commands.cppm:75-143 | +| shared dotted candidate rules | src/pm/dependency_selector.cppm:78-120 | +| package identity spec | docs/spec/package-identity.md:150-169 | +| current TemplateSpec | src/scaffold/template.cppm:22-47 | +| scaffold short-name loss | src/scaffold/create.cppm:30-129 | +| template E2E | tests/e2e/69_package_templates.sh:94-194 | +| existing [xlings].subos | src/manifest/types.cppm:469-485src/manifest/toml.cppm:988-996 | +| root project environment materialization | src/build/prepare.cppm:1997-2009 | +| current runtime resolution | src/build/prepare.cppm:928-974 | +| user documentation | docs/05-mcpp-toml.md:946-978 | +| AUR workflow | .github/workflows/aur-publish.yml:14-102 | + +本文是待 review 设计,不应据此宣称上述行为已落地或关闭相关 issue。 diff --git a/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md new file mode 100644 index 00000000..cbeb4007 --- /dev/null +++ b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md @@ -0,0 +1,408 @@ +# mcpp Template, Runtime, Graphics, and AUR Convergence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在一张 mcpp Draft PR 中落地已冻结的模板 selector、项目级 RuntimeSelection/RuntimeBinding、provider-neutral 图形运行时契约与仅管理 mcpp-bin 的 AUR reconciler;随后完成 review-gated 普通合入、release、mcpp-index/GitCode 对接和隔离环境下的 xlings 全生态验证。 + +**Architecture:** CLI 和 mcpp.toml 共用一个 exact dotted PackageSelector;scaffold 在任何落盘前解析出完整 PackageId/version/template,并在 sibling 临时目录事务生成。运行时只允许 McppDefault 或 root/workspace-root NamedSubos,两者解析成一次性 RuntimeBinding snapshot,进入构建指纹并由 build/run/test 复用,dependency/member 的 SubOS 不传递。mcpp-index 声明图形 RuntimeRequirement,xlings/xim 解析 provider,mcpp 仅记录 canonical requester/provider/artifact provenance 和执行平台通用链接/运行验证。GitHub Release manifest 是 AUR desired state,mcpp-bin reconciler 单调、幂等、校验实物并只做 fast-forward push。 + +**Tech Stack:** C++23 modules, mcpplibs.cmdline, nlohmann/json, TOML manifest parser, Bash/Python 3 release tooling, GitHub Actions, Arch makepkg, gh/git, xlings/xim/mcpp-index. + +## Global Constraints + +- [ ] 所有行为改动遵循 RED → GREEN → refactor;每个 RED 命令和预期失败原因写入提交/验证台账。 +- [ ] mcpp 实现只使用一张 PR,基于最新 `origin/main`;不直接 push main,不 force-push,不 amend/rebase 历史。 +- [ ] 所有改动先形成可审阅的独立 commit,立即 push 到 Draft PR,并追加验证/checkpoint 评论;不在提交、PR 或日志摘录中记录本地用户名、绝对工作区路径或私有信息。 +- [ ] Draft PR 经用户 review 后才可转 ready;不使用 admin/bypass,只有最新 PR HEAD 的 required checks 全部终态成功后才进行普通 merge。 +- [ ] `mcpp-m` 边界为字节级不变:不修改 `scripts/aur/mcpp-m/**`,不读取/生成/发布其内容,不访问其 AUR remote。 +- [ ] 不增加 `--variant`、`--subos` 或其他顶层 CLI;不在 mcpp 中出现 GPU vendor、Mesa、NVIDIA、WSL 或 ICD 选择分支。 +- [ ] SubOS 只来自本次构建 root/workspace root 的 `mcpp.toml`,不写入 dependency requirement/lock identity,不从 dependency/member 继承。 +- [ ] 平台物理规则放入 `src/platform/` 或已有平台模块;Linux ELF/glibc 校验在 macOS/Windows 明确 no-op,不用 Linux 推断替代原生 CI。 +- [ ] 所有 stateful 本地/生态验证使用隔离 `HOME`、`MCPP_HOME`、`XLINGS_HOME` 和 SubOS root;验证前后确认宿主状态未变化。 +- [ ] 只显式 stage 本计划列出的文件;保留主 checkout 中用户的未跟踪 issue triage 文档。 +- [ ] GitHub release 是主发布真源;AUR 暂时不可用时 GitHub release 不回滚,reconciler 留下可重试的精确失败分类。 + +--- + +## Task 1: Freeze Baseline, Issue, Branch, and Test Evidence + +**Files:** + +- Modify: `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md` +- Add: `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md` +- Preserve: `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md` +- Preserve: `.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md` + +- [x] Create focused issue #398 referencing #397, #380, #392, and #396. +- [x] Create isolated worktree `feat/template-runtime-graphics-aur` from `origin/main@80291ca`. +- [x] Record baseline versions, latest releases, open PRs, and exact hashes for mcpp, xlings, mcpp-index, and xim-pkgindex in the validation ledger. +- [x] Run baseline `mcpp build`, unit suite, focused scaffold E2E, runtime E2E, AUR script tests (if any), and `git diff --check`; record any pre-existing failure without weakening later gates. +- [x] Snapshot hashes of `scripts/aur/mcpp-m/**` and the host xlings configuration for final boundary comparison. +- [x] Commit the approved design documents, implementation plan, and baseline validation ledger as the first explicit-files commit. + +## Task 2: Replace Candidate Guessing with One Exact PackageSelector + +**Files:** + +- Modify: `src/pm/dependency_selector.cppm` +- Modify: `src/pm/commands.cppm` +- Modify: `src/manifest/toml.cppm` +- Modify: `src/manifest/xpkg.cppm` +- Modify: `src/pm/dep_spec.cppm` +- Modify: `src/pm/index_route.cppm` +- Modify: `src/build/prepare.cppm` +- Modify: `src/cli.cppm` +- Modify: `tests/unit/test_pm_compat.cpp` +- Modify: `tests/unit/test_pm_index_route.cpp` +- Modify: `tests/unit/test_manifest.cpp` +- Modify: `tests/unit/test_pm_package_fetcher.cpp` +- Modify: `tests/e2e/12_add_command.sh` +- Modify: `tests/e2e/27_namespace_dependencies.sh` +- Modify: `tests/e2e/62_dotted_dependency_selector_priority.sh` +- Modify: `tests/e2e/63_bare_dependency_peer_root_priority.sh` +- Modify: `tests/e2e/78_test_main_combinations.sh` +- Modify: `tests/e2e/79_gtest_regular_dep_feature_main.sh` +- Modify: `tests/e2e/162_bare_name_namespace_scope.sh` +- Modify: `tests/e2e/165_bare_name_cross_namespace_wire_address.sh` +- Add: `tests/e2e/203_exact_selector_lock_migration.sh` +- Modify: `docs/05-mcpp-toml.md` +- Modify: `docs/zh/05-mcpp-toml.md` +- Modify: `docs/06-workspace.md` +- Modify: `docs/zh/06-workspace.md` +- Modify: `docs/spec/package-identity.md` + +**Interfaces:** + +- `PackageSelector { optional namespace; NameAtom name; string spelling; }` +- `parse_package_selector(string_view) -> expected` +- `normalize_package_selector(PackageSelector, defaultNs="mcpplibs") -> DependencyCoordinate` +- `format_package_selector(DependencyCoordinate) -> dotted selector` + +- [x] RED: add unit rows proving `lua -> (mcpplibs,lua)`, `capi.lua -> (capi,lua)`, `mcpplibs.capi.lua -> (mcpplibs.capi,lua)`, and rejection of empty/double-dot/control segments. +- [x] RED: update add E2E so `mcpp add capi.lua@5.4.7` probes/writes exact `capi:lua`, not `mcpplibs.capi:lua`; prove a same-short-name sibling cannot win. +- [x] Implement the O(length), no-I/O shared parser/normalizer and return one exact coordinate after default namespace filling. +- [x] Route `mcpp add`, dependency TOML parsing, feature dependency parsing, and xpkg dependency parsing through the same normalized coordinate. +- [x] Preserve already-parsed/locked identities; during the migration release, emit a warning only when an old dotted candidate exists and differs from the new exact coordinate, with both copyable selectors. +- [x] Update existence-gate and not-found diagnostics to show the normalized PackageId and explicitly mention default `mcpplibs` when namespace was omitted. +- [x] GREEN: run the focused unit and namespace/add E2E tests, then `git diff --check`. +- [x] Commit exact selector normalization and identity documentation. + +## Task 3: Make TemplateSpec Typed, Namespace-Aware, and Deterministic + +**Files:** + +- Modify: `src/scaffold/template.cppm` +- Modify: `src/scaffold/create.cppm` +- Modify: `src/cli/cmd_new.cppm` +- Modify: `src/cli.cppm` +- Add: `tests/unit/test_scaffold.cpp` +- Modify: `tests/e2e/69_package_templates.sh` +- Modify: `.github/workflows/ci-fresh-install.yml` + +**Interfaces:** + +- `TemplateSpec { PackageSelector package; optional version; optional templateName; bool legacyList; }` +- `parse_template_spec(string_view) -> expected` +- `ResolvedTemplatePackage { DependencyCoordinate id; string version; string indexRoute; string descriptorDigest; string payloadDigest; path root; }` +- `TemplateSelection { ResolvedTemplatePackage package; string templateName; }` + +- [x] RED: unit-test every valid and invalid grammar row from focused design §5.1, including multiple `:`/`@`, empty components, exact dotted namespaces, and `pkg:` legacy list recognition. +- [x] RED: add E2E packages with `mcpplibs:widget`, `acme:widget`, and `mcpplibs.capi:lua`; prove template resolution uses the exact PackageId through `IndexRoute::lookup_descriptor`. +- [x] Implement TemplateSpec parsing in the fixed order template delimiter → version delimiter → shared PackageSelector; reject before config/network access. +- [x] Replace scaffold short-name/compat probing with `IndexRoute` exact lookup and keep canonical namespace, version, route, descriptor digest, payload digest, and root through fetch/render/output. +- [x] Reuse package-manager semver resolution for latest stable; omitted versions must not select prereleases, while explicit exact prerelease remains allowed. +- [x] RED: test default selection rules: one explicit default wins; one sole non-default auto-wins; multiple without default list choices and fail; multiple defaults fail validation; no templates directory reports provider error. +- [x] Implement the default rules and `pkg:` one-release warning pointing to `--list-templates`. +- [ ] Update human and machine output to include canonical selector, resolved namespace/name/version, template, and runtime selection. +- [ ] GREEN: run new unit tests, template E2E, and three-platform fresh-install template lanes. +- [x] Commit typed TemplateSpec and exact template-package resolution. + +## Task 4: Make Scaffolding Safe and Transactional (#380) + +**Files:** + +- Add: `src/scaffold/project_name.cppm` +- Add: `src/platform/project_name.cppm` +- Add: `src/platform/scaffold_fs.cppm` +- Modify: `src/scaffold/template.cppm` +- Modify: `src/scaffold/create.cppm` +- Modify: `src/manifest/toml.cppm` +- Modify: `src/pm/commands.cppm` +- Modify: `tests/unit/test_scaffold.cpp` +- Modify: `tests/unit/test_manifest.cpp` +- Modify: `tests/e2e/69_package_templates.sh` +- Add: `tests/e2e/204_new_transactional_scaffold.sh` + +**Interfaces:** + +- `validate_project_name(string_view) -> expected` +- `render_tokens(string_view, RenderVars) -> expected` +- `ScaffoldTransaction::begin(parent,name)`, `commit()`, destructor rollback. + +- [x] RED: reject empty, absolute, separators, `.`, `..`, C0/DEL, Windows reserved device names, trailing dot/space, quote/tab and names containing the legacy `PROJECT` marker before target creation. +- [x] RED: prove inserted values containing placeholder-like text are not rescanned and all RenderVars render canonical project/template identities. +- [x] RED: inject read/write/copy/rename failures and assert neither final target nor sibling temporary directory remains. +- [x] Implement shared portable project-name validation with platform-specific reserved-name rules isolated under `src/platform/project_name.cppm`. +- [x] Replace repeated string substitution with a single-pass token renderer that never rescans inserted values. +- [x] Extend RenderVars with project namespace/qualified name and template package namespace/name/selector/version/template. +- [x] Replace substring dependency detection with structured manifest editing keyed by canonical PackageId; exact resolved version and features must be idempotent across same short names in different namespaces. +- [x] Generate builtin and package templates in a same-parent temporary directory, check every filesystem/stream operation, fsync/close as supported, and atomically rename only after validation. +- [x] GREEN: run scaffold unit/E2E tests, reproduce #380 cases with timeouts, and confirm no path escape or partial output. +- [ ] Commit transactional scaffold and close #380 from the final PR only after CI. + +## Task 5: Introduce Root-Local RuntimeSelection and One RuntimeBinding Snapshot + +**Files:** + +- Add: `src/xlings/runtime_selection.cppm` +- Add: `src/platform/runtime_binding.cppm` +- Modify: `src/build/prepare.cppm` +- Modify: `src/build/plan.cppm` +- Modify: `src/build/execute.cppm` +- Modify: `src/xlings/subos_info.cppm` +- Modify: `src/toolchain/model.cppm` +- Modify: `src/toolchain/detect.cppm` +- Modify: `src/toolchain/fingerprint.cppm` +- Inspect (no change required; policy is owned by `runtime_selection`): `src/project.cppm` +- Add: `tests/unit/test_runtime_selection.cpp` +- Modify: `tests/unit/test_subos_info.cpp` +- Modify: `tests/unit/test_fingerprint.cpp` +- Add: `tests/e2e/205_root_local_subos.sh` + +**Interfaces:** + +- `RuntimeSelection { enum Mode { McppDefault, NamedSubos }; string subosName; Source source; path ownerRoot; }` +- `RuntimeBinding { int schema; string providerId; string platform; string arch; string contractHash; optional loader; optional libc; vector libraryDirs; vector environment; vector capabilities; string provenance; }` +- `select_runtime(rootManifest, optional workspaceManifest, rootPath) -> expected` +- `resolve_runtime_binding(selection, compiler, GlobalConfig) -> expected` + +- [x] RED: no `[xlings].subos` selects `McppDefault` even if active SubOS differs; an explicit `subos = "default"` is a NamedSubos selection. +- [x] RED: missing named SubOS is a hard error and cannot fall back to default/active/compiler-baked runtime. +- [x] RED: workspace root SubOS overrides member declaration during workspace build; a member declaration applies only when that member is built independently. +- [x] RED: dependency manifest SubOS never merges into the consumer, lockfile, or cache identity; the same sources under el8/trixie produce distinct RuntimeBinding contract hashes. +- [x] Implement selection before workspace member substitution and preserve its owner root; materialize project xlings config from the root/workspace-root selection only. +- [x] Define McppDefault as the mcpp-managed default SubOS/runtime in configured xlings home, independent of xlings active/current symlink; bootstrap it when absent. +- [x] Read one RuntimeBinding snapshot, canonicalize/sort its fields, compute contractHash, store it in BuildContext/BuildPlan/cache metadata, and feed it to toolchain detection/fingerprint. +- [x] Reuse that snapshot for run/test environment; remove build/run fast-path re-reads of active SubOS. +- [x] Match current xlings `op=set` presence semantics: preserve any ambient value, including an explicitly empty value, while `prepend` remains ordered and de-duplicated. +- [x] On macOS/Windows return platform-native bindings without invented glibc/ELF fields; platform-specific derivation stays in `src/platform/runtime_binding.cppm`. +- [x] GREEN: run unit tests, named/default/workspace/dependency SubOS E2E, and compare binding/fingerprint output. +- [x] Commit root-local runtime selection and shared binding snapshot. + +## Task 6: Fix Runtime Payload Selection and Add Linux Artifact Physics (#392/#396) + +**Files:** + +- Modify: `src/toolchain/post_install.cppm` +- Modify: `src/toolchain/lifecycle.cppm` +- Add: `src/platform/elf_runtime.cppm` +- Modify: `src/platform/runtime_binding.cppm` +- Add: `src/build/runtime_validation.cppm` +- Modify: `src/build/ninja_backend.cppm` +- Modify: `src/build/execute.cppm` +- Modify: `src/doctor.cppm` +- Modify: `src/xlings/subos_info.cppm` +- Add: `tests/unit/test_elf_runtime.cpp` +- Add: `tests/e2e/206_runtime_binding_physics.sh` + +**Interfaces:** + +- `ElfRuntimeFacts { artifact; interp; runpaths; needed; requiredGlibcVersions; definedGlibcVersions; resolvedLibc; resolvedObjects; }` +- `validate_runtime_artifact(path, RuntimeBinding, RuntimeResolution) -> RuntimeVerdict` +- `RuntimeVerdict { Pass | ProvenMismatch | Inconclusive; diagnostics[]; }` + +- [x] RED: select the glibc payload named by RuntimeBinding, never the first directory entry; stale/absent payload is an explicit error. +- [x] RED: fixture ELFs prove Rule B rejects interpreter/libc from different payloads and accepts same-payload paths. +- [x] RED: fixture version tables prove a required GLIBC symbol floor above the selected libc exports is a hard proven mismatch; a lower/equal floor passes; unavailable closure data is inconclusive, not falsely green. +- [x] Implement semantic exact payload lookup in post-install fixup and bump the fixup revision so existing toolchains repair against the selected binding. +- [x] Implement internal ELF64 little-endian parsing for PT_INTERP, DT_RPATH/RUNPATH, DT_NEEDED and GNU version need/definition sections under `src/platform/elf_runtime.cppm`; no shell parsing on the build hot path. +- [x] Validate only newly linked Linux ELF outputs; cache the verdict by artifact stat/link fingerprint so hot no-op performs zero parses. +- [x] Emit canonical requester/provider/artifact paths and a copyable SubOS remediation; hard-fail proven Rule B/A mismatches, classify unresolvable host/hardware closure as inconclusive with an explicit diagnostic. +- [x] Ensure macOS/Windows validators compile to a typed no-op and never apply Linux glibc rules. +- [x] Extend doctor/runtime explanation to reuse stored verdict rather than re-probe or guess. +- [x] GREEN: run ELF unit fixtures, form-X E2E, current #392 reproduction shape, and a safe host-DSO control. +- [x] Commit runtime physics validation; only close #392/#396 if the final released E2E proves their exact acceptance cases. + +## Task 7: Carry Provider-Neutral Graphics Runtime Provenance + +**Files:** + +- Modify: `src/manifest/types.cppm` +- Modify: `src/manifest/toml.cppm` +- Modify: `src/manifest/xpkg.cppm` +- Modify: `src/build/plan.cppm` +- Modify: `src/build/flags.cppm` +- Modify: `src/build/prepare.cppm` +- Modify: `src/build/runtime_validation.cppm` +- Modify: `src/doctor.cppm` +- Modify: `src/platform/runtime_binding.cppm` +- Modify: `src/xlings/subos_info.cppm` +- Add: `tests/unit/test_runtime_contract.cpp` +- Modify: `tests/unit/test_link_model_runtime_dirs.cpp` +- Modify: `tests/unit/test_runtime_selection.cpp` +- Modify: `tests/unit/test_subos_info.cpp` +- Modify: `tests/e2e/62_runtime_library_dirs.sh` +- Modify: `tests/e2e/66_runtime_provides.sh` +- Modify: `tests/e2e/200_subos_env_reaches_program.sh` +- Modify: `tests/e2e/205_root_local_subos.sh` +- Add: `tests/e2e/207_runtime_contract_provenance.sh` +- Modify: `docs/05-mcpp-toml.md` +- Modify: `docs/zh/05-mcpp-toml.md` +- Modify: `docs/08-toolchain-internals.md` +- Modify: `docs/zh/08-toolchain-internals.md` + +**Interfaces:** + +- `RuntimeRequirement { kind; value; phase; canonical requester PackageId; required; }` +- `RuntimeArtifact { role; canonical provider PackageId; path; provenance; abi; digest; hostFingerprint; }` +- `LinkIntent { libraries; linkLibraryDirs; transitiveNeededDirs; runtimeSearchDirs; frameworks; deployFiles; }` + +- [x] RED: same-short-name providers in two namespaces remain distinguishable in runtime resolution JSON and `mcpp why runtime`. +- [x] RED: required capabilities and provided capabilities are separate; a requester cannot become its own provider merely because it requires a capability. +- [x] RED: runtime search dirs do not enter `-L`; Linux transitive-needed dirs use `-Wl,-rpath-link`, macOS emits rpath/install-name semantics, and Windows uses explicit deploy files. +- [x] Introduce structured generic requirement/artifact/provenance values while keeping legacy descriptor fields readable for one compatibility train. +- [x] Populate requester/provider from the resolved package identity, including namespace/version/index provenance; never use bare `package.name` as provider identity. +- [x] Write the resolved RuntimeBinding, requirements, artifacts, search mechanism, and validation verdict into `resolution.json`. +- [x] Make `mcpp why runtime` a pure interpreter of stored generic facts; GPU/driver diagnostics point to xlings and never probe hardware. +- [x] Add a static ownership gate that rejects new mcpp source branches containing provider-specific GPU/ICD selection vocabulary outside docs/tests. +- [x] GREEN: run runtime-contract unit/E2E tests and prove the build hot path launches no GL/Vulkan probe. +- [x] Commit provider-neutral runtime contract and link-intent separation. + +## Task 8: Generate an Immutable Release Manifest + +**Files:** + +- Add: `scripts/release/generate_manifest.py` +- Add: `tests/scripts/test_release_manifest.py` +- Modify: `.github/workflows/release.yml` +- Modify: `docs/09-release.md` +- Modify: `docs/zh/09-release.md` + +**Interface:** + +- Release asset `mcpp-release.json` schema 1 with version, tag, release commit, and exact name/SHA256 for Linux x86_64/aarch64 plus every shipped platform asset. + +- [x] RED: fixtures reject duplicate platform/arch rows, missing sidecars, mismatched hashes, draft/prerelease input, wrong tag/version, and non-deterministic ordering. +- [x] Implement deterministic manifest generation from downloaded release artifacts and sidecars, recomputing every SHA256. +- [x] Wire the release workflow so the manifest is uploaded only after all required release assets exist and validation passes. +- [x] Add a release gate that downloads the uploaded manifest and compares it to the final GitHub release inventory. +- [x] GREEN: run manifest tests and a local fixture generation twice with byte-identical output. +- [x] Commit immutable release desired-state manifest. + +## Task 9: Replace Combined AUR Publishing with an mcpp-bin Reconciler + +**Files:** + +- Add: `scripts/aur/reconcile_mcpp_bin.py` +- Add: `scripts/aur/render_mcpp_bin.py` +- Add: `tests/scripts/test_aur_reconcile.py` +- Modify: `scripts/aur/update.sh` +- Modify: `scripts/aur/README.md` +- Modify: `.github/workflows/aur-publish.yml` +- Preserve byte-for-byte: `scripts/aur/mcpp-m/**` + +**Interface:** + +- `inspect -> DesiredState/ObservedState/ReconcilePlan` +- exit classification `noop | updated | transient | permanent | refused-downgrade` +- manual inputs `publish=false|true`, optional exact latest stable tag only; no downgrade override in this phase. + +- [x] RED: fixture tests cover desired==observed no-op, upgrade, late old event refusal, missing/hash-mismatched asset, AUR maintenance retry, auth/permanent failure, RPC lag after git update, and known clone failure not becoming first publish. +- [x] RED: assert the reconciler never opens, hashes, copies, stages, or addresses `scripts/aur/mcpp-m/**`; compare pre/post tree hashes. +- [x] Split `update.sh` into an mcpp-bin-only compatibility wrapper around the renderer; remove all mcpp-m reads/writes without editing mcpp-m files. +- [x] Render PKGBUILD from `mcpp-release.json`; regenerate `.SRCINFO` with non-root Arch `makepkg --printsrcinfo`, then `makepkg --verifysource`. +- [x] Query AUR RPC and HTTPS git, compare versions with Arch `vercmp`, validate both Linux assets/sidecars, and produce a dry-run diff before secrets are loaded. +- [x] Publish only by normal fast-forward SSH push with pinned AUR host key and bounded exponential retry for maintenance/timeouts; never force or initialize a missing known package. +- [x] Verify remote git head, bounded-poll RPC, then install in a clean Arch container and assert `mcpp --version`. +- [x] Change workflow triggers to successful release workflow_run + six-hour schedule + workflow_dispatch; all call the same latest-stable reconciler. Remove the mcpp-m publish leg. +- [x] Emit Actions summary with trigger, desired/observed versions, hashes, remote commit, retry count, classification, and drift age; AUR failure must not alter GitHub release conclusion. +- [ ] GREEN: run Python tests, shell lint, dry-run against current latest release, and an Arch container source verification. +- [x] Commit mcpp-bin-only AUR reconciliation and verify mcpp-m byte hashes are unchanged. + +## Task 10: Pin Latest xlings, Version mcpp, and Update User Documentation + +**Files:** + +- Modify: `src/xlings.cppm` +- Modify: `.github/actions/bootstrap-mcpp/action.yml` +- Modify: `.github/workflows/bootstrap-macos.yml` +- Modify: `.github/workflows/ci-linux-e2e.yml` +- Modify: all other authoritative xlings pin sites found by `tests/unit/test_xlings_version_pin.cpp` +- Modify: `mcpp.toml` +- Modify: `README.md` +- Modify: `docs/00-getting-started.md` +- Modify: `docs/zh/00-getting-started.md` +- Modify: `docs/05-mcpp-toml.md` +- Modify: `docs/zh/05-mcpp-toml.md` +- Modify: `docs/08-toolchain-internals.md` +- Modify: `docs/zh/08-toolchain-internals.md` +- Modify: `docs/spec/package-identity.md` +- Modify: `scripts/aur/README.md` + +- [x] Re-query latest non-draft/non-prerelease xlings immediately before pinning; pin the exact version and verify every authoritative pin site matches. +- [x] Choose the next unused calendar version (expected `2026.8.9.1` after live tag check), update `mcpp.toml`, and leave AUR snapshots to release-time generation. +- [x] Document `[ns.]name[@version][:tname]`, default `mcpplibs`, exact dotted namespaces, sole-template default, and legacy list migration. +- [x] Document McppDefault vs root/workspace-root `[xlings].subos`, no CLI override, non-transitive dependency semantics, coexistence across glibc bindings, and prebuilt ABI metadata boundary. +- [x] Document xlings/xim ownership of OpenGL/Vulkan providers and that mcpp never probes GPU/driver/ICD. +- [x] Document mcpp-bin-only eventual AUR reconciliation and explicitly state mcpp-m/mcpp-git are outside automation. +- [x] Update English and Chinese examples together; regenerate command reference if CLI help changed. +- [ ] GREEN: run pin tests, docs example tests, generated command reference tests, `git diff --check`, and forbidden-vocabulary/boundary scans. +- [ ] Commit version, xlings pin, and documentation. + +## Task 11: Full Local Validation and PR Publication + +**Files:** + +- Modify: `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md` + +- [ ] Build a fresh mcpp binary with the pinned xlings in isolated homes; do not validate with a stale installed mcpp. +- [ ] Run the complete unit suite and every applicable Linux E2E, then focused no-cache workspace/self-host/release builds. +- [ ] Run scaffold/template, default/named/workspace SubOS, runtime physics, runtime provenance, release-manifest, and AUR reconcile gates independently and record commands/results. +- [ ] Run sanitizers/static checks available in repository CI, `git diff --check origin/main...HEAD`, forbidden mcpp-m diff, and explicit scope inventory. +- [ ] Measure hot no-op build and template parse against baseline; require no material regression and zero xlings/GPU subprocess on hot no-op. +- [ ] Rebase is forbidden; if origin/main advanced, merge origin/main normally, rerun all affected gates, and preserve visible history. +- [x] Push the branch and open one Draft PR with `Closes #398`, related issue notes, architecture decisions, RED/GREEN evidence, boundary proof, version/pin, and cross-repo follow-up plan. +- [ ] After every logical commit, push immediately and append a checkpoint with commit id, focused evidence, remaining failures, and cross-repository boundary. +- [ ] Request review using the repository review workflow and address feedback with fresh evidence. + +## Task 12: Native GitHub Actions, Review-Gated Merge, and Release + +**Skills required at this task:** `mcpp-release`, `superpowers:verification-before-completion`, and `superpowers:finishing-a-development-branch`. + +- [ ] Wait for all latest-HEAD checks to reach terminal success across Linux, macOS, Windows, cross/QEMU aarch64, native aarch64, fresh install, release manifest, and AUR dry-run lanes. +- [ ] Treat skipped native/runtime hardware rows as NOT_EXERCISED, not pass; create an isolated temporary CI lane if a required platform gate is absent. +- [ ] Verify remote PR HEAD equals local HEAD, branch diff is clean, no unresolved reviews, no conflict, and `scripts/aur/mcpp-m/**` is unchanged. +- [ ] After explicit user review, mark the Draft ready and use the repository's normal merge path without admin/bypass; verify the merge commit is on main and PR/issue states are correct. +- [ ] Follow `mcpp-release`: verify version strings, create/push the release tag, monitor every release job, asset, checksum, `mcpp-release.json`, GitCode mirror, and GitHub release until terminal success. +- [ ] Do not block/rollback the GitHub release for an AUR transient; dispatch the fixed latest-stable mcpp-bin reconciler and record its exact terminal state. +- [ ] Close #380/#392/#396 only when their released acceptance paths are proven; otherwise comment with delivered subset and keep the residual open. + +## Task 13: mcpp-index, GitCode Resources, and xlings Ecosystem Follow-Through + +**Repositories:** + +- `mcpplibs/mcpp-index` +- `openxlings/xim-pkgindex` +- `openxlings/xlings` +- GitCode helper/config already used by the release workflow + +- [ ] In isolated repo-specific worktrees, update mcpp-index minimum/current mcpp pins and template descriptors to canonical selectors and sole/default template lint. +- [ ] Refactor ImGui features/templates to core/headless, backend-glfw-opengl3, backend-vulkan, app, docking, and viewports without making core pull graphics unconditionally. +- [ ] Express OpenGL/Vulkan runtime requirements through generic xlings/xim package/capability dependencies and canonical provider identities; remove mcpp-index host ICD/DSO collection paths. +- [ ] In xim-pkgindex, add/fix Vulkan loader/ICD and host-link provider recipes/provenance only where current recipes cannot satisfy the new generic contract; keep provider detection/lifecycle outside mcpp. +- [ ] Use the established index CI workflow, one repo PR per external repository as required by repository boundaries, and wait for every latest-head validation job before merge. +- [ ] Confirm the mcpp release job opens/merges the mcpp index version bump and local GitCode resources contain the exact new release assets plus SHA256 sidecars; verify GitCode with ranged GET and full SHA256. +- [ ] Publish/rebuild index artifacts and verify their public hashes/refs. +- [ ] From fresh isolated HOME/MCPP_HOME/XLINGS_HOME, install the released mcpp and pinned/released xlings, run `xlings update`, canonical template new/build/run/test, default runtime, two named SubOS/glibc builds, and package lifecycle smoke. +- [ ] Run software OpenGL and Vulkan instance/device lanes where hardware exists; report AMD/Intel/NVIDIA/WSL/macOS/Windows hardware rows as PASS/FAIL/NOT_EXERCISED with actual provider/ICD provenance. +- [ ] Verify mcpp does not probe GPU and the same graphics dependencies resolve through xlings/xim under each exercised environment. +- [ ] Record all PRs, commits, CI run/job IDs, release tags, asset hashes, GitCode URLs, AUR state, and public smoke results in the validation ledger. + +## Task 14: Completion Audit and Final Report + +- [ ] Verify mcpp main/release/index heads, tags, PR merge states, issue comments/states, AUR mcpp-bin desired/observed state, and public artifact hashes from live sources. +- [ ] Verify local worktrees are clean and main checkout user files remain untouched. +- [ ] Re-run `git diff --check`, version/pin consistency, mcpp-m boundary hash, and one final cold-home released-binary smoke. +- [ ] Mark every checkbox with evidence; do not convert NOT_EXERCISED hardware rows into green. +- [ ] Update the active goal to complete only when no required item remains. +- [ ] Report concise core outcomes plus links to the implementation plan, validation ledger, issue, PR, CI, release, index PRs/artifacts, GitCode, AUR and ecosystem evidence. diff --git a/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md new file mode 100644 index 00000000..14e95f37 --- /dev/null +++ b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md @@ -0,0 +1,408 @@ +# mcpp Template, Runtime, Graphics, and AUR Validation Ledger + +> Started: 2026-08-09 Asia/Shanghai +> +> Implementation issue: https://github.com/mcpp-community/mcpp/issues/398 +> +> This ledger separates baseline, RED, GREEN, CI, release, and public-ecosystem evidence. A running, skipped, cancelled, or superseded job is never recorded as pass. + +## 1. Scope and repository boundaries + +The mcpp implementation is developed in one branch and one pull request: + +- worktree: isolated feature worktree; local path intentionally omitted +- branch: `feat/template-runtime-graphics-aur` +- base: `mcpp-community/mcpp main@80291ca01a982c1e8c00e43bfa97ffe68516e6d7` +- focused issue: `mcpp-community/mcpp#398` +- umbrella issue: `mcpp-community/mcpp#397` + +External repositories retain their own history and review boundaries. Their implementation, if required after the mcpp release, uses separate repository PRs; it does not create a second mcpp implementation PR. + +The implementation worktree was created from `origin/main`; pre-existing checkout state was left untouched. + +## 2. Live baseline + +Captured after fresh `git fetch origin main --prune` on 2026-08-09: + +| Repository | local HEAD | `origin/main` | local state | latest release | +|---|---|---|---|---| +| `mcpp-community/mcpp` | `80291ca01a982c1e8c00e43bfa97ffe68516e6d7` | same | isolated feature worktree clean before docs | `v2026.8.8.4`, published 2026-08-08 10:51:31Z | +| `openxlings/xlings` | `2913a0949af3b26192a6c1d8f12b78f976bea6b8` on `feat/version-grammar` | `f203b6b9a5e3e0d3f707468ed56cdb8b50cc7acc` | dirty user checkout; must use a new worktree | `v2026.8.9.2`, published 2026-08-08 20:38:57Z | +| `mcpplibs/mcpp-index` | `b86fc7c0c80a93f4ccdf797c13ec0cca2557d6eb` | same | clean main | no GitHub release list | +| `openxlings/xim-pkgindex` | `576ef09b69becefca00de8c94c6ba17d5cdb1ee4` | `e8029381beb2e0c83c4ec4318f01bd18c523890a` | local main behind; must use a new worktree | no GitHub release list | + +Open PR inventory at capture time: + +- mcpp: #387, #372 (Draft), #353, #351 (Draft), #272. +- xlings: none. +- mcpp-index: #150 (Draft), #61. +- xim-pkgindex: none. + +Local executables visible before the isolated cold build: + +- PATH mcpp: `mcpp 2026.8.6.2`. +- latest existing source-built binary: `mcpp 2026.8.8.4` at `target/x86_64-linux-gnu/09887d532ce30543/bin/mcpp` in the main checkout. +- PATH xlings: `xlings 2026.8.9.2`. + +The release version proposed for this implementation is `2026.8.9.1`; the tag is re-queried immediately before changing `mcpp.toml`. + +## 3. Immutable boundary snapshots + +Approved design hashes: + +- focused design: `e06821b73102049e1f2184a2967e5dd81dfb6f05389a763dad326d139d0e3f25` +- parent convergence design: `ad93db80a3222f6536629a15d9b5ba430b222e179b91de01131c619ecbd3bd65` +- initial implementation plan: `aea2963f593ef00e71b2876f52a773014a28098f513f82260063dcd336acb09e` + +`scripts/aur/mcpp-m/**` baseline: + +| File | SHA256 | +|---|---| +| `.SRCINFO` | `9d8b8279aadfa6b1915850fbbfe33c2ef237456f94dd706e726e3169b60be0f8` | +| `PKGBUILD` | `545fe0de51f0cf7e5979871ba8f8d6cd3233c485338a9768f57e53c0a6f8fa7f` | +| `mcpp.sh` | `cbab68984b02c415f8ae42bf9417647b842e900fbf7bf292067e4a384b924f8f` | +| sorted aggregate | `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6` | + +Host xlings configuration snapshot: + +- scope: sorted SHA256 rows for `$XLINGS_HOME/.xlings.json` and `$XLINGS_HOME/subos/*/.xlings.json`; contents were not copied. +- aggregate: `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`. + +All stateful verification uses a separate temporary root and must reproduce both aggregate hashes before completion. + +## 4. Baseline verification + +Isolated root: disposable task root; exact local path intentionally omitted. + +| Gate | Command shape | Status | Evidence | +|---|---|---|---| +| cold source build | isolated `MCPP_HOME`, `XLINGS_HOME`, vendored xlings 2026.8.9.2; `mcpp build --no-cache` | PASS | cold bootstrap installed `glibc@2.44` and resolved `gcc@16.1.0`; release build finished in 64.80s; binary `target/x86_64-linux-gnu/72abd390cce53924/bin/mcpp` reports `2026.8.8.4` | +| unit suite | fresh baseline binary, isolated homes; `mcpp test` | PASS | `68 passed; 0 failed`, 89.06s total | +| template E2E | isolated homes; `MCPP= bash tests/e2e/69_package_templates.sh` | PASS | exit 0, `OK` | +| runtime E2E | isolated homes; scripts 74, 166 and 201 | PASS with one fixture rerun | #74 proved payload glibc 2.44 PT_INTERP; #166 proved private glibc env inclusion/exclusion; #201 passed both sysroot/payload-first modes after using a non-`/tmp` isolated HOME | +| AUR tests | inventory + `bash -n scripts/aur/update.sh` | PASS for syntax; coverage absent | baseline has no reconciler tests; Task 9 adds fixture coverage | +| diff check | `git diff --check origin/main...HEAD` before edits | PASS | no tracked diff before documentation was added | + +### Baseline fixture diagnosis + +The first #201 run exited 1 because the isolated `MCPP_HOME` itself was below the system temporary directory. The test intentionally rejects every RUNPATH beginning with that directory as evidence of another temporary home, so it classified the current, valid isolated home as pollution. The observed two paths were the current glibc and gcc payload directories, not a stale foreign home. + +The hypothesis was tested by hard-link cloning the disposable state into a non-temporary isolated root, letting the normal fixup rebind paths, and running the unmodified script again. It exited 0 and proved both link modes load nothing from the host. No product or test source was changed for this baseline result. + +## 5. RED/GREEN ledger + +Each behavior entry is appended with: + +1. exact test command; +2. RED exit code and why the old code failed; +3. production change; +4. GREEN exit code and relevant assertions; +5. refactor/full-gate result. + +### 5.1 Exact PackageSelector and dependency identity (Task 2) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| shared parser | Selector tests initially could not compile because `PackageSelector` / `parse_package_selector` did not exist; unsafe-segment rows then exposed missing character validation | Added one O(length), no-I/O parser, default `mcpplibs` normalization, dotted formatter, and diagnostic-only legacy coordinate helper | `test_pm_compat`: 17 tests passed, including bare/dotted/nested/invalid rows | +| manifest/xpkg parsing | Updated manifest expectations produced 3 failures under ordered-candidate behavior; invalid TOML/xpkg selectors were previously not hard parse failures | Routed direct, nested, feature, target and xpkg dependency inputs through the shared exact parser | `test_manifest`: 148 passed; `test_pm_index_route`: 7 passed; `test_pm_package_fetcher`: 10 passed | +| add/exact sibling | `12_add_command.sh` first showed `capi.lua` written/resolved with the old default-prefix candidate and no migration diagnostic; a later RED left both legacy `"acme.util"` and canonical `[dependencies.acme] util` rows | `mcpp add` now accepts canonical `[ns.]name@version`, retains `ns:name` as a warned one-release alias, probes only one PackageId, removes an equivalent legacy flat row, and writes/upserts the canonical table | `12_add_command.sh`: `capi.lua` cannot be stolen by `(mcpplibs.capi,lua)`; warning names both exact selectors; legacy source shape becomes one canonical row; malformed input leaves TOML byte-stable | +| scoped remove | Added same-name `[dev-dependencies]` before `[dependencies]`; RED removed the dev row and left the regular row | Limited flat removal to the exact `[dependencies]` body and kept nested namespace removal exact | Rebuilt source binary; the same E2E preserves the dev row and removes only the regular dependency | +| exact build miss | `162_bare_name_namespace_scope.sh` initially passed an exact miss into install-time compatibility retries | Every version dependency is identity-validated before install; miss reports the exact coordinate and did-you-mean remains diagnostic-only | `162`: bare gtest stays `(mcpplibs,gtest)` while explicit `(compat,gtest)` resolves; `165` proves the descriptor supplies the exact wire namespace | +| lock migration | New `203_exact_selector_lock_migration.sh` first failed because Form-B synthesis received the ambiguous manifest map key instead of the resolved short name | Existing v2 lock identity anchors an old dotted selection for one release train; unlocked selection never falls back; Form-B synthesis uses canonical short name | `203`: warning names old/new selectors, build/run use the locked old identity, rewritten lock retains its namespace | + +Task 2 refactor/full gates on the isolated source binary +`target/x86_64-linux-gnu/aee81584bf66d3f8/bin/mcpp`: + +- `mcpp build --no-cache`: PASS, release build completed in 64.12s. +- `mcpp test --no-cache`: PASS, **68 passed; 0 failed**, 86.37s. +- focused E2E set `12, 27, 62, 63, 78, 79, 162, 165, 203`: all PASS/OK. +- changed shell scripts `bash -n`: PASS. +- `git diff --check`: PASS. + +### 5.2 Typed exact TemplateSpec and deterministic provider selection (Task 3) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| grammar | New `test_scaffold` could not build because typed `parse_template_spec` / `select_template` APIs did not exist | Added fixed-order `:` then `@` parsing over the shared PackageSelector; exact safe version keys; atomic tname validation; one-release `pkg:` list marker | 9 scaffold tests pass for every valid design row and empty/double-delimiter/range/unsafe failures | +| exact provider identity | Updated E2E is red on the baseline binary at the first new exact-identity diagnostic; baseline implementation probes empty/compat short names and cannot retain an explicit foreign/nested PackageId | Registry template fetch now uses one normalized coordinate through `IndexRoute::lookup_descriptor`; no short-name or compat probe remains | E2E same-short `(mcpplibs,tpl-demo)` / `(acme,tpl-demo)` selects acme exactly; nested `(mcpplibs.capi,lua)` survives output and injected selector | +| deterministic version | Baseline accepted only its ad-hoc version flow and exposed no payload provenance | Shared SemVer resolver chooses latest stable, explicit prerelease is validated, indirect aliases are refused, and xpkg version entries retain declared SHA256 | E2E omits `2.0.0-rc.1` for default selection, accepts its exact pin, rejects `latest`, rejects unpublished `9.9.9`, and prints descriptor/payload provenance | +| default template | Old code failed whenever no `default=true` existed, including a package with exactly one template | Shared selection implements sole-template auto-default, unique explicit default, explicit tname, and deterministic provider errors | Unit/E2E cover sole auto-default, multiple ambiguous choices, duplicate defaults, missing templates directory, and explicit disambiguation | +| migration/CI surfaces | Legacy `pkg:` silently meant listing and native fresh-install only exercised a bare short name | `pkg:` warns with copyable `--list-templates`; fresh-install Linux/macOS/Windows lanes now use explicit `mcpplibs.imgui` and assert resolved identity | Local legacy-list E2E passes; native lanes are configured and remain pending PR/latest-head CI evidence | + +Task 3 local gates on source binary +`target/x86_64-linux-gnu/94da92f90aedbe7f/bin/mcpp`: + +- source build: PASS, full release rebuild completed in 72.92s. +- `mcpp test --no-cache`: PASS, **69 passed; 0 failed**, 33.07s. +- `test_scaffold`: 9/9 behavior rows PASS; `test_manifest`: 148/148 PASS. +- `69_package_templates.sh`: PASS with exact/default/nested/alias/provider/wire assertions. +- `bash -n tests/e2e/69_package_templates.sh` and `git diff --check`: PASS. + +### 5.3 Portable, durable, transactional scaffolding (Task 4 / #380) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| portable project name | New scaffold tests failed to compile because `mcpp.scaffold.project_name` did not exist | Added an ASCII package/directory validator and isolated Windows device-basename policy under `mcpp.platform.project_name`; `cmd_new` validates before config/index/network/staging work | Unit rows accept bare/qualified names and reject empty, absolute, separators, dot components, C0/DEL, Windows-invalid/device names, trailing dot/space, and the legacy `PROJECT` marker | +| single-pass rendering | Baseline only repeatedly replaced the literal `PROJECT`/two legacy variables | Added the complete eight-field `RenderVars` vocabulary, strict unknown/unterminated-token diagnostics, and append-only single-pass rendering | Unit test proves every canonical project/provider identity renders and an inserted `{{template.name}}` sequence is not rescanned; E2E renders qualified/nested identities | +| exact dependency edit | New manifest-editor tests failed to compile because `upsert_dependency_text` did not exist; baseline scaffold used substring presence/insertion | Added one parsed, source-preserving dependency editor shared by `mcpp add` and scaffold injection; it writes default tables or `[dependencies.]` plus short key and reparses/verifies the exact PackageId/version/features | `test_manifest` 150/150; same-short `compat.widget` survives while exact `acme.widget` is injected idempotently; add E2E remains green | +| transaction and I/O | Baseline created the final directory before rendering and ignored several stream/filesystem results; exclusive platform rename API was absent | Both builtin/package paths write a same-parent `.mcpp-new-*` tree, close+sync files, validate the complete manifest, then commit with Linux `renameat2(RENAME_NOREPLACE)`, macOS exclusive rename, or Windows write-through no-replace move | 18 scaffold tests cover rollback plus deterministic read/write/copy/rename failures; E2E 204 covers path escape, unknown token, type collision, symlink rejection, no final tree, and no staging residue | + +Task 4 focused local evidence on source binary +`target/x86_64-linux-gnu/94da92f90aedbe7f/bin/mcpp`: + +- source build: PASS, release rebuild completed in 47.89s after the shared manifest editor change. +- full unit suite: PASS, **69 test binaries passed; 0 failed**, 16.77s cached refactor gate. +- `test_scaffold`: **18/18** behavior tests PASS; `test_manifest`: **150/150** PASS. +- `02_new_build_run.sh`: PASS, generated builtin project builds and runs. +- `12_add_command.sh`, `69_package_templates.sh`, and `204_new_transactional_scaffold.sh`: all PASS/`OK`. +- `mcpp-m` aggregate remains `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`. +- host xlings config aggregate remains `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`. +- macOS/Windows native compilation and filesystem semantics remain pending latest-HEAD PR CI; they are not inferred from this Linux run. + +### 5.4 Root-local RuntimeSelection and immutable RuntimeBinding (Task 5) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| two-mode selection | `test_runtime_selection` first failed to compile because `mcpp.xlings.runtime_selection` did not exist | Added presence-preserving manifest parsing and one pure `McppDefault`/`NamedSubos` selector with portable name validation and an explicit owner root | 10 runtime selection/binding tests pass; absence, explicit `default`, workspace override, standalone member and invalid names are distinct | +| exact binding | Binding tests then failed to compile because `mcpp.platform.runtime_binding` did not exist | Resolves only the configured default or exact root-owned named SubOS; missing directory, missing block, empty runtime and incompatible schema are hard errors; canonical provider/runtime/env snapshot gets a stable contract hash | Unit tests prove missing named cannot fall back, same-content `el8`/`dev` hashes differ, and serialized cache round-trip verifies its hash | +| lifecycle/cache | Baseline execute path derived a compiler-owned SubOS, honored `MCPP_SUBOS_DIR`, and re-read `.xlings.json` independently on full/fast run | BuildContext/BuildPlan/toolchain/fingerprint/cache carry one snapshot; fast paths require it and invalidate on contract mtime; full and cached run/test resolve environment only from that snapshot | E2E 200 proves first/cached run equality, ignored legacy override, mutation-triggered rebuild, old-cache miss and missing-contract error | +| root-local scope | A member/dependency declaration could become the manifest visible after workspace substitution or nested prepare | Selection happens before member substitution; workspace root materializes its own xlings config; dependency/tool sub-builds inherit the consumer snapshot and never consult their manifest SubOS | E2E 205 proves active-state independence, explicit-default identity, workspace-root ownership, standalone-member ownership and non-transitive path dependency behavior | +| xlings env semantics | New tests showed `set` overwrote both a caller value and an explicitly empty variable | `set` now follows xlings presence semantics; `prepend` retains ordered element-wise de-duplication | `test_subos_info`: 17/17 pass, including caller/empty preservation | + +Task 5 local gates on source binary +`target/x86_64-linux-gnu/e49880a389812d1b/bin/mcpp`: + +- source build: PASS, incremental release rebuild completed in 47.59s. +- full unit suite: PASS, **70 test binaries passed; 0 failed**, 87.78s. +- `test_manifest`: **151/151**; `test_runtime_selection`: **10/10**; `test_subos_info`: **17/17**; `test_fingerprint`: **8/8**. +- E2E `02`, `88`, `200`, `205`, `12`, `69`, and `204`: all PASS/`OK`. +- changed shell scripts `bash -n` and `git diff --check`: PASS. +- `mcpp-m` aggregate remains `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`. +- host xlings config aggregate remains `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`. +- Linux behavior is locally exercised. The platform-native branch stores `runtimeId` while leaving `libc`/ELF fields empty off Linux; native macOS/Windows compilation remains a latest-HEAD PR CI gate, not a local claim. + +### 5.5 Exact runtime payload and Linux ELF physics (Task 6 / #392 / #396) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| exact payload | `test_elf_runtime` initially failed to compile because `mcpp.platform.elf_runtime` did not exist; the old post-install helper returned the first loader-bearing glibc directory | Added exact `glibc@` parsing/lookup, explicit malformed/missing/stale errors and fixup revision `hermetic-4-exact-runtime`; RuntimeBinding now carries canonical loader/lib dir and optional `host_glibc` | RuntimePayload unit rows prove 2.44 wins with 2.39 also installed and every malformed/absent identity refuses instead of falling back | +| internal ELF facts | The same RED had no typed artifact facts or GNU version-table reader | Added bounded ELF64-LE parsing of PT_INTERP, PT_DYNAMIC, DT_RPATH/RUNPATH/NEEDED and GNU verneed/verdef, preserving loader search order and invoking no shell tool | Synthetic stripped-shape fixture returns exact interpreter, ordered runpaths, needed soname, `GLIBC_2.40` need and `GLIBC_2.44` definition; text/truncated inputs refuse | +| Rule B | No post-link seam compared the emitted interpreter with the libc actually resolved through the closure | Closure resolution canonicalizes artifacts/providers and rejects host/private or multi-private libc mixtures against the selected RuntimeBinding | Unit rows reject interpreter/libc and transitive two-libc mixtures and accept one selected payload; E2E 206 forces host PT_INTERP + private libc and gets a canonical Rule-B hard failure | +| Rule A | No code compared a host DSO's GNU symbol floor with the selected libc exports | Validator compares every requester `GLIBC_*` need to the selected libc definitions; proven higher floors fail, missing closure data is typed inconclusive | Unit rows reject 2.42 > 2.39, accept equal/lower, and keep missing GPU closure inconclusive; E2E 206 links real host `libtinfo` under glibc 2.44 as the safe #392-shaped control | +| cache/doctor | A hot fast path could not know whether its artifact had ever been checked; doctor reparsing would observe a different current host | Stored verdict is keyed by artifact stat plus RuntimeBinding contract; hot paths require current PASS and compare pre/post-Ninja stats; mismatch/inconclusive records remain sticky without reparsing; doctor reads the stored record | Self-host artifact verdict is `pass`; bare build finishes in 0.01s with artifact/verdict stat unchanged; E2E 206 proves PASS no-op, cached mismatch remains red with unchanged verdict, and doctor prints the stored PASS | +| platform boundary | Linux-specific rules had no typed cross-platform seam | Validator compiles as a typed no-op off Linux; RuntimeBinding leaves ELF/libc-only data absent on macOS/Windows | Linux unit/E2E pass; native macOS/Windows compile/behavior remains an explicit PR CI gate, not inferred locally | + +Task 6 focused local evidence on source binary +`target/x86_64-linux-gnu/665e89fc782f5d0f/bin/mcpp`: + +- two-stage self-host: first new implementation build PASS; RuntimeBinding-aware rebuild PASS in 65.06s with canonical glibc 2.44 loader/lib directory and host floor 2.39. +- `test_elf_runtime`: **10/10** tests PASS (exact payload, parser, Rule A/B, inconclusive). +- `206_runtime_binding_physics.sh`: PASS for real host-DSO control, post-link mismatch, sticky cached mismatch, doctor reuse, and hot zero-rewrite cache. +- regressions `200_subos_env_reaches_program.sh`, `205_root_local_subos.sh`, and `02_new_build_run.sh`: PASS/PASS/OK. +- full unit/refactor gate: PASS, **71 test binaries passed; 0 failed**, 50.99s (49.30s build + 1.54s run). +- `bash -n` for changed E2E and `git diff --check`: PASS. +- #392/#396 remain open until the merged release artifact repeats the exact acceptance E2E; local source evidence alone is not closure evidence. + +### 5.6 Provider-neutral runtime provenance and LinkIntent (Task 7) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| structured descriptor contract | `test_runtime_contract` initially failed to compile because `RuntimeRequirement`, `RuntimeArtifact`, `LinkIntent`, canonical `PackageId`, and the resolver API did not exist; the first xpkg GREEN attempt then exposed a dangling `string_view` over a temporary parser body; review RED proved an explicit relative library file remained relative to the consumer instead of its declaring package | TOML and xpkg descriptors now read the same typed requirement/artifact/link-intent grammar; explicit library paths resolve at the declaring package; legacy `library_dirs`, `dlopen_libs`, and `capabilities` remain readable for one train and normalize only to runtime-search/requirement facts | `test_runtime_contract` TOML/xpkg rows pass and assert every structured channel, package-root path anchoring, plus retained legacy fields | +| exact ownership | The old runtime model retained only short provider strings and treated a requirement as a weak provider candidate | Build-plan resolution stamps every requirement/artifact/provider with namespace, name, version, and source provenance; only `provides` creates a descriptor provider; an explicit override is exact or uniquely compatible and missing/ambiguous cases hard-fail | Unit rows keep `alpha.backend@2.0.0` and `beta.backend@3.0.0` distinct and prove the requester is never self-promoted; E2E 66 rejects the legacy weak-requester override | +| xlings-selected facts | RuntimeBinding had no generic provider/artifact payload and its hash/cache could not preserve a host selection; review RED then proved equal-prefix facts were not ordered by source/provenance/digest and could make the contract hash input-order-dependent | Optional schema-1 `subos_info.runtime_contract` facts resolve `${subosdir}`/relative paths once, sort by their complete identity into RuntimeBinding, participate in its hash/serialization, and precede descriptor fallback facts in BuildPlan | `test_subos_info` and `test_runtime_selection` round-trip provider identity, artifact provenance, digest and host fingerprint; the full-fact ordering row and merge row prove deterministic selected-before-fallback behavior | +| link/search separation | Legacy `library_dirs` entered both `-L` and rpath; there was no typed seam to assert all output formats | Added a pure four-flavor LinkIntent renderer: runtime dirs never enter link lookup, ELF alone gets `-rpath-link`, Mach-O gets rpath/frameworks, PE gets link dirs while deploy files remain copy edges | `test_link_model_runtime_dirs` passes all flavor assertions; E2E 62 inspects a real Linux link and finds RUNPATH without the forbidden `-L` token | +| durable explanation | Runtime explanation could resolve again and provider provenance was not present in the stored schema; an added RED also showed `required=false` ABI facts were incorrectly promoted into hard compatibility constraints | `resolution.json` schema 2 stores the exact binding, canonical requirements/providers/artifacts, link intent, search mechanism, and synchronized post-link verdict; optional requirements remain recorded but stay out of hard ABI/doctor projections; `mcpp why runtime` reads only the newest stored file | E2E 207 builds successfully with an intentionally mismatched optional `abi:musl`, preserves same-short identities/artifact provenance, corrupts `mcpp.toml`, installs fake probe commands, then proves `why runtime` still succeeds without launching them | +| ownership boundary | No automated fence prevented mcpp from acquiring graphics-provider or hardware-probe policy | Added a source gate over executable code branches and process-launch neighborhoods; diagnostics redirect provider/host re-diagnosis to `xlings doctor` | Static gate passes over the complete `src/` tree; E2E 207 proves the live why path is probe-free | +| runtime-physics compatibility | Synthetic named SubOS fixtures contained only metadata, which became physically false after Task 6 began enforcing loader/libc closure | E2E 200/205 now reuse the managed default's exact libc/loader view while varying only environment and root ownership; no product fallback was introduced | E2E 200, 205, and 206 pass with truthful bindings; Task 6 Rule A/B enforcement remains intact | + +Task 7 focused local evidence on the latest self-hosted source binary +`target/x86_64-linux-gnu/4091fed9f558ec8a/bin/mcpp`: + +- self-host source builds: PASS; initial RuntimeBinding implementation completed in 68.66s and the post-review deterministic/optional/path refinement rebuild completed in 78.83s. +- focused units: `test_runtime_contract` **5/5**, `test_link_model_runtime_dirs` **5/5**, `test_runtime_selection` **10/10**, and `test_subos_info` **18/18** PASS. +- focused E2E `62_runtime_library_dirs.sh`, `66_runtime_provides.sh`, and `207_runtime_contract_provenance.sh`: PASS/`OK`. +- RuntimeBinding regressions `200_subos_env_reaches_program.sh`, `205_root_local_subos.sh`, and `206_runtime_binding_physics.sh`: PASS. +- full no-cache unit gate: PASS, **72 test binaries passed; 0 failed**, 109.40s (107.48s build + 1.78s run); after the review refinements, the complete latest-source refactor gate repeated **72/72** in 14.58s (12.98s build + 1.48s run). +- changed shell scripts `bash -n`, executable mode for E2E 207, and `git diff --check`: PASS. +- `mcpp-m` aggregate remains `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`. +- host xlings config aggregate remains `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`. +- Linux semantics are locally exercised. Mach-O and PE flag spelling has pure unit coverage on Linux; native macOS/Windows compilation and behavior remain latest-HEAD PR CI gates, not inferred passes. + +### 5.7 Immutable release desired state (Task 8) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| manifest contract | The initial 11-case suite produced 12 failing assertions because no generator existed; fixtures cover duplicate normalized platform/arch, missing sidecars, payload/sidecar hash disagreement, draft/prerelease releases, wrong tag/version, missing required assets, duplicate release names, and invalid commit identity | Added schema-1 generation from the GitHub release JSON plus downloaded payloads; four current platforms are mandatory, every additional versioned platform payload is included, every digest is recomputed, and output is sorted deterministically | `test_release_manifest.py` passes **12/12**; randomized release-API asset order produces byte-identical output and every manifest hash equals the independently recomputed fixture payload hash | +| future platform closure | Review fixture added a valid FreeBSD/riscv64 versioned payload; the first implementation stayed green while silently omitting that row | Primary-platform discovery is provider-neutral and version-exact rather than a fixed four-platform allowlist; the four present release targets remain a required floor | The focused future-platform test changed RED-to-GREEN and asserts the complete row and SHA256 occur in the manifest | +| immutable publication | Previously each platform uploaded independently and downstream jobs inferred completeness from timing and filenames | New `release-manifest` job waits for all four uploaders, validates non-draft/non-prerelease identity, uploads only when absent, refuses a byte-different existing manifest, polls public API visibility, then refetches all public assets and regenerates/compares the manifest | Ruby/Psych parses `release.yml` and confirms six jobs, `publish-ecosystem` depends on `release-manifest`, Python bytecode compilation passes, and `git diff --check` is clean; live GitHub upload/refetch remains a PR/release CI gate | +| maintainer contract | Release docs had no machine-readable completeness boundary or reproducible local audit command | English and Chinese release docs define schema, row scope, immutability/rerun behavior, public refetch command, and checklist gates | Documentation examples invoke the same checked-in generator and explicitly recompute payload hashes rather than trusting manifest or sidecar values | + +Task 8 focused local evidence: + +- RED: missing generator yielded 12 assertion failures across 11 initial tests; the later additional-platform review row independently failed because FreeBSD/riscv64 was omitted. +- GREEN: `python3 tests/scripts/test_release_manifest.py` reports **12 tests OK** in 0.309s; `python3 -m py_compile` succeeds for generator and tests. +- Determinism: the complete fixture shuffles API inventory with seed 398, invokes the CLI twice to separate output files, and compares exact bytes. +- Public-inventory replay: downloaded the eight real versioned payload/sidecar assets from stable `v2026.8.8.4`, resolved tag commit `55a39d90fe98b5475fc394ac8487fe6804b2b84f`, and generated twice byte-identically. The four-row manifest SHA256 is `6614ba2db65c8c28cb5a9d3466bd6cf3007634221da76d25216785061c647edb`; every real sidecar and recomputed payload digest agreed. +- Workflow structure: Ruby/Psych parses `.github/workflows/release.yml`; all six jobs are present and ecosystem publication is gated by the immutable manifest job. +- Public GitHub upload/refetch and eventual-consistency polling cannot be truthfully claimed from local fixtures; they remain explicit latest-HEAD release CI evidence. + +### 5.8 mcpp-bin-only AUR reconciliation (Task 9) + +- State-machine/contract suite: `python3 -m unittest tests/scripts/test_aur_reconcile.py` + passes **12/12**. It covers no-op/upgrade/repair/RPC-lag/refused-downgrade, + missing or mismatched release material, bounded maintenance retry, permanent + auth failure, failed known-package clone without repo initialization, exact + latest-complete release selection, workflow recovery triggers, and the pinned + AUR ED25519 fingerprint. It also requires both the rendered `mcpp-bin` + maintainer and reconciler commits to use the public `speak-agent` GitHub + noreply identity rather than a personal address. +- Release fixture: the real public `v2026.8.8.4` payloads/sidecars and locally + generated immutable manifest validate as desired `2026.8.8.4-1`; both Linux + digests match manifest and sidecar. The first Arch render attempt classified + the Docker Hub registry-header timeout as **transient**, not success. A later + real Arch run completed `makepkg --printsrcinfo` and non-root + `makepkg --verifysource`; the rendered `.SRCINFO` and both source payloads + verified successfully. +- `bash -n scripts/aur/update.sh`, Python compilation/tests, Ruby/Psych workflow + parse, and `git diff --check`: PASS. +- Protected `scripts/aur/mcpp-m/**` aggregate before/after remains + `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`. +- No AUR publish was performed. The public package remains an observed + `2026.8.1.1-1` while the verified desired fixture is `2026.8.8.4-1`; release + publication must later exercise the reconciler from a fixed merged + main/release state. + +### 5.9 latest pin, version, and user contract docs (Task 10) + +- Live GitHub release API on 2026-08-09: newest stable xlings is + `v2026.8.9.2`; newest stable mcpp is `v2026.8.8.4`, so the next unused mcpp + version is `2026.8.9.1`. +- Live `openxlings/xim-pkgindex` main identifies `2026.8.8.4` as the current + mcpp latest for Linux/macOS/Windows; `.xlings.json` now bootstraps that + published version rather than the previous stale `2026.8.6.2`. +- `.github/tools/check_version_pins.sh` passes with every xlings pin at + `2026.8.9.2`, build version `2026.8.9.1`, and bootstrap mcpp `2026.8.8.4`. +- English/Chinese README, getting-started, manifest, toolchain, identity, AUR, + and changelog text now state the same template/SubOS/graphics/AUR boundaries. + +## 6. Pull request and CI + +Draft PR [#400](https://github.com/mcpp-community/mcpp/pull/400) was opened from +`feat/template-runtime-graphics-aur`. Every logical change is a separate pushed +commit followed by a PR checkpoint comment. Branch history is not rewritten; +the PR remains Draft until explicit user review. + +Initial head `234a4df` exposed two real boundaries: stale declared glibc +identity versus the resolved SubOS view on Linux, and Linux-only +runtime-physics assertions executing on macOS/Windows. Both received focused +regressions at `dae4384`. Subsequent native and local review exposed and fixed: + +- exact namespace inheritance and dependency-owner retention; +- ELF SONAME reuse and exact-miss diagnostic cause retention; +- truthful runtime contracts in fake xlings fixtures; +- macOS BMI-settling scope; +- Windows open-stream cleanup; +- isolated `MCPP_HOME` use in the libc poison fixture; +- lexical workspace-root anchoring for inherited relative indices on Windows. + +At implementation head `ed4cf64`, the complete latest-source local C++ gate is +**72/72**. One isolated Linux full-E2E audit produced **186 pass, 1 fixture +failure, 19 platform/capability skips**; the only failure was the wrong state +root in E2E 156, and its exact rerun passes after `0cc6a2a`. Exact E2E 12 also +passes after `ed4cf64`. This is deliberately not restated as a full latest-head +206-case rerun. + +The native `ed4cf64` matrix is terminal with 12 successes, five failures and one +concurrency cancellation: + +- PASS: hermetic Linux E2E, macOS ARM64 E2E, musl + LLVM, MinGW + Linux-to-Windows + Wine, Windows-to-Linux cross-build and Linux artifact run, + Linux unit/cold-GCC/both E2E shards, Windows build/unit/package and Windows + E2E 1/2; +- FAIL at one known cross-repository boundary: Linux xlings integration, macOS + xlings LLVM E2E, aarch64 mcpp + xlings, and Windows xlings regressions all + encounter xlings' bare `ftxui` declaration under exact selection; +- FAIL independently in Windows E2E 2/2 (run `31321961040`, job + `93266267511`): workspace-member `mcpp add acme.util@2.0.0` cannot read its + root-owned local index even though the corresponding native unit passes; +- CANCELLED: the bare-Windows/no-Visual-Studio job was superseded by a later + push and is not counted as pass. + +The exact Windows artifact succeeds for both a hand-authored member and `mcpp +new m1` under an isolated Wine reproduction. That narrows the remaining failure +to native Git Bash/process/workspace state, but does not prove it fixed. +Commit `9a47ccf` therefore adds a permanent, privacy-safe `route:` error line +that reports only local-index root/`pkgs` presence. Its TDD cycle is RED on the +missing API, then GREEN for `PmIndexRoute` 12/12 and real E2E 12. The next native +Windows run remains the authority for the root cause; no latest-head full-suite +claim is made yet. + +The xlings boundary is tracked by +[openxlings/xlings#521](https://github.com/openxlings/xlings/pull/521), whose +eight native/cross checks pass. It remains Draft and REVIEW_REQUIRED. After the +handoff (`9f6161a`) and validation-ledger (`6e42d6c`) commits, privacy review +produced `cf39cb2`: the AUR reconciler and rendered `mcpp-bin` now use the public +`speak-agent` noreply identity, with a RED/GREEN contract regression. `189c6d1` +records that 12/12 contract result. The current diagnostic head `9a47ccf` must +reach terminal state before any latest-head claim. + +The Chinese operator handoff is +`.agents/docs/2026-08-09-pr400-handoff-zh.md`. It records implementation scope, +all pushed commits, diagnosed failures, honest validation boundaries, remaining +release/ecosystem work, and the mandatory Draft/commit/checkpoint/privacy +workflow. + +## 7. Merge and release + +Not started. After explicit user review, this section will record the normal merge commit, tag, GitHub release, release workflow/job IDs, asset inventory, checksums, `mcpp-release.json`, and GitCode mirror evidence. Admin/bypass merge is outside the approved workflow. + +## 8. AUR mcpp-bin + +Current live snapshot from the preceding audit: + +- GitHub desired release: `v2026.8.8.4`. +- AUR observed `mcpp-bin`: `2026.8.1.1-1`. +- latest failed AUR run: `31254088758`, after version and both checksums resolved, then AUR SSH returned maintenance. +- public replay manifest SHA256: + `6614ba2db65c8c28cb5a9d3466bd6cf3007634221da76d25216785061c647edb`. +- real Arch dry-run: `.SRCINFO`, non-root `makepkg --verifysource`, and both + Linux source checksums PASS for desired `2026.8.8.4-1`. + +No old failed run will be rerun. Recovery uses the reconciler from a fixed main/release state. `mcpp-m` is outside every verdict and publish path. + +## 9. Cross-repository and public ecosystem verification + +### 9.1 mcpp-index exact identity bridge + +- Issue [mcpplibs/mcpp-index#196](https://github.com/mcpplibs/mcpp-index/issues/196) + is closed by merged PR + [mcpplibs/mcpp-index#197](https://github.com/mcpplibs/mcpp-index/pull/197). +- Merge commit: `b974cbba5a5ab7da7908422e44ff8e4b63243dc3`. +- The immutable `mcpplibs.capi.lua@0.0.3` payload remains unchanged; an + index-side Form-B bridge declares the actual `compat.lua@5.4.7` dependency. +- All ten PR checks passed, including descriptor lint/select, Linux/macOS/ + Windows workspace consumers, mirror reachability, graphics side-effect + isolation, and timings. + +### 9.2 xlings exact compatibility identities + +- Issue [openxlings/xlings#520](https://github.com/openxlings/xlings/issues/520) + and Draft PR + [openxlings/xlings#521](https://github.com/openxlings/xlings/pull/521) + carry the xlings repository boundary. +- Head `0ff9e502f6857b5d1a48657dfeeb94244e322cb9` declares + `compat.ftxui@6.1.9` and test-only `compat.gtest@1.15.2`, then refreshes the + lock so the transitive Lua C library retains exact identity. +- All eight Linux/macOS/Windows/aarch64 checks pass. The PR remains Draft, + MERGEABLE but REVIEW_REQUIRED; no admin/bypass merge is authorized. +- Current xlings release `2026.8.9.2` predates this fix. After review and normal + merge, a separate xlings version bump/release is required before updating the + mcpp pin. + +### 9.3 Still pending + +GitCode ranged/full resource publication, any required xim-pkgindex update, +fresh-home package lifecycle, the canonical template build/run, multiple +SubOS/glibc bindings, and OpenGL/Vulkan provider +PASS/FAIL/NOT_EXERCISED provenance remain release-follow-through work. None is +claimed from source-only or local Linux evidence. diff --git a/.agents/docs/2026-08-09-pr400-handoff-zh.md b/.agents/docs/2026-08-09-pr400-handoff-zh.md new file mode 100644 index 00000000..33e0f620 --- /dev/null +++ b/.agents/docs/2026-08-09-pr400-handoff-zh.md @@ -0,0 +1,422 @@ +# PR #400 中文交接文档 + +> 更新时间:2026-08-10 00:06 CST +> +> 维护身份:`speak-agent` +> +> 状态:Draft,尚未批准合并、尚未发布 +> 本文只记录可公开的仓库事实;本地用户名、绝对工作区路径、临时目录、凭据和个人邮箱均有意省略。 + +## 1. 交接结论 + +本轮已经把用户确认的四组设计决策实现到 +[mcpp-community/mcpp#400](https://github.com/mcpp-community/mcpp/pull/400): + +1. 包和模板统一使用 `[ns.]name[@version][:tname]`,省略 `ns` 时严格等于 + `mcpplibs`;`version` 和 `tname` 可省略;不引入 `--variant`;只有一个模板时, + 即使没有声明 `default = true`,它也自动成为默认模板。 +2. mcpp 不探测 GPU、Mesa、NVIDIA、WSL、ICD 或驱动来源。xlings/xim 负责图形栈和 + 运行时事实,mcpp-index 负责 C++ 包依赖关系,mcpp 只消费规范化后的 provider、 + artifact、provenance 和 link intent。 +3. mcpp 默认使用自己的运行时;根项目或 workspace 根可以在 `mcpp.toml` 中选择 + xlings SubOS。暂不提供 CLI override。SubOS 只是本次根构建/运行的本地 OS 环境, + 不传递为依赖约束,成员和源码库自己的 SubOS 声明不会污染消费者。 +4. AUR 自动化只收口 `mcpp-bin`。`mcpp-m` 的文件、远端和发布路径均未修改。 + +实现本身已经完成主要代码、单元测试、E2E、英文/中文文档、release manifest 和 +AUR reconciler。当前不能合并的主要原因不是上述核心实现,而是新“精确命名空间” +契约暴露了 xlings 仓库中仍然存在的裸 `ftxui`/`gtest` 声明。修复已经放在独立 +Draft PR [openxlings/xlings#521](https://github.com/openxlings/xlings/pull/521), +8/8 CI 通过,等待用户 review 和普通合并。 + +## 2. 关键入口和当前边界 + +| 项目 | 当前状态 | 说明 | +|---|---|---| +| mcpp 实施 issue | [#398](https://github.com/mcpp-community/mcpp/issues/398),OPEN | 冻结范围和验收条件;已更新为 Draft、逐 commit/checkpoint、隐私安全、普通合并 | +| mcpp 汇总 issue | [#397](https://github.com/mcpp-community/mcpp/issues/397),OPEN | C1-C9、特殊保留项和最终残留工作的统一入口 | +| mcpp Draft PR | [#400](https://github.com/mcpp-community/mcpp/pull/400),Draft | 本轮唯一 mcpp 实施 PR | +| scaffold 缺陷 | [#380](https://github.com/mcpp-community/mcpp/issues/380),OPEN | 代码已实现,仍需发布后二次验收才可关闭 | +| glibc 冲突 | [#392](https://github.com/mcpp-community/mcpp/issues/392),OPEN | 代码已实现,仍需发布后二次验收才可关闭 | +| Rule A/B | [#396](https://github.com/mcpp-community/mcpp/issues/396),OPEN | 代码已实现,仍需发布后二次验收才可关闭 | +| mcpp-index issue | [#196](https://github.com/mcpplibs/mcpp-index/issues/196),CLOSED | 修复不可变 `mcpplibs.capi.lua@0.0.3` 的裸 Lua 依赖 | +| mcpp-index PR | [#197](https://github.com/mcpplibs/mcpp-index/pull/197),MERGED | Form-B bridge 精确声明 `compat.lua@5.4.7`,10/10 CI 通过 | +| xlings issue | [#520](https://github.com/openxlings/xlings/issues/520),OPEN | 跟踪 xlings 裸兼容包声明 | +| xlings Draft PR | [#521](https://github.com/openxlings/xlings/pull/521),Draft | 精确声明 `compat.ftxui`、`compat.gtest` 并刷新 lock,8/8 CI 通过 | + +mcpp 分支为 `feat/template-runtime-graphics-aur`,基线为 +`80291ca01a982c1e8c00e43bfa97ffe68516e6d7`。核心实现和原生诊断 checkpoint 是 +`ed4cf64279a5da94c736742338e5b01d90e297b8`;随后增加中文交接、验证账本、公共发布身份, +以及不泄露本地路径的 exact-index 路由诊断。本文更新时远端 HEAD 为 +`9a47ccf5727a2756451812da2c98e18e1f034409`。相对基线共有 31 个可追溯 commit,改动 +111 个文件,约 15,049 行新增、1,692 行删除。接手时仍须重新比较本地与远端 HEAD, +不要把本文快照当成可变分支的永久 HEAD。 + +设计和验证文档按以下顺序阅读: + +1. `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md` +2. `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md` +3. `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md` +4. `.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md` +5. 本文 + +## 3. 已完成的实现 + +### 3.1 包身份和模板语法 + +- 新增共享 `PackageSelector`,统一解析包依赖、`mcpp add`、xpkg 和模板 provider。 +- 裸名称只规范化为 `(mcpplibs, name)`,不再按短名称跨命名空间猜测。 +- 显式点分命名空间保持精确,例如 `mcpplibs.capi.lua`、`compat.ftxui`。 +- `mcpp add` 与 `mcpp new` 使用同一身份风格。 +- 模板语法固定为 `[ns.]name[@version][:tname]`;先解析 `:`,再解析 `@`。 +- `version` 省略时选择最新稳定版本;不会把 prerelease 当默认,也不接受模糊 + `latest` 别名。 +- `tname` 省略时:一个模板自动默认;多个模板必须有且仅有一个显式 default, + 否则给出确定性错误。 +- 旧 `pkg:` 列表写法仅保留一个迁移周期并打印可复制的新命令提示。 +- exact miss 会打印实际尝试的 canonical selector,并只把其它命名空间作为 + “did you mean” 诊断,不自动回退。 + +### 3.2 `mcpp new` 事务和跨平台安全 + +- 在访问配置、索引或网络前验证项目名称。 +- 拒绝绝对路径、路径分隔符、`.`/`..`、控制字符、Windows device basename、 + 尾随点/空格以及旧 `PROJECT` 占位名等不安全输入。 +- 模板变量改为单次渲染,插入值中的 `{{...}}` 不会被二次扫描。 +- 未知变量、未闭合 token、目录逃逸、symlink、类型冲突、读取/写入/复制失败都会 + 明确失败。 +- 在目标同级 staging 目录完整生成、关闭并同步文件、重新解析 manifest 后,才用 + no-replace rename 提交;失败不留下半成品目标或 staging 残留。 +- Linux、macOS、Windows 的独占提交分别放在平台实现中,不把平台 API 混入通用层。 + +### 3.3 根本地运行时和 SubOS + +- 引入纯 `RuntimeSelection`:只有 `McppDefault` 与 `NamedSubos` 两种状态。 +- 只有根项目/workspace 根的 `[xlings].subos` 生效;没有 CLI override。 +- workspace 成员独立运行时可以有自己的根选择;作为 workspace 成员或依赖被消费时, + 不传播自己的 SubOS。 +- 构建、运行、测试和快速路径共享一次解析出的不可变 `RuntimeBinding`。 +- binding 保存 owner、provider、runtime id、环境、路径和 contract hash;cache key + 包含该 hash,运行时配置变化会正确失效。 +- 旧 `MCPP_SUBOS_DIR` 不再改变选择结果。 +- xlings 环境的 `set`/`prepend` 语义保留调用方已存在变量并去重,不把显式空值误当缺失。 + +### 3.4 Linux 运行时物理约束(#392/#396) + +- 精确解析并查找 `glibc@`,不会从多个 payload 中取“第一个看起来能用的”。 +- 内部 ELF64-LE 读取器解析 `PT_INTERP`、`DT_RPATH/RUNPATH`、`DT_NEEDED`、 + GNU verneed/verdef,不调用外部 shell probe。 +- Rule A:host DSO 的 `GLIBC_*` 需求不得高于选中 libc 的 export floor。 +- Rule B:最终解释器、直接/传递 libc 必须与同一个 RuntimeBinding 一致,拒绝 host/private + 或两个 private libc 混用。 +- post-link verdict 与 artifact stat、RuntimeBinding contract 一起缓存;缓存 mismatch + 仍然保持失败,不能靠下一次运行重新探测变绿。 +- `mcpp doctor` 读取构建时已经保存的 verdict,不重新观察另一个当前 host。 +- 非 Linux 平台只保留 typed no-op 边界,不执行 ELF/glibc 规则。 + +### 3.5 图形栈和 provider provenance + +- mcpp 只接受结构化 `RuntimeRequirement`、`RuntimeProvider`、`RuntimeArtifact` 和 + `LinkIntent`。 +- 每条事实携带 canonical namespace/name/version/source/provenance;同名 provider + 不会相互覆盖。 +- xlings 选中的 runtime facts 优先于 descriptor fallback,并以完整身份排序,保证 + contract hash 与输入顺序无关。 +- link-time search 与 runtime search 分离:runtime dirs 不再错误进入 `-L`;ELF、Mach-O、 + PE 分别渲染自己的 link intent。 +- `resolution.json` schema 2 保存 binding、requirements、providers、artifacts、link intent + 和 post-link verdict。 +- `mcpp why runtime` 只读取保存结果,不启动探测命令。 +- 增加源码边界测试,防止 mcpp 后续引入 GPU、vendor、driver、ICD 探测分支;需要重新诊断 + host/provider 时,提示用户使用 `xlings doctor`。 + +### 3.6 release desired state + +- release workflow 新增不可变 `mcpp-release.json` schema 1。 +- manifest 由最终公开 GitHub release inventory 和实际下载 payload 重新计算,不信任上传 + 顺序或 sidecar 文本。 +- 四个现有平台是最低完整集合,未来符合命名契约的平台也必须进入 manifest,不能静默遗漏。 +- manifest 只允许首次创建;同名且字节不同会拒绝,rerun 只能验证相同内容。 +- `publish-ecosystem` 必须等待 manifest job 完成。 +- 版本已经准备为 `2026.8.9.1`;当前 mcpp 内部 xlings pin 为已发布的 + `2026.8.9.2`,待 #521 合并并发布后还需要更新到新 xlings 版本。 + +### 3.7 `mcpp-bin` AUR reconciler + +- 只管理 `mcpp-bin`;`mcpp-m` 和 `mcpp-git` 不在 reconcile verdict 或 push 范围。 +- 支持 release event、定时恢复和手动触发。 +- 选择最新完整稳定 release,验证 `mcpp-release.json`、两个 Linux payload 和 sidecar, + 再生成 `.SRCINFO`。 +- 使用 Arch `vercmp`,拒绝倒退;相同版本可检测并修复 drift。 +- clone、认证、AUR maintenance、RPC lag 都有明确分类和有界重试。 +- 只允许 fast-forward push,不提供 force、空仓库首次发布或旧 run 重跑旁路。 +- 已对公开 `v2026.8.8.4` 做真实 Arch `makepkg --printsrcinfo`/ + `--verifysource` dry-run;目标应为 `2026.8.8.4-1`,公开 AUR 观测仍是 + `2026.8.1.1-1`,本轮没有发布。 + +### 3.8 文档和用户契约 + +- 同步 README、中文 README、getting-started、manifest、toolchain、identity、AUR、release + 和 changelog。 +- 明确无 `--variant`、无 CLI SubOS override、SubOS 非传递、mcpp 不探测 GPU、AUR 只管理 + `mcpp-bin`。 +- `.agents/docs` 中保留详细设计、拆分计划和 RED/GREEN/CI/release 验证账本。 +- 已清理文档中的本地绝对路径和旧 squash/admin-bypass 描述;当前流程是 Draft、逐 commit、 + 用户明确 review 后普通合并。 + +## 4. Commit 追踪 + +以下顺序就是实现历史;不要 rebase、amend、squash 或 force-push: + +| Commit | 内容 | +|---|---| +| `52706c9` | 写入聚焦设计、实施计划和初始验证账本 | +| `527b26e` | 共享精确 PackageSelector,收口包身份 | +| `93db300` | 精确 TemplateSpec、版本和默认模板选择 | +| `dce8619` | 事务式、跨平台安全的项目脚手架 | +| `ac0670f` | 根本地 RuntimeSelection/RuntimeBinding | +| `7850702` | Linux ELF/runtime closure Rule A/B | +| `6904673` | provider-neutral runtime provenance 与 LinkIntent | +| `234a4df` | 不可变 release manifest | +| `08857c7` | `mcpp-bin` AUR desired-state reconciler | +| `dae4384` | 真实 SubOS view 与 RuntimeBinding 对齐 | +| `76fee4e` | 准备 `2026.8.9.1` 版本和 pin/docs | +| `04a348e` | E2E capability 正确识别 Python | +| `0108ee1` | ELF parser fixture 只在 Linux 执行 | +| `e6050b7` | 依赖精确继承 index 声明命名空间 | +| `d008a21` | 依赖选择保留 index owner identity | +| `c569152` | runtime closure 正确处理 ELF SONAME 复用 | +| `679c7ab` | E2E fixture 与精确身份契约对齐 | +| `65fc77b` | fake xlings fixture 提供真实 runtime contract | +| `e3b93ac` | exact miss 保留 version-floor 根因 | +| `1ef3112` | malformed exact descriptor 变为明确错误 | +| `4d8d080` | module mangling 使用实际 authored modules | +| `12b0b95` | macOS BMI E2E 只检查自身稳定 edge | +| `92caaf9` | Windows scaffold 测试清理前关闭文件流 | +| `4e39a8e` | Draft PR 流程和文档隐私清理 | +| `0cc6a2a` | libc poison fixture 尊重隔离 `MCPP_HOME` | +| `ed4cf64` | workspace 继承相对 index 时使用 lexical root anchor | +| `9f6161a` | 新增完整中文 PR 交接文档 | +| `6e42d6c` | 同步 Arch、CI 和跨仓库验证账本 | +| `cf39cb2` | AUR 使用 `speak-agent` 公共 noreply 身份,并增加隐私回归 | +| `189c6d1` | 记录隐私安全 AUR checkpoint 和 12/12 契约结果 | +| `9a47ccf` | exact miss 输出隐私安全的 index route 状态,供 Windows 原生定位 | + +每个逻辑 commit push 后都在 PR #400 留有 checkpoint。issue #380、#392、#396 也已经 +收到“实现已进入 Draft、但发布前不关闭”的状态评论。 + +## 5. 遇到的问题、根因和处理 + +### 5.1 精确身份暴露 xlings 的裸依赖 + +症状:Linux、macOS、Windows 和 aarch64 中“使用新 mcpp 构建 xlings”的任务报错: +`ftxui` 被严格解释为 `(mcpplibs, ftxui)`,而索引中的真实包是 `compat.ftxui`。 + +这不是应该在 mcpp 中恢复 fallback 的理由;恢复 fallback 会破坏用户确认的命名空间 +语义。处理方式分两层: + +1. mcpp-index #197 已把不可变 `mcpplibs.capi.lua@0.0.3` 的裸 Lua 依赖用 + index-side Form-B bridge 精确改为 `compat.lua@5.4.7`,已经正常合并。 +2. xlings #521 把根依赖改为 `compat.ftxui`、测试依赖改为 `compat.gtest`,并刷新 lock。 + 该 PR 仍是 Draft,8/8 CI 通过,不能 admin/bypass 合并。 + +### 5.2 RuntimeBinding 最初与真实 SubOS view 不一致 + +初版 binding 只信任声明 identity;真实安装中 loader/libc view 可能经过 xlings 重定位或 +版本视图转换,导致“声明 glibc”与实际闭包不一致。修复后,binding 从选中的 SubOS view +获取 canonical payload,并保留声明/provider provenance;缺失、过期或混合闭包都明确失败。 + +### 5.3 原有测试含 Linux 特有假设 + +- ELF parser synthetic fixture 在非 Linux 也执行:改为 typed platform skip。 +- Python E2E capability 通过错误方式推断:改为真实可执行能力判断。 +- fake xlings 只有元数据、没有 loader/libc 物理事实:补足真实 runtime contract,未增加产品 + fallback。 + +### 5.4 macOS 增量构建断言过宽 + +E2E #170 原先要求第三次构建“整个图没有任何工作”。macOS 合法重建生成的 iOS init/link +edge,但 stage/main BMI 已经稳定。测试现在只约束它负责验证的 stage/main edge,避免把 +平台合法工作误判成 BMI 回归。最新 macOS ARM64 E2E 已通过。 + +### 5.5 Windows 清理和 workspace path identity + +- 单元 fixture 在 `remove_all` 前仍持有两个 `ifstream`;Linux 允许删除打开文件,Windows + 拒绝。修复为在清理前结束 stream lifetime。 +- workspace 根 `[indices] acme = { path = "index" }` 在 Windows 上经 + `weakly_canonical` 后可能因为 short-name/case alias 与成员视图失去字符串身份。现在使用 + `(workspace_root / relative_path).lexically_normal()`,既把相对 index 锚定到根,又不改变 + 用户声明的 lexical identity。新增 focused unit,相关本地 E2E #12 通过;但 + `ed4cf64` 的原生 Windows E2E 2/2 仍在 workspace member 的 `mcpp add + acme.util@2.0.0` 失败,因此该提交不是完整修复。 +- 失败 job 为 run `31321961040` / job `93266267511`;错误只有 `package 'acme.util' + not found in any configured index` 和 `tried: (acme, util)`。同一原生 Windows 矩阵的 + `PmIndexRoute.WorkspaceMemberReadsRootAnchoredRelativeIndex` 已执行并通过,说明纯单元 fixture + 没有覆盖真实进程/工作目录视图。 +- 已下载该 job 构建的 Windows artifact,并在隔离 Wine 环境中同时用手工 member 和 + `mcpp new m1` 重放;两条 `mcpp add acme.util@2.0.0` 都通过,所以不能把 Wine 结果外推为 + GitHub Windows 已修复。 +- `9a47ccf` 增加永久、隐私安全的 `route:` 诊断,只报告 local index 的 root/`pkgs` + present/absent,不输出盘符、用户目录或绝对路径。下一轮原生日志将区分 workspace + inheritance、路径锚定和 descriptor read 三层;拿到证据前不猜测修复。 + +### 5.6 隔离 HOME fixture 使用错误根 + +E2E #156 用 `$HOME/.mcpp` 注入旧 libc,但测试实际选择了 `MCPP_HOME`。因此它先污染了当前 +mcpp,而没有进入目标 nested-tool regression。fixture 现在使用 +`${MCPP_HOME:-$HOME/.mcpp}` 对应的状态根,精确重跑通过。 + +### 5.7 本地无法替代原生 Windows 验证 + +曾尝试用隔离环境的 MinGW cross toolchain 构建当前 mcpp,以更快复现 Windows 行为;构建 +停在现有 GCC module 与 `windows.h` language-linkage 冲突,未修改产品代码,也未污染 host。 +因此 Windows 结论只接受 GitHub 原生 runner 终态,不从 Linux source review 或 cross-build +推断。 + +### 5.8 AUR 外部状态不稳定 + +第一次真实 Arch 容器拉取受到 Docker Hub registry header timeout,按 transient 记录,未 +伪装成通过。随后真实 Arch dry-run 已完成并验证两个 Linux 源。公开 AUR 仍落后,之前的 +发布任务遇到 AUR SSH maintenance;本轮没有绕过、force-push 或手工覆盖远端。 + +## 6. 已有验证证据 + +### 6.1 本地代码和 E2E + +- 最新源码完整 C++ unit/integration:`72/72`,0 fail。 +- 一轮隔离 Linux 全量 E2E:186 pass、1 fail、19 个平台/能力 skip。唯一失败是 #156 的 + fixture 根错误,修复后精确重跑通过。 +- `ed4cf64` 后精确重跑 #12 和 #156 通过,完整 72/72 unit 再次通过。 +- `9a47ccf` 按 TDD 先得到缺少 `IndexRoute::describe` 的 RED;实现后 focused + `PmIndexRoute` 12/12、真实 E2E #12 通过,且诊断不包含本机路径。该提交尚未重新宣称 + 最新源码全量 unit/E2E 均通过。 +- 重点 E2E 已覆盖 package/template、事务 scaffold、RuntimeBinding、root-local SubOS、 + Rule A/B、provider provenance、stored `why runtime`、AUR/release 脚本。 +- release manifest contract:12/12。 +- AUR state-machine/contract:12/12,包括公共 `speak-agent` 发布身份契约。 +- 所有变更 shell 通过 `bash -n`,Python generator/tests 通过 bytecode compile,workflow YAML + 可解析,`git diff --check` 通过。 +- 公开 `v2026.8.8.4` 四平台 payload/sidecar replay 生成的 manifest 字节稳定;manifest + SHA256 为 `6614ba2db65c8c28cb5a9d3466bd6cf3007634221da76d25216785061c647edb`。 + +不要把上述“一轮全量后精确修复”写成“最新 HEAD 已重新全量 206 个 E2E 全绿”。最终完整 +结论仍需要 latest-head CI 和发布后二次验证。 + +### 6.2 边界未被污染 + +- `scripts/aur/mcpp-m/**` 聚合 SHA256 在实现前后都为 + `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`。 +- host xlings 配置聚合 SHA256 在状态化测试前后都为 + `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`。 +- 原始 checkout 的用户改动未被暂存、覆盖或清理;实施在隔离 worktree 中进行。 +- PR diff 已扫描常见 Linux/macOS/Windows home path、runner 用户名和临时目录模式,未发现 + 本地路径泄露。 + +### 6.3 GitHub CI 快照 + +以下是 `ed4cf64` 的终态快照;共 12 success、5 failure、1 cancelled。cancelled 不是 pass: + +| 状态 | Job | +|---|---| +| PASS | hermetic Linux E2E | +| PASS | macOS ARM64 E2E | +| PASS | musl + LLVM toolchain | +| PASS | MinGW Linux→Windows build + Wine run | +| PASS | Windows→Linux cross-build及 Linux artifact run | +| PASS | Linux unit、GCC cold toolchain、Linux E2E 1/2 和 2/2 | +| PASS | Windows build/unit/package、Windows E2E 1/2 | +| FAIL(已知外部边界) | Linux 构建/运行 xlings | +| FAIL(已知外部边界) | macOS xlings LLVM E2E | +| FAIL(已知外部边界) | aarch64 mcpp + xlings | +| FAIL(已知外部边界) | Windows toolchains + regressions 中的 xlings 构建 | +| FAIL(mcpp 内部,仍在定位) | Windows E2E 2/2 的 workspace root local index | +| CANCELLED | bare Windows/no-Visual-Studio;被后续 push 的 concurrency 取消,不作结论 | + +四个已知失败都发生在构建当前 xlings source 时的裸 `ftxui` exact miss;对应 #521, +不能在 mcpp 中用 fallback 掩盖。Windows E2E 2/2 是独立的 mcpp 问题,不能归入 #521; +`9a47ccf` 的 latest-head 原生矩阵需先给出 route 状态,再做下一笔最小修复。 + +## 7. 尚未完成 + +以下项目必须明确交接,不能因为“代码已经很多”而省略: + +1. **等待 `9a47ccf` 的 mcpp latest-head CI 证据。** 特别读取 Windows E2E 2/2 的 + `route:` 行;`ed4cf64` 已经由原生 runner 证明没有完整解决根相对 index。 +2. **用户 review xlings #521。** 8/8 CI 已通过,但仍为 Draft/REVIEW_REQUIRED。 +3. **普通合并 xlings #521。** 不使用 admin、bypass、squash、force 或历史改写。 +4. **发布新的 xlings 稳定版本。** 当前 `2026.8.9.2` 不含 #521;需要正常 version bump、 + tag、全平台 release CI 和资产核验。 +5. **更新 mcpp #400 的 xlings pin。** pin 到包含 #521 的新稳定版本,作为独立 commit、push + 和 checkpoint;然后重新跑 latest-head mcpp 全矩阵。 +6. **补最终交付账本。** 更新 `.agents/docs/...validation.md` 中过时的 CI、Arch 和跨仓库 + “not started” 段落,记录终态 job/run、PR/merge commit、release asset/checksum。 +7. **用户 review mcpp #400。** 只有用户明确同意后才能从 Draft 转 ready,并走普通合并。 +8. **发布 mcpp `v2026.8.9.1`。** 验证 tag、版本字符串、所有平台资产、sidecar、最终公开 + inventory 和不可变 `mcpp-release.json`;running/cancelled/superseded 不算通过。 +9. **GitCode/生态镜像。** 按发布边界补 GitCode resource,验证 ranged/full download 和 hash; + 必要时更新 xim-pkgindex,但不把 provider policy 搬进 mcpp。 +10. **AUR `mcpp-bin` 收敛。** 从合并后的固定 main/release 状态运行 reconciler,等待 AUR + maintenance 恢复,验证 RPC 可见、`.SRCINFO`、两个架构 checksum 和 fresh install。 +11. **全生态 fresh-home 验证。** 在隔离 HOME/XLINGS_HOME/SubOS root 中验证:安装、 + `[ns.]name[@version][:tname]` 模板、唯一默认模板、多个 SubOS/glibc、build/run/test、 + OpenGL/Vulkan provider provenance 的 PASS/FAIL/NOT_EXERCISED 表达。 +12. **issue 收口。** #398 随 PR 合并关闭;#380/#392/#396 只在发布后二次验收通过后关闭, + 并留言精确 release/测试证据。残留工作统一写回 #397。 +13. **原始 issue/PR 全量整理未完成。** C1-C9 和其它开放 PR/issue 的最终关闭/合并仍需按 + #397 清单继续;特殊保留 #43、#260 以及标记为保留/Draft/do-not-merge 的项不要动。 + +## 8. 推荐接手顺序 + +1. 确认 `gh api user --jq .login` 返回 `speak-agent`。 +2. 检查 PR #400 本地 HEAD、远端 HEAD 和 clean worktree;不要在原始用户 checkout 上工作。 +3. 等 `9a47ccf` 的 Windows E2E 2/2 输出隐私安全的 `route:` 状态;只按原生证据修复。 + 四个裸 `ftxui` 失败归入 #521,其余失败必须单独 RED/根因/修复/commit/checkpoint。 +4. 把最新终态快照评论到 #400;不要重复 rerun 已被新 HEAD 取代的旧 workflow。 +5. 请用户 review #521;得到明确同意后,转 ready 并普通合并,随后按 xlings release skill + 正常发版。 +6. mcpp 独立 commit 更新新 xlings pin,push 后评论 checkpoint,再等待新的 latest-head + 全矩阵。 +7. 所有代码和依赖 gate 通过后,独立 commit 更新验证账本;做 diff/privacy/mcpp-m/host-config + 边界复核并 push/comment。 +8. 把本文“尚未完成”逐项清零,再请求用户 review #400;没有 review 不转 ready。 +9. 普通合并、release、GitCode、AUR、fresh-home 生态验证完成后,再按证据关闭 issue。 + +## 9. 每个后续改动的强制流程 + +每个逻辑变化都执行同一套流程: + +1. 先写或选中能复现问题的 focused test,记录 RED; +2. 做最小实现,记录 GREEN; +3. 跑相关回归和 `git diff --check`; +4. 扫描 diff,确保没有本地用户名、绝对路径、临时目录、个人邮箱或凭据; +5. 只暂存明确文件,不使用宽泛 `git add -A`; +6. 使用 `speak-agent` 的 GitHub noreply commit identity; +7. 一个逻辑变更一个 commit,立即 push 到 Draft PR; +8. 立即在 PR 留中文 checkpoint:问题、根因、改动、测试、当前阻塞; +9. 不 amend/rebase/squash/force-push,不使用 admin/bypass; +10. pending、skipped、cancelled、superseded 任务绝不记为 PASS。 + +## 10. 不应改变的产品边界 + +- 不恢复跨命名空间短名称 fallback。 +- 不增加 `--variant`。 +- 暂不增加 CLI SubOS override。 +- 不把 SubOS 作为库的传递依赖要求。 +- 不让 mcpp 探测 GPU、Mesa、NVIDIA、WSL、ICD 或驱动来源。 +- 不把 xlings/xim provider policy 复制进 mcpp-index 或 mcpp。 +- 不修改或发布 `mcpp-m`。 +- 不碰明确特殊保留的 issue/PR。 +- 不在用户未 review 时把 Draft 转 ready 或合并。 + +## 11. 当前可供 review 的核心点 + +用户 review 时建议优先确认以下五点: + +1. `ns` 省略严格等于 `mcpplibs` 是否符合预期,包括 exact miss 不 fallback。 +2. 唯一模板自动默认、多模板显式 default/tname 的错误边界是否足够直观。 +3. RuntimeBinding 是否真正只由根/workspace 根选择,成员和依赖完全不传播 SubOS。 +4. 图形栈是否保持“xlings/xim 诊断和选择,mcpp-index 描述关系,mcpp 只构建”的责任分层。 +5. `mcpp-bin` reconciler 是否满足幂等、单调、checksum、fast-forward-only,同时完全隔离 + `mcpp-m`。 + +在这五点确认前,PR #400 和 #521 都应保持 Draft。 diff --git a/.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md b/.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md new file mode 100644 index 00000000..01cd8c34 --- /dev/null +++ b/.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md @@ -0,0 +1,985 @@ +# xlings × mcpp 生态契约收敛与优化设计 + +> 状态:Review Draft +> +> 日期:2026-08-09 +> +> 基线:mcpp main@80291ca01a98;xlings 最新发布 2026.8.9.2;mcpp 发布包当前内置 xlings 2026.8.8.1。#397 的跨仓证据快照为 mcpp-index b86fc7c、xim-pkgindex c0aded29;2026-08-09 复核的远端 main 分别为 b86fc7ce802938,设计不依赖本地旧 checkout。 +> +> 范围:设计与迁移方案,不代表本文所列实现已经完成。 +> +> 关联问题:[#397(C1–C9 汇总)](https://github.com/mcpp-community/mcpp/issues/397)、[#396](https://github.com/mcpp-community/mcpp/issues/396)、[#392](https://github.com/mcpp-community/mcpp/issues/392)、[#380](https://github.com/mcpp-community/mcpp/issues/380)。 + +## 1. 结论先行 + +这些问题不是九个独立 bug,也不是“再补一轮 if”能稳定解决的问题。它们共同暴露了五类边界不清: + +1. **身份边界不清**:namespace:name 在 CLI、模板、索引、渲染器之间被拆掉或退化为裸 name。 +2. **状态边界不清**:构建缓存只记“输入看起来没变”,却没有声明一次成功必须留下哪些输出与证明。 +3. **运行时边界不清**:xlings、SubOS、索引和 mcpp 都能推断或改写运行时环境,却没有统一、可持久化、可验证的运行时契约。 +4. **策略边界不清**:用户构建时的物理可运行性、索引仓库的生态闭包策略、GPU/宿主探测混在同一层。 +5. **发布边界不清**:GitHub Release 是事件源,AUR/Homebrew 等下游却按“一次事件必成功”实现,缺少最终一致性对账。 + +建议采用 **分层、类型化、可验证契约**,而不是把 xlings 扩成一个全知守护进程: + +- **xlings** 是环境与运行时 substrate:安装、版本选择、payload provenance、SubOS、RuntimeBinding、环境操作语义。 +- **xim-pkgindex** 是系统级 payload 与宿主桥接策略:glibc、Mesa、NVIDIA/WSL sentinel、图形运行时闭包。 +- **mcpp-index** 是 C/C++ 包、模板和链接元数据:模块、头文件、link plan、GUI 模板与平台依赖声明。 +- **mcpp** 是项目规划器与产物验证器:构建图、缓存后置条件、模板事务、实际二进制物理检查、可解释诊断。 +- **发布流水线** 是投影对账器:以不可变 Release Manifest 为源,将 AUR 等渠道收敛到期望状态。 + +推荐方案的核心不是“增加更多配置”,而是减少重复推断:每个事实只有一个所有者,其他组件消费版本化契约。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 将 C1–C9、#380、#392、#396 收敛到少量可复用的根契约。 +- 让 mcpp newmcpp add、索引和生成后的清单使用同一套包身份。 +- 让构建“成功”同时意味着必要产物、元数据和运行时证明完整。 +- 让私有 glibc、宿主库、图形运行时的来源和兼容性可计算、可解释、可缓存。 +- 支持 Linux、macOS、Windows 的真实平台差异,不制造虚假对称。 +- 让发布渠道从一次性推送转成幂等对账,外部服务恢复后可自动收敛。 +- 保持无变化构建快速,不在构建热路径启动 xlings 或访问网络。 + +### 2.2 非目标 + +- 不在 mcpp 中实现 GPU 驱动管理。 +- 不要求每个 GUI 测试都在普通共享 runner 上打开真实窗口。 +- 不用 xlings daemon 替代 mcpp 的项目图规划。 +- 不把索引闭包策略变成用户本地每次构建的强制联网检查。 +- 不以一个跨四仓库的巨型 PR 交付。 +- 本文不直接关闭 issues、重跑 AUR 工作流或发布新版本。 + +## 3. 当前事实与根因 + +### 3.1 C1–C9 不是同一优先级,但可以归并 + +| 编号 | 当前现象 | 直接根因 | 应归入的长期契约 | 优先级 | +|---|---|---|---|---| +| C1 | fast path 在 compile_commands.json 等配置产物缺失时仍可直接进入 Ninja,编辑器反复请求;普通 executable 缺失通常仍由 Ninja 重建 | 缓存没有声明配置成功的后置条件 | BuildSnapshot + OutputManifest | P1 | +| C2 | 注释中的 R"(、普通字符串中的注释记号会污染模块扫描状态 | 先做脆弱的行级 strip,再解析 token | 线性词法 masker | P1 | +| C3 | TTY 下 --no-color 无效 | color 状态延迟初始化覆盖显式参数 | 一次初始化的 UiPolicy | P2 | +| C4 | namespaced 包 exports 非 strict 时漏检,strict 时会把合法 exports 误报 missing | qualified owner 与短 manifest name 做字符串比较 | PackageId 类型贯穿 | P1 | +| C5 | Windows 版本探测含 POSIX 2>/dev/null | 命令以 shell 字符串表达 | CommandSpec + direct exec | P1 | +| C6 | 部分安装形态下 release 内置 xlings 不进入候选更新链 | 候选只按位置推断,没有 provenance/capability | XlingsCandidate 选择策略 | P1/P2 | +| C7 | xlings 端已按 presence 语义修复,但 mcpp 仍把 op=set 无条件覆盖 | reader 与当前 wire 语义漂移,且缺少显式 replace | EnvOp schema v2 | P1 | +| C8 | 两段无版本安装循环在进入 fetch 前即失败并吞掉错误;当前用户可达影响尚未证明 | 多个安装 owner、错误被忽略 | 单一 solver/installer owner | P2 | +| C9 | 路径、源输出、glob、脚手架、链接、版本、canary、CI、机器输出等 11 个长期项 | 同一事实多处生产、字符串协议与 fail-open | 分别映射到下表所列基础契约 | P1–P3 | + +C9 的 11 项应明确归属,而不是留作“杂项”: + +| C9 项 | 归属 | +|---|---| +| Windows 路径与 action 规范化(#393) | typed Path + CommandSpec | +| generated source output 被静默排除(#393) | OutputManifest + action postcondition | +| 默认 source glob 漂移(#386) | 单一 SourceSet producer | +| 脚手架安全与卡死(#380) | NameAtom + 单次渲染 + 事务目录 | +| runtime link order / macOS runtime dir(#304) | LinkPlan token + 平台 capability | +| prerelease 依赖解析(#370) | 单一 VersionReq parser | +| cfg version 被忽略(#290) | 封闭条件词汇;未知条件 hard error | +| operator-template canary 只 precompile、不 import(#256) | 真实 importer + crash/unsupported/fail 三态 | +| cppfly resolver 后续候选不可达、canary 可自我跳过(#215) | 遍历全部候选 + fail-closed capability gate | +| fresh install 被缓存掩盖(#259) | 冷 HOME 独立 gate | +| machine output 不一致(#379) | UiPolicy + WireEnvelope | + +其中 #386 的“四项 fallback glob 与七项 canonical 默认不一致”是已证实的代码漂移,但正常 xpkg 装载路径已补默认并要求 sources 非空;当前用户可达性尚未建立,应作为 P3 清理而不是阻塞前两阶段。 + +### 3.2 #380:输入验证只是第一层,真正缺的是脚手架事务 + +当前 mcpp new 可接受路径穿越、控制字符和会重新引入占位符的名称;builtin renderer 对含 PROJECT 的替换值可静态证明无限循环,package-template renderer 虽推进游标,不会以同一种方式无限循环,但仍会发生占位符相互消费。两条路径都有 I/O 错误被忽略和半成品风险。修复不能只加一个正则: + +- 项目逻辑名必须是一个 NameAtom,与目标目录 --dir 分离。 +- 新项目默认执行 portable policy:拒绝绝对路径、./..、任一平台路径分隔符、NUL/控制字符、Windows 保留设备名及尾随点/空格;已有 legacy manifest 继续可读,不借此批量改写用户身份。 +- namespace 是结构化字段,不允许塞回项目名或模板字符串。 +- 渲染必须单次完成;未知占位符、重复键、非法 TOML/C++ 标识均 hard error。 +- 先写同父目录临时目录,验证生成清单与必需文件,再原子 rename。 +- 任一步失败清理临时目录;目标已存在默认不覆盖。 + +### 3.3 #392 与 #396:问题是“实际装载物理”,不是版本号猜测 + +#392 展示了私有 glibc 与宿主 libtinfo/Mesa 混装时的两个失败方向: + +- 私有 loader/libc 较旧,宿主库要求更高 GLIBC symbol version。 +- 通过全局 LD_LIBRARY_PATH 暴露私有 glibc,又让外部宿主命令加载到错误 libc/私有符号。 + +当前 main 已有隔离私有 glibc 环境的局部缓解,因此不能表述成“完全未修”。但仅靠目录过滤仍不能证明最终 ELF 的实际闭包可运行。 + +另一个独立的不确定性是 post-install 的 sandbox glibc 查找:注释声称选择 newest,代码实际返回 directory_iterator 的第一个命中;该顺序未规定,不能描述为“字典序第一”。目标设计必须按完整 PackageId/version/RuntimeBinding 选择,不从目录枚举顺序推断。 + +#396 中应保留两条物理不变量,但修正 Rule A 的计算方式: + +- **Rule B — 单一 RuntimeBinding**:实际 PT_INTERP 与最终解析到的 libc 必须来自同一个 payload/runtime binding。 +- **Rule A — symbol ceiling**:对每一个实际借用的宿主 ELF,对其 .gnu.version_r 所需的最高 GLIBC 版本逐对象检查,不用粗糙的“私有 glibc 版本 ≥ host glibc 版本”替代。 + +生态策略另行定义: + +- **Policy D — 生态闭包**:索引包的运行时依赖应由生态包闭包满足;宿主对象只允许出现在显式、类型化的 host-link sentinel 后。 +- D 在索引 CI/安装物化时强制。用户构建的最终目标是对**已证明**的 A/B 冲突 hard fail,并清晰展示 provenance;在精确闭包解析器积累真机证据前,按 observe → warning → strict opt-in → 默认 strict 分阶段,不能拿粗略的 host glibc 代理在第一天全局 hard fail。 + +这一区分很重要:A/B 是物理事实,D 是仓库治理策略。 + +### 3.4 模板 namespace:现有语法先天歧义 + +当前 --template 解析 pkg@ver:template,第一个冒号被当作 template 分隔符。因此 ocornut:imgui 会被理解为 package=ocornut、template=imgui,无法表达规范身份。scaffold resolver 又只尝试空 namespace 与 compat,没有复用依赖路径的 IndexRoute;裸 imgui 会先命中冻结的 mcpplibs:imgui,而不是新的 ocornut:imgui,也不会报告跨 namespace 候选歧义。 + +同时,模板获取阶段即使从 descriptor 得到 namespace,返回值和渲染变量仍只保留裸 name;依赖注入也用字符串搜索和短名。这会重新引入此前包索引已经修复的同名碰撞。 + +正确方向是复用 mcpp add 已有身份语法,而不是再发明第三套压缩分隔符: + +~~~text +mcpp new app --namespace acme \ + --template ocornut:imgui@1.92.8 \ + --variant glfw-opengl3 \ + --index official + +mcpp new app --template mcpplibs:templates@0.0.1 +mcpp new --list-templates ocornut:imgui@1.92.8 +~~~ + +- --template:规范 PackageRef,即 namespace:name@version。 +- --variant:该 provider 内的模板变体。 +- --namespace:新项目自身 namespace。 +- --dir:文件系统目的地。 +- --index:路由来源;index alias/path/URL 不属于 PackageId。 +- 旧 pkg@ver:template 只作为 deprecated ingress,解析后立即规范化并告警。 +- 对 pkg:word,新语义“namespace:name”与旧语义“pkg 的 variant”可能同时成立:只在一侧候选唯一时兼容;两侧都存在或无法排除时返回稳定的 ambiguity error,并给出可复制的显式 --template/--variant 写法。 +- builtin bin 可保留兼容 alias,但内部也使用明确的 builtin provider identity。 + +可选的紧凑糖 namespace:name@version#variant 没有冒号歧义,但建议在显式 flags 与 wire model 稳定后再决定;内部始终是 TemplateSelection,不让紧凑语法成为第三种身份。 + +### 3.5 图形程序:当前已有正确积木,但责任仍有重复 + +当前生态已经包含: + +- mcpp-index 的 ocornut:imguicompat.glfwcompat.glx-runtime。 +- xim-pkgindex 的 xim:graphics、Mesa,以及 NVIDIA/WSL host-link sentinels。 +- Linux Mesa 的软件和多类硬件 backend;macOS native frameworks;Windows Win32/system SDK 路径。 + +问题在于 compat.glx-runtime 仍需要从 SubOS view 选择并 symlink GL 库到自己的 runtime 目录。这是过渡桥,而不是最终模型。最终应让 xlings 持久化可传递 runtime exports,mcpp 直接消费 resolved runtime contract;mcpp-index 只声明依赖,不复制 xlings 的视图布局。 + +Vulkan 还存在更明显的旁路:compat.vulkan-runtime 仍从 host 的 /lib*//usr/lib* 收集 ICD 和 transitive DSOs,测试主要覆盖 loader API,没有证明真实 ICD/device、来源或 GLIBC 闭包。GL 的收敛方案必须同时覆盖 Vulkan loader、ICD manifest 与 driver;否则只是把同类风险移到另一个 API。 + +### 3.6 AUR:外部维护是触发原因,缺少对账才是长期漂移原因 + +2026-08-09 的只读审计显示: + +- GitHub 最新 release 是 [v2026.8.8.4](https://github.com/mcpp-community/mcpp/releases/tag/v2026.8.8.4),AUR RPC 中 mcpp-binmcpp-m 均停在 2026.8.1.1-1,相差 20 个正式 release。 +- 最后成功的 aur-publish run 是 [30649165652](https://github.com/mcpp-community/mcpp/actions/runs/30649165652);从 [30718043364](https://github.com/mcpp-community/mcpp/actions/runs/30718043364) 起到 2026-08-08,共核对到 18 个同类失败、4 个因 release gate skipped;最新失败是 [31254088758](https://github.com/mcpp-community/mcpp/actions/runs/31254088758)。 +- 失败日志均已完成版本与 sha 刷新,随后在 AUR SSH 阶段收到 The AUR is down due to maintenance 并退出 128。 +- 当前 workflow 只有 release 完成事件与手工触发;没有 retry、schedule/reconcile、推送后验证。 + +还存在一个必须先修的独立 P0: + +- scripts/aur/update.sh:65 只匹配单行 sha256sums=('...')。 +- scripts/aur/mcpp-m/PKGBUILD:34-35 是两行数组,所以 source checksum 没有被更新。 +- workflow 生成的 .SRCINFO 使用新 checksum,而 PKGBUILD 仍保留旧 checksum,形成同一提交内部不一致。 +- 当前顺序先推 mcpp-bin 再推 mcpp-m;AUR 恢复后直接重跑可能先产生部分成功,再尝试推送坏的 mcpp-m 元数据。 + +因此不能把“重跑工作流”作为修复。应先增加本地结构化更新与一致性 gate,再恢复发布。 + +### 3.7 多视角评估 + +| 视角 | 当前风险 | 评价 | 目标变化 | +|---|---|---|---| +| 架构 | 高 | xlings、probe、post-install、build plan、run env 多次推导 runtime truth;scaffold 又绕开 canonical resolver | 单一 owner + versioned contract + typed snapshot | +| 稳定性 | 高 | fail-open、忽略 I/O/error_code、配置副作用未入 manifest;外部发布一次失败即永久漂移 | transaction/postcondition/reconciler | +| 兼容性 | 高 | GLIBC 风险取决于实际 DSO;namespace、EnvOp、runtime dirs 在读写端语义漂移 | exact artifact proof + schema negotiation | +| 优雅简洁 | 中高 | CLI 表面不算大,但内部靠短名、目录顺序、字符串命令和目录复制造成隐性复杂度 | 保持少量 CLI,内部用 PackageRef/CommandSpec/LinkIntent | +| 用户体验 | 高 | 成功可能留下半状态;错误只报 GLIBC/包不存在,无法说明来源;机器输出不稳定 | 原子操作、canonical ref、why provenance、稳定 envelope | +| 性能 | 中 | 现有 fast path 很快,但把完整性排除在“命中”之外;runtime/版本探测可能重复 | 本地 hash snapshot、按产物增量验证、无热路径 xlings/network | +| 多平台 | 高 | POSIX shell 片段泄漏到 Windows;Mach-O runtime dirs 与 Linux ELF 语义混用;硬件结论常由 headless/cross build 代替 | LinkIntent 平台 lowering + native cold-home/hardware evidence | + +这里的目标不是用“更严格”换“更慢”。类型化契约让昂贵解析只发生在安装、configure 或 changed artifact 上,hot no-op 只校验 fingerprint 与 required outputs。 + +## 4. 三种架构选择 + +| 方案 | 架构 | 稳定性 | 兼容性 | 简洁性 | 性能 | 多平台 | 结论 | +|---|---|---|---|---|---|---|---| +| A. 按 issue 打补丁 | 局部修改快,但同一事实继续多处推断 | 短期变绿,回归概率高 | 表面影响小,长期漂移大 | 初看简单,维护复杂 | 可维持当前热路径 | 平台分支继续散落 | 只用于 P0 止血 | +| B. xlings 全知 daemon | 所有解析/探测集中 | daemon 生命周期与状态成为新故障域 | 强耦合 xlings 版本 | 接口表面少,系统更重 | 构建热路径多进程/IPC | Windows/macOS 服务语义复杂 | 不采用 | +| C. 分层 typed contracts | 每个事实单一 owner,边界清楚 | 可校验、可缓存、可回滚 | 支持 schema/legacy ingress | 数据模型略增,重复逻辑显著减少 | 构建只读本地契约 | 原生 provider 表达差异 | **推荐** | + +方案 C 可以允许 P0 局部修复先落地,但所有 P0 修复都应朝目标契约收敛,不能制造第二套临时协议。 + +## 5. 目标架构 + +~~~mermaid +flowchart LR + XI[xlings
install / SubOS / RuntimeBinding] --> RC[Runtime & Install Contract] + XP[xim-pkgindex
system payload / host sentinel] --> XI + MP[mcpp-index
C++ package / template / LinkPlan] --> MC[mcpp
planner / builder / inspector] + RC --> MC + MC --> OM[BuildSnapshot / OutputManifest] + MC --> ELF[Artifact Physics Verdict] + GH[Immutable GitHub Release Manifest] --> REC[Channel Reconciler] + REC --> AUR[AUR mcpp-bin / mcpp-m] + REC --> OTHER[other package channels] +~~~ + +### 5.1 单一事实所有者 + +| 事实 | 唯一 owner | 消费者 | +|---|---|---| +| 包 canonical identity、版本与安装 payload | xlings/libxpkg | mcpp、索引工具、模板 | +| runtime binding、loader/libc、exports、env op | xlings + xim package descriptor | mcpp | +| C/C++ source/module/link/template metadata | mcpp-index | mcpp | +| 项目 build graph 与 required outputs | mcpp | IDE、CI、用户 | +| 实际 ELF 闭包与兼容 verdict | mcpp post-link inspector | 用户、CI、缓存 | +| GitHub release 资产与 checksum | Release Manifest | AUR/Homebrew/其他渠道 | +| AUR 当前版本 | AUR | reconciler,仅作为 observed state | + +### 5.2 不可违反的不变量 + +1. **Identity**:内部永远用结构体,不用拼接字符串承担身份。 +2. **Success**:返回成功前,声明的 required outputs 必须存在并通过验证。 +3. **Runtime**:loader、libc 与解析到的共享库必须能追溯到 provider。 +4. **No hot-path orchestration**:构建热路径不启动 xlings、不访问索引网络。 +5. **Atomicity**:cache snapshot、模板目录、投影元数据和渠道更新必须原子提交或明确部分失败。 +6. **Fail closed on ambiguity**:同名候选、多 payload、未知 cfg/placeholder 不按目录顺序或短名猜。 +7. **Native evidence**:Windows/macOS/aarch64 行为只由对应 runner 证明。 + +## 6. 核心数据契约 + +### 6.1 Identity types + +建议共享概念定义,语言实现可独立: + +~~~text +NameAtom = one validated identifier atom +NamespacePath = one or more NameAtom segments +PackageId = { namespace: NamespacePath, name: NameAtom } +PackageRef = { id: PackageId, versionReq?: VersionReq } +ResolvedPackageId= { id: PackageId, version, indexRoute, descriptorDigest, payloadDigest } +ProjectIdentity = { namespace: NamespacePath, name: NameAtom, version } +TemplateSelection= { provider: PackageRef, variant?: NameAtom } +~~~ + +规则: + +- wire/display 规范形式是 namespace:name。 +- bare name 只能在候选唯一时作为便捷输入;歧义必须列出候选并失败。 +- legacy FQN 只在 ingress 解析一次;之后不再拆字符串。 +- map/cache key 使用完整 PackageId。 +- qualified display 由类型派生,不在各层自行加前缀。 +- index route 和 transport provenance 随 ResolvedPackageId 保留用于诊断/lock,但不混进 PackageId 本身。 + +### 6.2 xlings Runtime & Install Contract v2 + +xlings 在安装/物化时写入 versioned、只含相对路径的契约。可以演进现有安装描述文件,也可以使用专用文件;名称不是本文的关键决策,schema 是。 + +建议字段: + +~~~json +{ + "schema": 2, + "package": {"namespace": "xim", "name": "glibc", "version": "2.44"}, + "payload": { + "id": "content-addressed-id", + "digest": "sha256:...", + "root": "." + }, + "runtimeBinding": { + "id": "linux-glibc-2.44-x86_64", + "platform": "linux", + "arch": "x86_64", + "loader": "lib/ld-linux-x86-64.so.2", + "libc": "lib/libc.so.6", + "glibcProvidedCeiling": "2.44" + }, + "exports": { + "includeDirs": [], + "libraryDirs": ["lib"], + "runtimeDirs": ["lib"], + "crtObjects": [] + }, + "runtimeDependencies": [ + {"namespace": "xim", "name": "ncurses", "version": "..."} + ], + "environment": [ + {"name": "PATH", "op": "prepend", "value": "bin"} + ], + "capabilities": ["runtime.opengl", "runtime.egl"], + "provenance": "ecosystem", + "hostObservation": { + "glibc": "2.43", + "fingerprint": "optional-host-fingerprint" + } +} +~~~ + +约束: + +- 所有路径相对 payload root;移动安装根不使契约失效。 +- 契约文件有 content hash;mcpp cache key 使用 hash,不使用 mtime 猜测。 +- SubOS 只引用 active provider contract id,不复制或重新解释 payload 目录。 +- provenance 是封闭枚举:ecosystemsystem-sdkhost-link。 +- 复用 xlings 现有 host_glibc 观测并带入诊断/host-link fingerprint;mcpp 不再忽略该字段,但它不能替代逐 DSO 的 symbol requirement 检查。 +- 旧 schema 可读时告警并转成内存 v2;多 payload/歧义时 fail closed,不再按字典序挑目录。 + +### 6.3 EnvOp schema v2 + +废弃歧义的单一 set: + +事实边界:xlings 自 2026.8.8.2 起,POSIX/fish/PowerShell/进程内路径已经统一为“变量存在即不覆盖”,包括显式空字符串;剩余缺陷是 mcpp reader 仍把 Set 解释成 replace。schema v2 是为了把这一区别永久写入协议,而不是声称 xlings writer 仍未修。 + +| op | 精确定义 | +|---|---| +| default | 变量不存在时设置;变量存在但为空也视为已存在 | +| prepend | 按平台路径分隔符前置,去重并保序 | +| replace | 无条件覆盖,必须显式使用 | + +兼容规则: + +- schema 1 的 set 按 xlings 历史行为映射为 default。 +- xlings writer 与 mcpp reader 同一发布窗口支持 v2。 +- POSIX shell、fish、PowerShell、xlings 进程内应用和 mcpp 读取共享同一组 golden fixtures。 + +### 6.4 BuildSnapshot 与 OutputManifest + +一次配置成功写入原子、版本化 snapshot: + +~~~text +BuildSnapshot + schema + inputFingerprint + graphFile + sourceSetDigest + toolchainContractHash + runtimeContractHashes[] + outputManifest + postLinkVerdicts[] + +OutputManifest + ninjaOwnedArtifacts[] + metadataProjections[] + generatedSources[] + requiredPostconditions[] +~~~ + +fast path 决策: + +- Ninja-owned artifact 缺失:交给 Ninja 依据图重建。 +- compile_commands.json 等 metadata projection 缺失:从 output-dir 中 fingerprint 对应的 canonical copy 原子重投影,不必完整 prepare。 +- graph/snapshot/runtime contract 缺失或 schema 不支持:完整 prepare。 +- 任一 required postcondition 缺失:不得输出 “Finished”。 +- compile_commands.json 加入新项目默认 ignore;已有项目即使未 ignore 也应正确修复。 + +### 6.5 UiPolicy 与 WireEnvelope + +进程启动时仅初始化一次: + +~~~text +UiPolicy { + color: Auto | Always | Never, + quiet: bool, + output: Human | Wire +} +~~~ + +- --no-color 直接得到 Never,后续 TTY 探测不能覆盖。 +- human/status/progress 写 stderr。 +- machine stdout 只允许 versioned envelope;无彩色、无 spinner、无说明文字。 +- pseudo-TTY、Windows console、管道和重定向分别测试。 + +### 6.6 CommandSpec + +所有子进程用结构化 argv,不拼 shell: + +~~~text +CommandSpec { + executable, + argv[], + cwd, + envDelta, + stdinPolicy, + stdoutPolicy, + stderrPolicy +} +~~~ + +2>/dev/null、引号、重定向不再进入参数字符串。POSIX 与 Windows 使用各自 native spawn backend,共享上层语义测试。 + +## 7. 详细设计 + +### 7.1 xlings 候选发现与升级 + +当前 release-bundled xlings 被排除在候选更新链之外。改为显式候选: + +1. CLI/config 显式覆盖。 +2. mcpp release-bundled sibling。 +3. distro/AUR 提供的环境候选。 +4. mcpp writable sandbox 已安装候选。 +5. system PATH。 + +每个候选携带: + +~~~text +XlingsCandidate { + path, + version, + provenance, + writable, + contractCapabilities[], + digest? +} +~~~ + +选择原则: + +- 先满足 required contract capability/schema。 +- 默认不降级。 +- 在可信候选中选择最高兼容版本,而不是只按目录位置。 +- 需要复制到 sandbox 时原子替换并验证 digest/version。 +- mcpp self env 显示候选、被选原因、版本和 provenance。 + +release pin 同时表达两件事: + +- 本次 release 原生 CI 验证过的 exact bundled version。 +- 运行时允许的 minimum contract capability/schema。 + +这样可接受未来兼容版本,又不丢失发布可复现性。 + +### 7.2 构建完整性、C1 和 generated source + +将构建分成三个明确阶段: + +1. **Plan**:解析 identity、source set、toolchain/runtime contract,生成 graph 与 snapshot。 +2. **Execute**:Ninja/runner 生产 artifacts 和 generated sources。 +3. **Verify/Project**:检查 OutputManifest、运行 post-link inspector、原子投影 CDB/机器元数据。 + +generated action 必须声明 outputs;其输出并入下一轮 SourceSet 或明确标为最终产物。没有声明或声明后缺失均失败,不能静默排除。 + +默认 source glob 只由一个 SourceSet producer 展开;清单、scanner、Ninja graph 不再各自 glob。 + +性能要求: + +- snapshot 校验按 digest/stat 快速路径完成。 +- CDB 重投影是文件复制/rename,不重新求解全部依赖。 +- no-change build 目标维持 < 0.5 s,或相对当前基线不回退超过 10%;两者取更严格、但先在 CI 固定硬件建立基线。 + +### 7.3 C++ scanner + +用 O(bytes) streaming lexical masker 代替启发式 strip,状态至少包括: + +- Normal +- line comment +- block comment +- string / char / escape +- raw string delimiter / raw body +- line splice 与 CRLF 处理 + +masker 保留换行与列宽,将注释/字符串内容替换为空白;module/import parser 只读取 masked code。注释只可在 Normal 状态开始。 + +回归 corpus 必含: + +- 行注释和块注释中的 R"(。 +- 普通字符串中的 /*//。 +- raw string 中的假 import。 +- char literal、encoding prefix、自定义 raw delimiter。 +- CRLF、行拼接、文件末尾未闭合状态。 + +scanner 保持热路径无编译器子进程;若未来接 P1689,编译器扫描作为权威慢路径/不确定输入 fallback,不与 masker 产生第三套模块身份。 + +### 7.4 exports 与 namespace + +SourceUnit.owner、manifest owner、dependency owner 全部改为 PackageId。校验使用结构体相等;诊断需要字符串时调用一个 formatter。 + +这个改动同时解决: + +- C4 namespaced exports 被跳过。 +- namespace 被重复前缀化。 +- 模板依赖注入回退短名。 +- cache/graph 在同短名包之间碰撞。 + +### 7.5 脚手架与模板 + +#### 7.5.1 CLI + +推荐稳定表面: + +~~~text +mcpp new + [--namespace ] + [--dir ] + [--template ] + [--variant ] +~~~ + +交互输出必须同时展示 provider 的 qualified identity 和 variant;--list-templates 的 wire 模式返回结构化数组。 + +#### 7.5.2 renderer + +RenderVars 至少包含: + +~~~text +project.name +project.namespace +project.qualifiedName +project.version +template.provider.namespace +template.provider.name +template.provider.qualifiedName +template.variant +~~~ + +实现要求: + +- parse template 成 token stream,一次替换,不扫描插入值。 +- 未知 token hard error;需要 literal token 使用明确 escape。 +- 文件路径渲染也走同一验证器,禁止绝对路径、.. 和目录逃逸。 +- 依赖注入操作 TOML AST,不用字符串查找;写出完整 namespace 分组。 +- 模板 descriptor 声明兼容的 mcpp contract/version、必需 capability 与平台。 + +#### 7.5.3 transaction + +流程: + +~~~text +parse and validate input + -> resolve exact template provider + -> materialize to sibling temp dir + -> render once + -> parse generated mcpp.toml + -> validate required files and no path escape + -> optional offline configure smoke + -> atomic rename to destination +~~~ + +任何错误返回非零并删除 temp;诊断给出字段、非法值和允许形式,不留下半项目。 + +#### 7.5.4 compatibility + +- 旧 pkg@ver:variant 支持两个 release train,仅限能唯一解析的无 namespace provider。 +- 第一个 release train warning;第二个可通过 compatibility flag 使用;之后删除。 +- 模板索引新增 canonical identity,不原地改变旧模板含义。 + +### 7.6 Artifact Physics Inspector + +当前 pre-link driver -### 检查只看到“编译器计划”,不能证明最终 ELF。新增 post-link inspector: + +1. 读取实际 PT_INTERPDT_NEEDED、RPATH/RUNPATH。 +2. 根据 artifact 路径、Runtime Contract 和平台规则递归解析动态闭包。 +3. 给每个对象标注 payload/provider/provenance。 +4. 从 .gnu.version_r 计算每个 host-borrowed object 的 GLIBC requirements。 +5. 验证 Rule B 与 Rule A。 +6. 生成 versioned verdict,按 artifact digest + Runtime Contract hash 缓存。 + +可复用 vendored patchelf 获取 interp/rpath/needed;GLIBC version requirement 建议实现最小只读 ELF parser,避免假设用户安装 readelf,也避免仅用系统 glibc 版本近似。 + +诊断示例信息应包含: + +~~~text +artifact +selected loader and provider +selected libc and provider +offending object +required GLIBC symbol ceiling +provided runtime ceiling +resolution path and remediation candidates +~~~ + +build fast path 必须把 verdict 当作 required output;artifact 或 Runtime Contract hash 变化后失效。 + +allow_host_libs 可以调整 Policy D 的告警/允许范围,但不能关闭物理 A/B:配置项不能让不可装载的 ELF 变得可运行。 + +上线分四步: + +1. **observe**:只记录闭包与 provider,对照 linker trace/map、readelf/patchelf fixtures。 +2. **warning**:对 proven mismatch 报高质量诊断,但保留显式 strict opt-in。 +3. **strict opt-in**:在 mcpp/xim-index native matrix 中积累低误报数据。 +4. **default strict**:只对精确解析得到的 mismatch/unresolved required object hard fail;解析器自身无法证明的情况返回“inconclusive”,不得假装兼容,也不得用 our_glibc ≥ host_glibc 代理误拒绝。 + +### 7.7 GUI / graphics 分层 + +当前 [runtime] 的 soname、capability、provider 和目录大多是扁平字符串:provider 只记 short name,library_dirs 又同时影响 -L 与 RPATH,capability 还可能同时表示 requires 与 provides。目标模型先拆开“需求”和“已解析实物”: + +~~~text +RuntimeRequirement { + kind: soname | capability | icd_manifest | display | host_service, + value, + phase: link | run, + required, + target, + requester: PackageId +} + +RuntimeArtifact { + role: loader | library | driver | manifest | host_bridge, + relativePath, + provider: ResolvedPackageId, + provenance: payload | subos_view | host_link | system_sdk, + abi, + requiredGlibcCeiling?, + digest?, + hostFingerprint? +} +~~~ + +链接和运行目录也必须分义: + +~~~text +link.libraryDirs +link.transitiveNeededDirs +runtime.rpathDirs +runtime.dlopenDirs +runtime.environment +deploy.files +~~~ + +runtime 目录不得隐式进入 -L。ELF lowering 分别生成 link search、-rpath-link 和 RPATH/RUNPATH;Mach-O 使用 @rpath/install name;PE 使用 app-local DLL 与 system DLL contract。resolution.json 输出 requirement → canonical provider → artifact → search mechanism → provenance → ABI verdict 的完整链。 + +#### 7.7.1 层次 + +| 层 | 职责 | 禁止事项 | +|---|---|---| +| xlings | payload、SubOS view、RuntimeBinding、runtime exports、host sentinel 激活 | 不理解 ImGui/GLFW 项目语义 | +| xim-pkgindex | Mesa/系统运行时闭包、NVIDIA/WSL host-link sentinel、平台系统包 | 不生成 mcpp 工程 | +| mcpp-index | ImGui/GLFW 等 C++ 包、LinkPlan、模板、平台依赖选择 | 不复制/symlink xlings 内部 view 作为长期 ABI | +| mcpp | 根据 capability 规划、链接、artifact physics、why 诊断 | 不探测 GPU 型号或选择驱动 | + +#### 7.7.2 平台策略 + +- **Linux**:xim:graphics 作为图形运行时入口;Mesa 默认闭包;NVIDIA/WSL 通过 sentinel 表达显式 host-link。 +- **macOS**:使用系统 SDK/framework capability;不伪装成 Linux Mesa 布局。 +- **Windows**:使用 Win32/system SDK 与明确 DLL runtime contract;命令执行和路径由 native backend。 + +模板声明平台依赖,不在源码中散落“如果 Linux 就手工找 libGL”。 + +ImGui 包本身也应按 feature 解耦: + +- core/headless 不拉窗口系统。 +- backend-glfw-opengl3 显式引入 GLFW、OpenGL headers 与 runtime。 +- backend-vulkan 显式引入 Vulkan loader + ICD requirement。 +- app 可组合默认 backend;docking/viewports 是正交 feature。 + +当前 ocornut:imgui 的 app facade/后端依赖仍容易让只用 core 的项目拉入 graphics 栈,这一拆分应由 mcpp-index 完成,不放进 xlings。 + +#### 7.7.3 过渡 + +compat.glx-runtime 的 symlink bridge 暂时保留: + +1. xlings contract v2 能表达 resolved transitive runtime exports。 +2. mcpp 能直接消费并生成正确 LinkPlan/runtime dirs。 +3. mcpp-index native tests 证明不再需要桥。 + +然后在一个有 warning 的 release train 中弃用,避免双份 runtime dir 漂移。 + +#### 7.7.4 测试矩阵 + +| 层级 | Linux | macOS | Windows | +|---|---|---|---| +| 常规 PR | headless llvmpipe configure/build/run;artifact A/B | native framework build + minimal smoke | Win32 backend build + minimal smoke | +| 原生/计划任务 | X11/Wayland;AMD/Intel/NVIDIA;WSL2 | arm64 + x86_64 native window | x64/arm64 native window | +| side-effect | 安装 graphics 前后无关 ELF 的 interpreter 与 GLIBC ceiling 不变 | SDK selection 不污染全局 env | DLL/path contract 不污染宿主 shell | + +Vulkan native gate 至少创建 instance、枚举 physical device,并验证实际加载的 ICD manifest/driver provenance;只测试 loader symbol 不算通过。真实 GPU 行为只能由对应 native runner 证明;Linux headless 通过不外推成所有硬件通过。当前 xim:graphics recipe 明确仅支持 Linux x86_64,所以 aarch64 在支持落地前应返回清晰的 capability unavailable,而不是把 cross-build 或 skipped runtime test 汇总成绿色。 + +### 7.8 安装 owner 与错误传播 + +删除 C8 的两段 versionless 预安装循环。唯一流程: + +~~~text +parse PackageRef + -> solve exact graph + -> install/materialize exact nodes through xlings + -> verify install contracts + -> expose graph to planner +~~~ + +- 任何节点失败立即带完整 PackageId/version/provenance 返回。 +- 不允许 catch (...) {} 或忽略 error_code 后继续。 +- 离线模式只使用已验证 materialization;缺失时明确报错。 + +### 7.9 Release Manifest 与 AUR reconciler + +#### 7.9.1 Release Manifest + +release 成功后发布不可变 manifest: + +~~~json +{ + "schema": 1, + "version": "2026.8.8.4", + "tag": "v2026.8.8.4", + "commit": "...", + "assets": [ + {"name": "...", "sha256": "...", "platform": "...", "arch": "..."} + ], + "bundled": {"xlings": "2026.8.8.1"} +} +~~~ + +AUR 生成器只消费 manifest,不重新从日志/文件名猜版本和资产。 + +mcpp-m 应消费 release 自己发布且带 sidecar 的版本化 source asset,不再依赖可能重生成的 tag archive;生成器下载实物重算 hash,并与 manifest/sidecar 交叉验证。 + +#### 7.9.2 先修 P0 生成器 + +- 不再用行级 sed 更新 Bash array;使用小型、确定性生成器从 model 完整生成 PKGBUILD 与 .SRCINFO。 +- 在推送前下载/校验 source archive 与二进制资产。 +- 比较 PKGBUILD 解析结果与 .SRCINFO:version、source URL、每个 checksum 必须一致。 +- 在 Arch container 中运行 makepkg --printsrcinfo 并与提交文件 diff。 +- 在 Arch container 中运行 makepkg --verifysource;PKGBUILD 是源,.SRCINFO 只由 makepkg 生成。 +- 分别构建/安装 mcpp-binmcpp-m 的最小 smoke。 +- 所有 package 预检全部通过后才允许任何 push,避免 mcpp-bin 先成功、mcpp-m 后失败。 +- dry-run 默认不加载 SSH secret、不 push,只输出 desired/current、生成文件、diff 和验证报告。 + +#### 7.9.3 对账工作流 + +触发: + +- release workflow 成功事件:低延迟路径。 +- schedule:建议每 6 小时。 +- workflow_dispatch:恢复/指定版本,但默认仍以 latest stable manifest 为准。 + +算法: + +~~~text +read latest immutable Release Manifest + -> query AUR RPC and git heads for every managed package + -> compare desired/current with Arch vercmp; reject implicit downgrade + -> generate all expected repositories in temp dirs + -> validate all package postconditions + -> for each drifting package, push idempotently with bounded retry/backoff + -> query AUR RPC/git again + -> succeed only if observed state matches expected state +~~~ + +行为: + +- AUR maintenance、连接超时等 retryable failure 使用指数退避与抖动。 +- auth、invalid metadata、checksum mismatch 属于 permanent failure,立即失败并告警。 +- 每个 package 的 push 可独立重试,但总体状态明确显示 partial convergence。 +- GitHub Release 是 desired state,AUR 是 observed projection;无需再维护一份可漂移的 mutable ledger。 +- 错过中间 release 时允许直接收敛 latest stable,不要求重放全部历史版本。 +- workflow_run 只负责唤醒;每次都重新读取最新完整、非 draft、非 prerelease manifest,迟到事件不得降级 AUR。 +- 已知 AUR package clone 失败时不得自动当作“首次发布”初始化空仓;首次认领必须是独立、显式流程。 +- 读取可走 HTTPS,SSH 只用于 push;固定官方 host key,不在发布时盲信动态 ssh-keyscan。 +- 使用专用发布身份/Ed25519 key;普通 PR 与不受信任代码永远拿不到 secret。 +- AUR git head 是立即验证源,RPC 允许 bounded poll;RPC 延迟不得触发重复提交。 +- 使用普通 fast-forward push,禁止 force-push/历史改写。 + +建议 SLO(待 review): + +- AUR 可用时,release 后 30 分钟内收敛。 +- 事件失败后,scheduled backstop 最迟 6 小时再次尝试。 +- 连续 24 小时未收敛触发高优先级告警。 + +mcpp-git 是否发布是独立产品决策;若保留,必须加入 managed package matrix 和相同 postcondition,不能只在仓库里放模板。 + +## 8. 兼容与迁移策略 + +### 8.1 双读单写 + +- xlings/mcpp 在迁移窗内读取 schema 1 和 2,只写 schema 2。 +- build cache schema 改变视为 cache miss,不尝试就地猜测迁移。 +- 唯一可转换的 legacy identity 在 ingress 转换;歧义立即失败。 +- 旧模板语法有明确两个 release train 的弃用窗口。 + +### 8.2 Fail-open 与 fail-closed 边界 + +| 情况 | 行为 | +|---|---| +| 缺少可再生成的 CDB projection | 本地重投影 | +| 旧但唯一可转换的 contract | 转换 + warning | +| 多 payload、同短名多候选 | fail closed | +| Rule A/B 已证明的物理冲突 | 经过 observe/warning 迁移后 hard fail;解析不确定单独报告 inconclusive | +| Policy D 仅在本地用户项目违反且 A/B 安全 | 默认清晰 warning;索引 CI hard fail | +| 未知 cfg、placeholder、output | hard fail | +| AUR 外部维护 | retry + scheduled reconcile,不回滚 GitHub release | + +### 8.3 可回滚性 + +- contract/snapshot 都有 schema 与原子文件;回滚二进制时旧 reader 忽略不支持的新文件并重新 materialize。 +- symlink bridge 只在新 runtime export 经过至少一个 release train 后移除。 +- 渠道 reconciler 生成提交前保存 expected diff;不 force-push AUR 历史。 +- 不通过修改全局 host env 回滚 runtime;切换 active RuntimeBinding。 + +## 9. 分阶段交付 + +### Phase 0 — 止血,不等待完整架构 + +1. mcpp:C1 输出完整性检查/CDB repair;C2 masker;C3 color;C4 PackageId 比较;C5 CommandSpec;删除 C8 循环。 +2. scaffold:#380 NameAtom、路径约束、单次渲染、临时目录事务。 +3. AUR:修复 mcpp-m checksum 生成;增加 PKGBUILD/.SRCINFO 一致性 gate;全部预检后再 push;再人工恢复一次对账。 +4. release:先完成 C5/C6 的候选/版本探测闭环,再将 bundled xlings 升到已验证的 2026.8.9.x,并跑冷 HOME 原生 gate;不能只改 pin。 + +每项独立小 PR,避免 P0 被 contract v2 设计阻塞。 + +### Phase 1 — Identity 与本地契约 + +1. PackageId/PackageRef/ProjectIdentity 全链路。 +2. 新 --template PackageRef --variant 表面和 legacy ingress。 +3. BuildSnapshot/OutputManifest。 +4. EnvOp schema v2 golden fixtures。 +5. xlings Runtime & Install Contract v2 writer;mcpp 双 reader。 +6. XlingsCandidate capability/provenance 选择。 + +### Phase 2 — Runtime physics 与 graphics + +1. post-link ELF parser/verdict 与 mcpp why runtime。 +2. xim-pkgindex Policy D CI。 +3. mcpp 读取 transitive runtime exports。 +4. Linux llvmpipe/native 图形矩阵。 +5. 弃用 compat.glx-runtime symlink bridge。 + +### Phase 3 — 脚手架 UX 与原生 GUI vertical slice + +1. TOML AST 依赖注入与模板 contract。 +2. ImGui/GLFW canonical template。 +3. Linux、macOS、Windows 各自一条从 mcpp new 到真实运行的冷 HOME vertical slice。 +4. 机器输出与 IDE 消费统一。 + +### Phase 4 — 发布渠道最终一致性 + +1. Release Manifest。 +2. AUR event + schedule reconciler、retry 分类、推后验证。 +3. 将 Homebrew/其他渠道逐步迁到同一 manifest 模型。 +4. 发布 dashboard 展示 GitHub release、索引、AUR 和 bundled xlings 的期望/实际版本。 + +## 10. 跨仓库 PR 切分建议 + +| 顺序 | 仓库 | 小 PR 主题 | 依赖 | +|---|---|---|---| +| 0A | mcpp | AUR generator/checksum consistency + dry-run validation | 无 | +| 0B | mcpp | C1/C3/C5 快速修复与回归测试 | 无 | +| 0C | mcpp | scanner masker(C2) | 无 | +| 0D | mcpp | scaffold transaction(#380) | 无 | +| 1A | xlings | install/runtime contract v2 + EnvOp v2 writer | 设计字段冻结 | +| 1B | mcpp | contract v1/v2 reader + candidate selection | 1A fixtures | +| 1C | mcpp/libxpkg consumer | PackageId 全链路与模板 CLI | identity spec | +| 1D | mcpp | BuildSnapshot/OutputManifest | 0B | +| 2A | mcpp | ELF actual-artifact inspector | 1B | +| 2B | xim-pkgindex | Policy D closure CI + sentinel schema | 1A | +| 2C | mcpp-index | graphics runtime exports 消费,移除桥的准备 | 1A/1B/2B | +| 3A | 三平台仓库矩阵 | GUI cold-home vertical slices | 1C/2A/2C | +| 4A | mcpp release | Release Manifest + AUR reconciler | 0A | + +协调者负责 schema fixtures、跨仓集成顺序和最终 native gate;各 PR 保持可独立回滚,不改写历史。 + +## 11. 验收标准 + +### 11.1 功能与回归 + +- #380 的 hang、路径逃逸、控制字符、部分目录均有回归测试。 +- 同短名不同 namespace 的 package/template 可同时存在,bare ambiguity 明确失败。 +- 删除 artifact、CDB、graph、verdict 任一项后,下一次 build 能正确重建/重投影或完整 prepare。 +- C2 最小 corpus 及现有真实项目 corpus 零 pass-to-fail。 +- --no-color 在 TTY、pipe、Windows console 均无 ANSI。 +- EnvOp golden fixtures 在 xlings 与 mcpp 结果字节一致。 +- A/B 测试含:同 binding 成功、loader/libc 混源失败、host object ceiling 高于 runtime 失败、低于等于成功。 +- 安装 graphics 不改变无关 ELF 的 interpreter/provider/GLIBC ceiling。 + +### 11.2 用户路径 + +每个平台都从隔离 HOME/MCPP_HOME/XLINGS_HOME 开始: + +~~~text +install released mcpp + -> verify selected bundled/upgraded xlings + -> mcpp new namespaced GUI project + -> resolve/install dependencies + -> configure/build + -> run platform-appropriate smoke + -> delete CDB/artifact and verify repair + -> inspect mcpp why runtime / wire output +~~~ + +不得用已缓存开发机状态替代。 + +### 11.3 性能 + +- scanner O(bytes),以大 translation-unit corpus 防止超线性回退。 +- no-change build 不启动 xlings、不访问网络。 +- artifact physics 仅在新 artifact 或 contract hash 变化时运行。 +- 固定硬件建立 median/p95 基线后,hot no-op build 回退不超过 max(5%, 10 ms);缺 CDB 只允许一次轻量 reconfigure/project。 +- 增量链接的 artifact inspector 额外成本目标不超过 max(5%, 50 ms/产物);hot no-op parse 次数必须为 0。 +- contract 解析与 snapshot 校验有单独 benchmark,避免 JSON/磁盘布局成为热路径瓶颈。 + +### 11.4 多平台与发布 + +- Linux x86_64/aarch64、macOS arm64/x86_64、Windows x64 至少有原生 cold-home gate。 +- 不从 Linux source review 推断 Windows spawn/console 正确。 +- release 完成后验证 remote tag/commit、assets、checksums、bundled xlings、索引版本。 +- AUR push 后同时验证 AUR git head 与 RPC version;只看到 workflow green 不算渠道收敛。 + +## 12. 可观测性与用户体验 + +新增统一解释命令,优先扩展现有 mcpp self/mcpp why,不创建大量顶层命令: + +~~~text +mcpp self env + selected xlings, all candidates, provenance, contract schema + +mcpp why package namespace:name + normalized PackageRef, selected version, source index, dependency path + +mcpp why runtime + loader/libc provider, dynamic closure, host-link leaves, A/B verdict + +mcpp build --output wire + versioned event/result envelope only +~~~ + +human 模式先给解决动作,再给细节。例如 loader/libc 混源时直接指出是哪个对象、来自哪里、需要哪个 runtime contract,而不是只输出 “GLIBC not found”。 + +## 13. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| schema v2 同时改 xlings/mcpp,发布错位 | 双读单写、共享 fixtures、capability negotiation | +| typed identity 改动面大 | 先在 ingress/graph 边界引入,禁止新增裸字符串 key | +| ELF parser 容易遗漏格式 | 最小只读范围、fixture 与 readelf/patchelf 对照、fuzz | +| Policy D 过严伤害用户自定义宿主库 | 只在 index CI hard fail;用户项目区分 A/B 与 D | +| GUI native CI 不稳定 | headless PR gate + 有标签的 native scheduled gate,分别报告 | +| AUR 部分发布 | 全包预检、独立状态、post-push reconcile;不 force-push | +| contract 使 fast path 变慢 | content hash 缓存、只读本地文件、按 artifact digest 复用 verdict | + +## 14. Review 需要确认的决策 + +1. 是否接受 **方案 C:分层 typed contracts**,并明确拒绝 xlings daemon 化? +2. 是否接受模板 CLI 将 provider 与 variant 分离:--template namespace:name@version --variant name?是否还需要后续提供 #variant 紧凑糖? +3. 是否接受项目逻辑 namespace、项目名和输出目录分别由 --namespace、位置参数、--dir 表达? +4. 是否接受 Rule A 按实际 host object 的 GLIBC symbol requirement 计算,而不是比较两端 glibc 发行版本? +5. 是否接受 A/B 在用户构建 hard fail、Policy D 只在索引 CI hard fail的分层? +6. 是否接受 mcpp 不做 GPU 探测,GPU/宿主驱动选择只由 xim sentinel/provider 负责? +7. 是否接受 Runtime & Install Contract v2 使用相对路径、content hash、双读单写迁移? +8. 是否接受 AUR 先修 generator consistency,再启用 6 小时 scheduled reconcile;30 分钟/6 小时/24 小时作为初始 SLO? +9. mcpp-git 是正式维护的第三个 AUR package,还是从自动发布范围明确移除? +10. Phase 0 的顺序是否同意:AUR 元数据安全、C1–C5/C8、#380 事务、bundled xlings 升级并行止血? + +## 15. 证据锚点与验证边界 + +下列行号对应本文基线,后续代码移动时以符号为准: + +| 主题 | 当前实现证据 | +|---|---| +| C1 fast path / CDB | src/build/execute.cppm:705-774src/build/ninja_backend.cppm:1547-1549src/build/compile_commands.cppm:282-307 | +| C2 scanner | src/modgraph/scanner.cppm:137-189,579-590 | +| C3 color | src/cli.cppm:97-108src/ui.cppm:233-240,266-331 | +| C4 exports | src/modgraph/scanner.cppm:786-792src/modgraph/validate.cppm:90-118 | +| C5 process | src/fallback/xlings_binary.cppm:156-183src/platform/process.cppm:191-226,335-350 | +| C7 EnvOp | src/xlings/subos_info.cppm:250-300tests/unit/test_subos_info.cpp:290-310 | +| C8 dead loops | src/toolchain/lifecycle.cppm:551-565src/build/prepare.cppm:1664-1670src/pm/package_fetcher.cppm:923-936 | +| #380 | src/scaffold/create.cppm:183-298src/scaffold/template.cppm:134-182 | +| template identity | src/scaffold/template.cppm:22-47,125-228src/scaffold/create.cppm:30-141 | +| #392 selection | src/toolchain/post_install.cppm:378-388src/xlings.cppm:35-48 | +| #396 pre-link only | src/build/hermetic.cppm:103-211src/build/ninja_backend.cppm:1575-1582 | +| runtime flattening | src/manifest/types.cppm:455-467src/build/plan.cppm:631-712src/build/flags.cppm:709-724,860-885 | +| AUR trigger/push | .github/workflows/aur-publish.yml:14-102 | +| AUR checksum defect | scripts/aur/update.sh:61-66scripts/aur/mcpp-m/PKGBUILD:32-35 | + +本次只读交叉审计没有执行会安装 graphics 或创建工程的命令,也没有在 WSL2、AMD/Intel/NVIDIA、macOS MoltenVK、Windows Vulkan 或 Linux aarch64 graphics 上实跑。mcpp 基线已有的 7 个被触发 Actions workflow 全绿,只能证明现有覆盖集;它们没有针对性覆盖上述大部分缺陷,native aarch64 在该提交也未触发。因此本文不建议仅凭现有 CI 关闭 #397/#396/#392/#380。 + +## 16. 相关既有设计 + +本文是跨问题的收敛层,不替代以下文档的细节: + +- .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md +- .agents/docs/2026-08-08-payload-version-and-contract-drift-design.md +- .agents/docs/2026-08-08-machine-readable-output-protocol-design.md +- .agents/docs/2026-08-05-build-mcpp-extensibility-architecture.md +- xlings 的 .agents/docs/2026-08-09-ecosystem-closure-design.md + +如果本文获批,下一步不是直接开启一个大实现,而是把第 14 节决策写成冻结的 ADR/schema fixtures,再按第 10 节拆分小 PR。 diff --git a/.agents/docs/2026-08-10-graphics-closure-acceptance.md b/.agents/docs/2026-08-10-graphics-closure-acceptance.md new file mode 100644 index 00000000..9613bf64 --- /dev/null +++ b/.agents/docs/2026-08-10-graphics-closure-acceptance.md @@ -0,0 +1,307 @@ +# 验收记录:图形栈闭合与分发档位(2026.8.10.2) + +> 日期:2026-08-10 +> 被测:`feat/graphics-closure-and-distribution-tiers`(PR #408) +> 本机:x86_64-linux-gnu / gcc 16.1.0 / NVIDIA RTX 4080 / 550.144.03 / X11 `:1` +> 设计:`2026-08-10-graphics-closure-and-distribution-tiers-design.md` +> +> **本文只写实测。** 每一条结论都带命令与输出;凡未实测的一律标 **未验**, +> 凡本机环境导致的一律与代码结论分开。 + +--- + +## 0. 一句话 + +**mcpp 那一半全部验到了;GPU 那一半在本机验不了 —— 而"验不了"和"验过是好的"必须分开写, +这正是本轮到处在建的那条判据。** + +--- + +## 1. #405 —— 在真实 imgui 模板上端到端验过 + +不是构造的 fixture,是 issue 里那个模板本身。它生成的 `main.cpp` 只有: + +```cpp +import imgui.core; +import imgui.app; +``` + +**自己不 `import std`** —— 这正是复现所需的形状。 + +```console +$ rm -rf ~/.mcpp/build-cache/v1/pkg/mcpplibs/imgui@* # 强制第一次 MISS +$ mcpp new a --template imgui:window && (cd a && mcpp build) + Compiling imgui v0.0.6 + Finished dev [unoptimized + debuginfo] in 1.15s ← 缓存 MISS,一直是好的 + +$ mcpp new b --template imgui:window && (cd b && mcpp build) + Cached imgui v0.0.6 (9 units) ← 缓存 HIT + Finished dev [unoptimized + debuginfo] in 0.78s ← 修复前这里必挂 +``` + +修复前的输出(同一形状,e2e `212` 的 RED 实录): + +``` +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 +``` + +**判据达成。** 顺带,这条也复核了 issue 里「第一个人好、后面都坏」的形状: +`a` 与 `b` 是同一个 mcpp、同一个 home、同一分钟内建的,与版本无关。 + +--- + +## 2. 加载器标签 —— 在真实图形产物上验过 + +`b` 的产物,原生解析动态段: + +``` +form= executable tag= RPATH +``` + +`resolution.json` 的 `loader_tags`(rule E 的记录,共 13 条): + +| 对象 | form | required | actual | status | +|---|---|---|---|---| +| `bin/b` | executable | DT_RPATH | **DT_RPATH** | ok | +| `bin/libX11.so` | shared_library | DT_RUNPATH | DT_RUNPATH | ok | +| `bin/libXcursor.so` … `bin/libxcb.so`(共 12 个) | shared_library | DT_RUNPATH | DT_RUNPATH | ok | + +**一分为二的两侧都验到了**:可执行 RPATH、12 个库全部 RUNPATH,零 violation。 + +产物的 DT_RPATH 内容,四条全部存在(逐条核过): + +``` +/xim-x-glibc/2.39/lib64 +/xim-x-gcc/16.1.0/lib64 +/compat-x-glx-runtime/2026.08.08/mcpp_generated/glx_runtime/lib ← 52 个文件 +$ORIGIN +``` + +> ⚠️ **一次我自己的测量错误,记在这里。** 我一度报告第三条"指向一个不存在的版本" +> ——那是 `find … | head -1` 先返回了同目录下的 `2026.06.03` 造成的。 +> `2026.08.08` 存在且完整。**`head -1` 不是判据。** + +--- + +## 3. GPU 那一半:本机 **NOT_EXERCISED**,不是 PASS 也不是 FAIL + +程序能起来,窗口创建失败: + +```console +$ ./b +imgui.app: window creation failed: GLFW error 65545 (GLX: Failed to find a suitable GLXFBConfig) +``` + +**宿主 GL 本身是好的** —— 所以这不是"这台机器没有显卡": + +```console +$ DISPLAY=:1 glxinfo -B +direct rendering: Yes +OpenGL vendor string: NVIDIA Corporation +OpenGL renderer string: NVIDIA GeForce RTX 4080/PCIe/SSE2 +OpenGL core profile version string: 4.6.0 NVIDIA 550.144.03 +``` + +派发与 vendor 也都在载荷里(`libGLX_nvidia.so.0 → 550.144.03`,与宿主驱动同版本)。 + +**但这台机器的载荷是被污染的**(见 §5),而 `/lib` 里一个 GL 都没有、 +`.wiring` 记录不存在 —— 也就是说**这个 home 从来没有被接线过**。 + +> **所以本机的诚实判决是 `NOT_EXERCISED`。** +> mcpp 侧的三条职责(标签对、路径通且存在、不打包不该打包的)全部验到; +> 「桥有没有搭上宿主驱动」需要一台接过线的机器,本轮**未验**。 +> 这正是设计 §6.3 写的:那部分是 `xlings doctor` 的事,mcpp 该报 `NOT_EXERCISED`。 + +`mcpp why runtime` 的实际输出(逐字),把 L3 的缺口直接摆出来: + +``` +requirements: + - capability:opengl.glx.driver [run] <- compat.glfw@3.4 (required) + - capability:opengl.glx.driver [run] <- compat.glx-runtime@2026.08.08 (required) + … +providers: + - opengl.glx.driver -> compat.glx-runtime@2026.08.08 [index+compat@2026.08.08] + - x11.display -> compat.glx-runtime@2026.08.08 [index+compat@2026.08.08] +artifacts: + (not declared by the environment — nothing to verify) + note: a resolved provider with no artifact is UNVERIFIED, + not verified-good +validation: pass (source post_link) + - bin/b: pass ← 13 个对象逐条 pass(rule E + 闭包) + … +provider and host-service re-diagnostics are owned by xlings; run `xlings doctor` +``` + +**一个 provider 按名字解析成功、身后一个物都没有** —— 这就是设计里那句 +「provider 有名无物,所以没有任何东西可以校验」的现场。 +身份判决因此无事可做,而它**说出来了**,没有伪装成 `(none declared)` +那种可以被读成"没问题"的措辞。 + +--- + +## 4. 分发档位 + +| 档 | 判据 | 结果 | +|---|---|---| +| `vendored` | bundle 内**每个** ELF 无构建机 store 路径;可执行 RPATH、库 RUNPATH | ✅ e2e `215` | +| `self-contained` | 有 run 期能力需求时 plan 期硬拒并给出 `vendored` 出路 | ✅ e2e `216` | +| `static` | 同上 | ✅ e2e `216` | +| `self-contained`(无能力需求) | 仍然可打包、`run.sh` 可运行 | ✅ e2e `30` | + +`215` 的 RED 实录(撤掉 F2 之后),正是设计里描述的那条: + +``` +lib/libgcc_s.so.1 shared_library RUNPATH + /xim-x-glibc/2.39/lib : /xim-x-gcc/16.1.0/lib64 +FAIL: bundled object still points at the BUILD MACHINE's store +``` + +**一个真回归,由 `30_pack_modes` 抓到并已修**:第一版 F2 把 **动态加载器本身** +也 patchelf 了。它不是被搜索的库,它是执行搜索的程序 —— 改它让 `self-contained` 档 +在 `main` 之前段错误。修法是把加载器排除在重写之外。 + +--- + +## 5. 本机环境的三个缺陷(与本 PR 无关,但解释了本地 e2e 的红) + +本地 e2e:**183 通过 / 25 失败 / 8 跳过**。 +**25 条里 24 条用已发布的 `2026.8.8.2` 逐条复现** —— 同一个根因,三种表现: + +### 5.1 共享 gcc 载荷的 specs 被历史安装污染 + +``` +--dynamic-linker → /xim-x-glibc/2.44/lib64/ld-linux-x86-64.so.2 ← 该目录已被改名为 2.44.aside +rpath → 约 40 条 /tmp/tmp.XXXXXX/mcpphome/... (全部来自已删除的 e2e 沙箱) +``` + +后果:每一个 `build.mcpp` helper 的 PT_INTERP 指向不存在的加载器, +`posix_spawnp` 返回 **ENOENT**,报成 `exited with 127`。 +这打掉了 112 / 124 / 125 / 179 / 181 / 186–194 全部。 + +> **这是设计 §3.1「specs 是共享可变状态,不能承载契约」的现场证据**, +> 也正是 `mcpp-clean-link.specs` 存在的原因 —— mcpp 自己的构建因此不受影响, +> 而**独立编译的 `build.mcpp` helper 走不到那条防线**。这条值得单独跟。 + +### 5.2 `-print-search-dirs` 指向另一个工程的 subos + +``` +libraries: … /home/speak/workspace/github/openxlings/xim-pkgindex-fromsource/.xlings/subos/default/lib/ … +``` + +与本工程毫无关系。同一族。 + +### 5.3 xim binutils 的 shim 指向已删除的会话目录 + +``` +[error] xlings: executable 'as' not found +[error] path: /tmp/claude-1000/…/accept-run1/home/.mcpp/registry/data/xpkgs/xim-x-binutils/2.42/bin +``` + +与记忆里 #293 同族。**因此本轮所有 ELF 判据都用原生解析,不 shell out** —— +一个坏掉的 `readelf` 会让标签断言静默空转。 + +**CI 是这部分的真判据**(干净机器,四平台)—— 结果回填: + +``` +e2e 1/2 (linux x86_64): 95 passed, 0 failed, 13 skipped +e2e 2/2 (linux x86_64): 97 passed, 0 failed, 11 skipped +``` + +**192 通过、0 失败**,并且五条新用例逐条确认真的跑了(不是被 `# requires:` 跳过): + +``` +PASS: 212_cached_dep_std_is_ordered.sh +PASS: 214_executable_carries_dt_rpath.sh +PASS: 216_selfcontained_refuses_host_capability.sh ← shard 1 +PASS: 213_build_after_test_is_not_the_test_graph.sh +PASS: 215_pack_has_no_build_machine_paths.sh ← shard 2 +``` + +> **这一条特意查了**:`# requires:` 里一个不认识的 token 会让用例**从不运行** +> 而不报错(记忆里 `65_*` 就这样从未在 CI 跑过)。所以不是看总数, +> 是看这五个名字逐个出现在 `PASS:` 行上。 + +18 项 PR 检查全绿,含 macOS 与 Windows —— 也就是说加载器契约没有扰动非 ELF 平台。 + +--- + +## 6. 未验 / 明确不做 + +| 项 | 状态 | +|---|---| +| 图形程序真正拿到 GPU | **未验**,需要一台接过线的机器 | +| pack 产物在没有 xlings 的机器上运行 | **未验**,需要第二台机器;e2e 只能验到「产物里没有构建机路径」 | +| rule E 转硬门禁 | **不做**,结论只来自一台 NVIDIA/X11/x86_64 机器(与 xlings E5 同一笔欠账) | +| `.wiring` 读取 | **不做**,主判据已改为 mcpp 自算;见设计 §2.1 | + +--- + +--- + +## 7. 生态验收(用**发布出去的**那份,不是本地构建) + +发布之后按生态路径重做了一遍 —— 因为「本地构建能跑」和「用户装到的能跑」是两个断言。 + +```console +$ xlings update && xlings install mcpp@2026.8.10.2 -y + ✓ xim:mcpp@2026.8.10.2 done + xim:mcpp@2026.8.10.2 installed, but 'mcpp' still resolves to 2026.8.8.2 +$ xlings use mcpp 2026.8.10.2 +[xlings] mcpp -> 2026.8.10.2 (xim:mcpp 2026.8.8.2 -> 2026.8.10.2) +$ mcpp --version +mcpp 2026.8.10.2 +``` + +> ⚠️ `install` **不会**自动切换已装的旧版本,它会明说。只看 `install` 的成功输出 +> 就以为切过去了,是这一步最容易的误读。 + +用这个生态装出来的 mcpp 重跑图形验收: + +| 项 | 结果 | +|---|---| +| imgui 模板 A(缓存 MISS) | ✅ | +| imgui 模板 B(缓存 **HIT** —— #405 的那一格) | ✅ `Cached imgui v0.0.6 (9 units)` | +| 产物标签 | ✅ 可执行 `DT_RPATH`,11 个库全 `DT_RUNPATH`,rule E 零 violation | +| `artifacts` | `[]` —— 环境未声明,报 `NOT_DECLARED`(见 §3) | + +### 顺带验到 2026.8.10.2 的一个不足(已由 2026.8.10.3 修) + +同一个 imgui 工程,`mcpp pack --mode self-contained`: + +```console +# 生态里的 2026.8.10.2 + Packed b-0.1.0-x86_64-linux-gnu-bundle-all.tar.gz ← 照打不误,也没有 HOST-REQUIREMENTS + +# 带 .3 修复的构建 +error: --mode self-contained cannot be used by a program that needs the host to + provide abi:glibc, opengl.glx.driver, x11.display. + use: --mode vendored — … +``` + +原因:`.2` 的那道门读的是**根 manifest**,而这条能力来自依赖 +(`capability:opengl.glx.driver <- compat.glfw@3.4`)。**真实工程恰恰是这个形态。** +`216` 的 fixture 当时在根工程声明能力,所以它通过的理由比它声称的范围窄 —— +这一轮反复写的那条判据,这次落在自己头上。已改成依赖声明 + 前置断言。 + +### 身份判决的渲染也验了(环境没声明,就自己造一个) + +符号链接仍指向 `0.1.1`、声明 `xim:vendor@0.1.2`: + +``` +artifacts: + - library …/current/lib/libvendor.so <- … [xim:vendor@0.1.2; identity=mismatch] + ^ STALE BINDING: this resolves into a different version than the one declared. +``` + +正是设计里描述的那一格,而且是纯路径事实 —— 不需要认识 GL。 + +## 附:今天重复了三次的同一条 + +> **要说"验过了",先说清楚验的是哪一片。** + +- e2e 25 红里 24 红是环境的 —— 不逐条对照已发布二进制,就会把它们当成回归, + 或者更糟,当成"本来就红"而放过其中真的那一条(`30_pack_modes`)。 +- `find | head -1` 让我报了一个不存在的缺陷。 +- 宿主 `glxinfo` 好、程序拿不到 context —— 只报前者是撒谎,只报后者也是。 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..e80d5040 --- /dev/null +++ b/.agents/docs/2026-08-10-graphics-closure-and-distribution-tiers-design.md @@ -0,0 +1,764 @@ +# 图形栈打通:一个标签、一条没人依赖的边、一个被钉住的 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) +> +> 本文所有"已验"结论都在本机跑过,命令与输出在正文里。凡未实测的一律标 **未验**。 +> +> **实施状态(2026-08-10):A–J 全部已实施,PR #408,版本 `2026.8.10.2`。** +> 实施计划见 `2026-08-10-graphics-closure-implementation-plan.md`, +> 验收实测见 `2026-08-10-graphics-closure-acceptance.md`。 +> 与本文的两处出入,以实施为准: +> - **§2.1 的 `.wiring` 增强没有做**。主判据(mcpp 自算路径身份)已实施并可独立成立; +> 读别人一份条件语义未表达的记录属于净增耦合,收益不足以抵。 +> - **§3.F 的 `discovery` 不由 mcpp 推断**。第一版按能力名推断,被既有守卫 +> `test_runtime_contract` 当场拦下 —— 那是把 provider 专属知识写进 mcpp。 +> 改为声明式(`[[runtime.requirements]] discovery`),未声明报 `unknown`。**守卫是对的。** + +--- + +## 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/.agents/docs/2026-08-10-pr400-completion-design.md b/.agents/docs/2026-08-10-pr400-completion-design.md new file mode 100644 index 00000000..c1942d62 --- /dev/null +++ b/.agents/docs/2026-08-10-pr400-completion-design.md @@ -0,0 +1,613 @@ +# PR #400 收尾设计方案 —— 重新判定阻塞点,并把串行收口改成并行 + +> 日期:2026-08-10 +> 对象:[mcpp-community/mcpp#400](https://github.com/mcpp-community/mcpp/pull/400)(Draft,HEAD `64803fc`) +> 关联:#398(实施)、#397(汇总)、#380/#392/#396、 +> [mcpplibs/mcpp-index#197](https://github.com/mcpplibs/mcpp-index/pull/197)(已合)、 +> [openxlings/xlings#521](https://github.com/openxlings/xlings/pull/521)(Draft) +> 本文只记录可公开的仓库事实,不含本机路径、用户名、凭据。 + +--- + +## 0. 这份文档做什么 + +PR #400 的实现主体已经完成(31 commit、111 文件、+15085/−1692)。现有交接文档 +`.agents/docs/2026-08-09-pr400-handoff-zh.md` 把"还没做完的事"列得很完整,但它对**唯一一个 +mcpp 内部失败**的根因判定是错的,而整条收口路线又建立在"等 xlings 发版"这个跨仓库串行依赖上。 + +所以本文不重述已完成的实现,只做三件事: + +1. **§1** 用 CI 原始日志重新判定 Windows E2E 2/2 的根因(结论与 PR 正文不同); +2. **§2** 给出 review 结论,标出**必须在合并前处理**的项; +3. **§3–§5** 给出把串行等待改成并行推进的收口设计与分阶段计划。 + +### 0.1 基线事实(已核验) + +| 项 | 值 | 证据 | +|---|---|---| +| PR #400 HEAD | `64803fc` | `gh pr view 400` | +| main | `80291ca` | `git log` | +| main 的 Windows E2E | **success** | run `31275102045` | +| `64803fc` 矩阵终态 | **13 pass / 5 fail** | `gh pr checks 400` | +| 4 个 fail | 全部是裸 `ftxui` exact miss | job `93271797138` / `93271798469` / `93271802557` / `93271790099` | +| 第 5 个 fail | `12_add_command.sh` (exit 2) | job `93271793304` | +| `bare Windows: no Visual Studio` | 已 **pass**(`ed4cf64` 时是 cancelled) | job `93272767480` | + +`64803fc` 比交接文档记录的 `ed4cf64` 快照好一格:cancelled 已消失,仍是 5 个 fail。 + +--- + +## 1. 重新判定:Windows E2E 2/2 的根因不是 workspace 索引继承 + +### 1.1 PR 现在的说法 + +PR 正文与交接文档 §5.5 都写:失败发生在 step (14),workspace member 读不到根 +`[indices] acme`;`ed4cf64` 用 lexical anchor 修但没修好;`9a47ccf` 加 `route:` 诊断, +"等原生 Windows 输出 root/pkgs 状态后再做最小修复"。 + +**证据已经到了**(run `31324173520`),但它指向另一处。 + +### 1.2 原始日志 + +``` +2026-08-09T16:39:28.5388080Z error: package 'acme.util' not found in any configured index +2026-08-09T16:39:28.5388719Z tried: (acme, util) +2026-08-09T16:39:28.5389134Z route: local index 'acme': root absent, pkgs absent +2026-08-09T16:39:28.5946779Z FAIL: 12_add_command.sh (exit 2, 2.06s) +``` + +### 1.3 四条独立证据都排除 step (14) + +**(a) 退出码。** step (14) 被包在 `|| { echo …; exit 1; }` 里: + +```bash +workspace_add=$("$MCPP" add acme.util@2.0.0 2>&1) || { + echo "$workspace_add" + echo "workspace member could not read its root-owned local index" + exit 1 +} +``` + +它失败只可能是 **exit 1**。日志是 **exit 2** —— 那是 mcpp 自己的退出码经 `set -e` 直传, +只有 `"$MCPP" add … > /dev/null` 这种裸调用才会这样。 + +**(b) 缺失的横幅。** step (14) 失败必然打印 +`workspace member could not read its root-owned local index`,日志里没有。 +同一 harness 会显示脚本自身的 stdout —— 同一个 job 里 +`118_purview_include_rebuild.sh` 的 `windows: depfile degradation reported as expected` +就是脚本 `echo` 出来的。 + +**(c) 顺序。** 失败前最后两条未被捕获的 mcpp 输出是 step (5) 的 `capi.lua` 迁移 warning +(16:39:27.10)和 step (12) 的 `unknownidx.thing` warning(16:39:27.98)。 +step (13)/(14) 的输出全部被 `$( )` 捕获,所以不出现。下一条落到日志的 stderr +就是 step (15)。 + +**(d) `route:` 行本身。** step (15) 的 fixture 在 +`tests/e2e/12_add_command.sh:250` 写的是: + +```bash +[indices] +acme = { path = "$TMP/myapp/index" } +``` + +`$TMP` 来自 `mktemp -d`。在 Git Bash 下它是 **MSYS 路径** `/tmp/tmp.XXXX`, +而被写进 `mcpp.toml` 的是**文件内容**,MSYS 的 argv/env 路径转换对它不生效。 +原生 `mcpp.exe` 拿到 `/tmp/tmp.XXXX/myapp/index`,按 Windows 语义那是"当前盘根相对", +即 `C:\tmp\tmp.XXXX\myapp\index` —— 不存在。 + +**这正好就是 `root absent, pkgs absent`。** + +### 1.4 为什么 main 是绿的 + +step (15) 是本 PR **新增**的(`git diff origin/main...HEAD` 中该段全为 `+`)。 +step (14) 在 main 上就存在且一直通过。 + +全仓 e2e 里,只有这一处把绝对 `$TMP` 路径写进 manifest **并且在 Windows 上真正通过它解析包**: + +- `43_indices_lockfile.sh:45` 也写绝对 `$INDEX_DIR`,但整个测试只做文本断言,从不解析包 → Windows PASS; +- `163_identity_first_resolution.sh` 用相对路径 `../idx1` → Windows PASS; +- `121_default_ns_redirect.sh` / `203_exact_selector_lock_migration.sh` / `44_*` 等 + 被 `# requires: gcc` 挡掉 → Windows SKIP。 + +### 1.5 为什么 Wine 复现是假绿 + +交接文档 §5.5 记录:"下载该 job 的 Windows artifact,在隔离 Wine 中重放,两条 +`mcpp add acme.util@2.0.0` 都通过。" + +Wine 默认把 **`Z:` 映射到 `/`**。于是 `\tmp\tmp.XXXX\myapp\index` 落回真实的 +`/tmp/tmp.XXXX/myapp/index`,fixture 可读,测试自然过。 + +> **方法论条目(值得写进 CLAUDE.md / 记忆):Wine 能验证 PE 产物"能跑", +> 不能验证"路径语义"。** 凡是结论依赖盘符、驱动器映射、`/` 开头路径解释、 +> 8.3 短名、UNC 的问题,Wine 通过 = 无信息,不是证据。 + +### 1.6 推论:`ed4cf64` 是一次无证据的产品改动 + +`ed4cf64` 把 `src/project.cppm` 的 `inherit_workspace_indices` 里的 + +```cpp +idx.path = std::filesystem::weakly_canonical(wsRoot / idx.path); // main,绿 +``` + +改成 + +```cpp +idx.path = (wsRoot / idx.path).lexically_normal(); // PR +``` + +理由写的是"weakly_canonical 可能把 Windows 短名/大小写路径改写成另一种拼写"。 + +对照事实: + +- **同样的失败在 `ed4cf64` 之前之后完全一致** —— run `31318089536`(`0108ee19`,改动之前) + 的错误文本与 `31321961040`(`ed4cf64`,改动之后)逐字相同; +- main 带着 `weakly_canonical` 是绿的; +- 该改动配的单测 `PmIndexRoute.WorkspaceMemberReadsRootAnchoredRelativeIndex` + (`tests/unit/test_pm_index_route.cpp:184`)里有一行 + `EXPECT_EQ(indices.at("acme").path, (root / "index").lexically_normal());` + —— 它断言的是**实现细节的路径拼写**,而不是能力。这正是它抓不到真 bug 的原因。 + +所以:**这条改动目前是"为了一个不存在的原因、改掉一个当时是绿的行为",且没有任何 +测试能证明它有必要。** 它不一定错(`weakly_canonical` 会解符号链接, +参见记忆 `issue344-cache-object-address`:`fs::relative` 解符号链接曾让包静默退出缓存), +但**它必须要么被证明,要么被还原**,不能就这么留在 diff 里。 + +--- + +## 2. Review 结论 + +按"合并前必须处理 / 合并前应处理 / 记录即可"三档。 + +### R1 · P0 · 修在了控制流到不了的地方 + +见 §1。当前 PR 的下一步动作("等 Windows route 证据 → 修 workspace 继承") +如果照做,会在一个本来就没坏的路径上继续改代码。 + +**处理**:按 §3 主线 A。 + +> 这是记忆 `repair-placed-where-flow-never-reaches` 的同一形状第 12 次出现: +> **修补被放在控制流永远到不了的地方。** 这次的变体是"断言的失败位置被错认"。 + +### R2 · P0 · 裸名精确化 = 让**已经发布**的数据失效 + +这是本 PR 最大的产品风险,而且它已经在用四个红 job 报警。 + +现状(`tests/e2e/162_bare_name_namespace_scope.sh` 的 diff 就是判据): + +| 写法 | main | PR #400 | +|---|---|---| +| `gtest = "1.15.2"` | 解析到 `compat.gtest` | **硬失败** | +| `ftxui = "6.1.9"` | 解析到 `compat.ftxui` | **硬失败** | +| `compat.gtest` | 解析 | 解析 | + +四个 CI 失败(Linux integration / macOS LLVM / aarch64 / Windows toolchains)全部是 +同一句: + +``` +error: dependency 'ftxui': no package found for exact selector + tried: (mcpplibs, ftxui) + compat.ftxui + ftxui = "6.1.9" +``` + +PR 把它们归类为"已知跨仓库边界,由 xlings #521 收口"。**这个归类不成立**: + +1. **mcpp 自己也被它打中**。本 PR 不得不把仓库根 `mcpp.toml` 的 + `[dev-dependencies] gtest` 改成 `[dev-dependencies.compat] gtest`。 + 一个变更需要改自己的 manifest 才能自举,就是破坏性变更的定义。 +2. **xlings 只是一个消费者**。索引里有 34 个 `compat.*` 包 + (`165_bare_name_cross_namespace_wire_address.sh` 在 main 上的注释记的数; + 本 PR 把那段注释删掉了)。 + 任何用户 `mcpp.toml` 里写着裸 `gtest`/`ftxui`/… 的项目, + 在升级到 2026.8.9.1 的那一刻全部构建失败,而**修 xlings 帮不到他们**。 +3. **它是记忆 `index-floor-must-degrade` 的镜像**。那条的教训是 + "索引是数据、mcpp 是程序,发布数据不得让程序失效"。 + 这次反过来:**发布程序不得让已发布的数据与用户 manifest 失效。** + 两个方向的判据是同一条:**必须降级,不得变砖。** +4. **PR 自己已经给出了正确的先例**。同一个 PR 对 `ns:name → ns.name` + 给了一个 release 的迁移别名 + 可复制的替换提示。裸名迁移比冒号拼写影响面**大得多**, + 却只给了硬失败。这是内部不一致。 + +**处理**:按 §3 主线 B(加一个 release 的迁移窗口)。这同时会把四个红变绿, +**并把 xlings #521 从发布关键路径上摘下来**。 + +### R3 · P1 · 合并 #400 会立刻武装 AUR 自动发布 + +`.github/workflows/aur-publish.yml`: + +```yaml +on: + workflow_run: { workflows: [release], types: [completed] } + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: { inputs: { publish: … default: false } } +``` + +而发布判定是: + +```bash +if [[ "$TRIGGER" == workflow_run || "$TRIGGER" == schedule ]]; then + publish=true +else + publish=${MANUAL_PUBLISH:-false} +fi +``` + +**`schedule` 直接 `publish=true`。** 也就是说:#400 一旦合入 main, +最多 6 小时后就会有一次**无人值守的、对第三方服务(AUR)的写操作**, +它会把当时"最新完整稳定 release"(合并瞬间是 `v2026.8.8.4`)推到 `mcpp-bin`, +而公开 AUR 现在还停在 `2026.8.1.1-1`。 + +这不一定是错的结果,但**它是一个由"合并"而不是由"明确放行"触发的对外副作用**, +而且这条路径**从未真跑过**(交接文档 §3.7:只做了非 root `makepkg --verifysource` dry-run, +"本轮没有发布")。 + +**处理**:见 §3 主线 C 的 C3 —— 首次落地把 `schedule` 分支降级为 dry-run +(用一个仓库变量或直接先不带 `schedule` 触发器合入), +手工 `workflow_dispatch --publish` 验证一次成功后,再用一个独立小 PR 打开定时收敛。 + +### R4 · P1 · 身份/索引路由的 e2e 在 Windows 上几乎不跑 + +本 PR 的核心是"精确身份 + 索引路由",而这类 e2e 的 `# requires:` 把它们挡在 Windows 之外: + +| 测试 | requires | Windows | +|---|---|---| +| `203_exact_selector_lock_migration.sh` | `gcc fresh-sandbox` | SKIP | +| `121_default_ns_redirect.sh` | `gcc fresh-sandbox` | SKIP | +| `205_root_local_subos.sh` | `elf gcc` | SKIP(合理,Linux 语义) | +| `206_runtime_binding_physics.sh` | `elf gcc` | SKIP(合理) | +| `207_runtime_contract_provenance.sh` | `elf python3 unix-shell` | SKIP(合理) | + +`205/206/207` 跳过是对的 —— 它们本来就是 Linux 运行时物理。 +但 `203`(**exact selector 的 lock 迁移**)和 `121`(**索引 default 重定向**) +是纯路径 + 纯身份逻辑,跟编译器无关,却因为 `gcc` 这个 token 从来没在 Windows 上跑过。 + +**这正是本次 bug 能溜过去的结构性原因:身份与路径的 bug 只在 Windows 上出现, +而验证身份与路径的测试只在 Linux 上跑。** + +**处理**:见 §3 主线 A 的 A3。 + +> 参见记忆 `issue375-retracted-c-runtime-and-subos-env`: +> `# requires:` 的死 token 让 `65_*` 从未在 CI 跑过。同一类问题,这次是"活 token 但选错了"。 + +### R5 · P2 · PR 体量与耦合 + +111 文件 / +15085,横跨四个互不依赖的产品域(包/模板身份、事务脚手架、 +运行时绑定与 ELF 闭包、release manifest + AUR reconciler),**外加一次版本 bump**。 + +现在拆已经不划算(31 个 commit + 不得改写历史的约束),所以**不建议拆 #400**。 +但两件事要做: + +- **发布分级**:AUR(R3)与 release manifest 是第一次真跑,按 §3 C3 单独放行, + 不要和身份语义同时"上线"; +- **记录规则**:下次同类工作按域拆 PR。一个 PR 只应该有一条可以独立回滚的主线。 + +### R6 · P2 · 版本号日期已过期 + +`mcpp.toml` / `src/version.cppm` 是 `2026.8.9.1`,今天已是 2026-08-10。 +按 `YYYY.M.D.N` 约定(记忆 `mcpp-date-version-convention`), +实际发版当天必须重新对齐(如 `2026.8.10.1`)。 +**这不是收尾时顺手改的东西** —— 它牵动 CHANGELOG、docs、release manifest、 +AUR 目标版本、bootstrap pin 的判据,必须作为独立 commit 在发版前执行。 + +### R7 · 待独立复核区(本文不下结论) + +以下三块是本 PR 新增的高风险实现,本轮只做了结构性抽查,**没有**做逐行审计, +因此**不给通过或不通过的结论**,只登记为"合并前需要一次独立 review pass": + +- `src/platform/elf_runtime.cppm`(+722):手写 ELF64 读取器。 + 抽查到 `off <= bytes.size() && size <= bytes.size() - off` 这类正确的溢出安全写法 + 和 `kMaxClosureObjects` 上界,形态是对的;但 verneed/verdef 遍历、 + SONAME 复用、`$ORIGIN` 展开需要针对畸形/截断输入的定向用例。 +- `src/build/runtime_validation.cppm`(+433):verdict 缓存。 + 键包含 artifact stat + `runtimeBinding.contractHash`, + 且 `Inconclusive` 会把 summary 从 `Pass` 拉下来 —— 方向正确。 + 需要专门确认的不变量:**缓存 miss/mismatch 必须保持失败,不得靠下一次运行变绿** + (PR 声称已实现,需要一个明确的 RED 用例锁住)。 +- `scripts/aur/reconcile_mcpp_bin.py`(+1132):12/12 契约测试 + 真实 Arch dry-run, + 但 fast-forward-only、vercmp 单调性、AUR RPC 滞后分类这三条只在**真实推送**时才被检验。 + 这就是 R3 要分级放行的原因。 + +--- + +## 3. 设计:三条主线 + +### 主线 A —— 让 latest-head Windows 变绿(修测试,不是修产品) + +#### A1. 修 fixture:`tests/e2e/12_add_command.sh:250` + +写进 manifest 的绝对路径必须是**宿主原生**路径。仓库已有先例: +`171_bmi_staging_locked_dest.sh:75` 用 `cygpath -w`。 + +TOML 里反斜杠要转义,所以用 **`cygpath -m`**(混合模式,`C:/Users/...`): +Windows API 接受正斜杠,TOML basic string 也不需要转义。 + +在 `tests/e2e/run_all.sh` 里导出一个共享 helper(新建 `_paths.sh` 或直接放进 run_all): + +```bash +# 把一个 shell 侧路径转成"写进 mcpp.toml 后原生 mcpp 能解析"的形式。 +host_path() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) cygpath -m "$1" ;; + *) printf '%s' "$1" ;; + esac +} +``` + +step (15) 改为: + +```bash +IDX="$(host_path "$TMP/myapp/index")" +... +acme = { path = "$IDX" } +``` + +**不要**改成相对路径。改成相对路径能让测试变绿,但会**顺手删掉"绝对路径索引" +这条唯一在 Windows 上被真正解析的覆盖** —— 那正是这次暴露出的盲区。 + +#### A2. `ed4cf64` 的处置:证明它,或者还原它 + +两条路,选一条,不允许"留着不动": + +- **A2-a(推荐)**:写一个 RED —— *workspace root 通过符号链接访问时, + 继承的相对 index 必须仍锚在声明的 root 上*。 + 如果 `weakly_canonical` 让它失败而 `lexically_normal` 让它通过, + 这条改动就被证明了,保留,并把该测试作为它的锁。 +- **A2-b**:还原为 `weakly_canonical`(回到 main 的绿行为)。 + +无论哪条,都要修 `tests/unit/test_pm_index_route.cpp:221`: +把 `EXPECT_EQ(path, …lexically_normal())` 换成能力断言 +(`route.describe(...)` 与 `lookup_descriptor` 命中), +**不要断言路径的字符串拼写**。 + +#### A3. 补 Windows 侧的身份/路径覆盖 + +- 把 `203_exact_selector_lock_migration.sh` 与 `121_default_ns_redirect.sh` 的 + `# requires:` 里的 `gcc` 去掉(若它们确实只做解析断言;若确有编译步骤, + 就把解析断言拆成一个不需要 gcc 的新用例)。目标:**exact selector 与索引路由 + 在三个平台都跑**。 +- 新增一条专门的 e2e:*绝对路径 `[indices]` 在三个平台都能解析*, + 内部使用 `host_path`,并**显式断言 `route:` 输出为 `root present, pkgs present`**。 + 这条测试的存在本身就是防止"MSYS 路径写进 manifest"再次发生的护栏。 +- 给 `run_all.sh` 加一条极简 lint:扫描 `tests/e2e/*.sh`, + 凡出现 `path = "$...` 形式且变量不是经 `host_path` 得来的,报错。 + (宁可稍微粗糙也要有 —— 这类错误人眼在 review 里看不出来。) + +#### A4. 保留 `route:` 诊断 + +`9a47ccf` 加的 `IndexRoute::describe` 是本轮**唯一让根因可判定**的东西: +没有它,日志只会说 "not found",谁都无法区分"索引没配"和"索引配了但路径不存在"。 +它应该保留,并按 A3 被测试固定下来。 + +--- + +### 主线 B —— 裸名迁移窗口(把 R2 从"生态阻塞"降级为"一次弃用警告") + +#### B1. 语义 + +对**省略命名空间**的选择器: + +1. 精确解析 `(mcpplibs, name)`; +2. **仅在 miss 时**,按上一版本的老顺序再试 `(compat, name)` 和 `(∅, name)`; +3. 若老 rung 命中:**正常解析**,同时打印一条弃用警告,内容包含 + - 实际选中的完整身份(`compat.ftxui`), + - 可直接粘贴的 manifest 片段(复用 `src/build/prepare.cppm` 里 miss 分支 + 已有的 "did you mean" 渲染,就在 `:2447` 那条 error 之前), + - 该 rung 将在下一个版本移除; +4. 老 rung 也 miss → 保持现在的硬失败与提示(不变); +5. **`mcpp.lock` 只写规范身份**(`compat.gtest`),绝不写裸拼写; +6. `mcpp add <裸名>` 命中老 rung 时,**把规范点分形式写进 `mcpp.toml`** + —— 用户第一次碰它就自动迁移。 + +关键点:**原缺陷是"静默",不是"回退"。** +`162_bare_name_namespace_scope.sh` 的注释原文写得很清楚: +"a bare name that matched nothing fell through to the FIRST candidate **SILENTLY**, +so mcpp carried on with a namespace it had invented"。 +一条带完整身份的警告已经把"静默"消灭了,同时保住了已发布的数据。 + +#### B2. 落点 + +| 文件 | 改什么 | +|---|---| +| `src/pm/dependency_selector.cppm` | 新增 `legacy_bare_candidates(name)`,与已有的 `legacy_prefixed_coordinate` 对称:只服务于"exact miss 之后",绝不进 exact 候选表 | +| `src/pm/index_route.cppm` | `Lookup` 增加"命中来自哪一 rung",让上层能区分 exact 命中与 legacy 命中 | +| `src/build/prepare.cppm:2447` 附近 | miss 后先试 legacy rung;命中则 warning + 继续;仍 miss 则维持现有 error | +| `src/pm/commands.cppm` | `mcpp add` 命中 legacy rung 时写规范点分形式 + warning | +| `tests/e2e/162_bare_name_namespace_scope.sh` | 两个分支都锁:裸 `gtest` **命中 compat 且必须出现弃用警告**;裸 `widget`(无处可寻)**必须硬失败** | +| `mcpp.toml` | 保持 `[dev-dependencies.compat] gtest`(这是规范写法,不回退) | +| `docs/spec/package-identity.md` + zh | 明确写出窗口的起止版本 | + +#### B3. 效果 + +- 四个红 job 立刻具备变绿条件,**不需要等 xlings #521 合并 + xlings 发版**; +- xlings #521 仍然是**正确**的修复,继续正常 review/合并/发版, + 但它从 mcpp 发布的关键路径上被摘下来; +- 用户升级不炸; +- 下一个版本移除窗口时,生态已经有一个完整 release 的警告期。 + +#### B4. 备选(不推荐,但记录) + +- **B-alt-1 生态优先**:不加窗口,先把索引里 34 个 `compat.*` 全部加 + `mcpplibs.` 桥接描述符(就是 mcpp-index #197 对 `capi.lua` 做的 Form-B bridge)。 + 缺点:把裸拼写永久固化进索引,与本 PR 的精确身份目标直接冲突。 +- **B-alt-2 硬切**:照现在合并,靠文档和 release note 通知。 + 缺点:所有存量用户 manifest 在升级瞬间失败,而错误信息虽然给了替换建议, + 但**无法自动修复**,也无法回退(旧 mcpp 已被 upgrade 提示引导升级)。 + +--- + +### 主线 C —— 收口顺序:把串行改并行 + +#### C1. 当前(交接文档 §8)是一条全串行链 + +``` +xlings#521 review → 合并 → xlings 发版 → mcpp 改 pin → mcpp 全矩阵 + → mcpp review → 合并 → mcpp 发版 → GitCode → AUR → fresh-home +``` + +任何一环卡住(尤其是跨组织的 xlings 发版)整条停摆。 + +#### C2. 加入主线 B 之后可以并行 + +``` +轨道 1(mcpp #400,自足) + A1 修 fixture ─ A2 处置 ed4cf64 ─ A3 补覆盖 ─ B 迁移窗口 + → latest-head 全矩阵 → 用户 review → 普通合并 → 发版 + +轨道 2(xlings #521,独立) + 用户 review → 普通合并 → xlings 发版 + → mcpp 独立小 PR 更新 pin(不阻塞轨道 1) + +轨道 3(对外发布面,独立放行) + release manifest 首跑验证 → AUR 手工 publish 验证 → 打开定时收敛 +``` + +#### C3. AUR 分级放行(对应 R3) + +1. **合并 #400 时**:`aur-publish.yml` 的 `schedule` 分支改为 `publish=false` + (或整段先不带 `schedule` 触发器合入)。合并不产生任何对外写。 +2. **手工验证一次**:`workflow_dispatch` 带 `publish=false` 看 plan, + 再带 `publish=true` 推一次,核验 AUR RPC 可见、`.SRCINFO`、两个架构 checksum、 + 以及在真实 Arch 上 fresh install。 +3. **验证通过后**,用一个只改触发器的独立小 PR 打开定时收敛。 + +这样"第一次真实对外推送"是一次**有人盯着的、可回退到上一步的**动作, +而不是合并后 6 小时内自己发生的事。 + +#### C4. 版本对齐(对应 R6) + +发版当天,独立 commit 统一 `mcpp.toml` / `src/version.cppm` / CHANGELOG / +docs / release manifest 目标 / AUR 目标版本。 +`.xlings.json` 的 mcpp bootstrap pin 保持 `2026.8.8.4`(上一个已发布版本)—— +按记忆 `release-bootstrap-pin-two-groups`,bootstrap pin 是自举起点,不随本次发布走。 + +--- + +## 4. 分阶段计划与验收判据 + +每一阶段都遵循 PR #400 已确立的流程:先 RED、最小实现、GREEN、 +隐私扫描、单文件暂存、一个逻辑变更一个 commit、立即 push、立即中文 checkpoint、 +不 amend/rebase/squash/force-push、不 admin bypass。 + +| 阶段 | 内容 | 验收判据(**判据是原生 CI 终态,不是本机**) | +|---|---|---| +| **P0** | A1 `host_path` + step (15) fixture | Windows E2E 2/2 中 `12_add_command.sh` PASS;Linux/macOS 不回归 | +| **P1** | A2 处置 `ed4cf64`;重写 `test_pm_index_route.cpp:221` 为能力断言 | 若走 A2-a:符号链接用例先 RED 后 GREEN;若走 A2-b:还原后三平台全绿 | +| **P2** | A3 Windows 身份/路径覆盖 + `run_all.sh` lint | `203`/`121` 在 Windows 上由 SKIP 变 PASS;新绝对路径索引用例三平台 PASS;lint 对故意构造的坏 fixture 报错 | +| **P3** | B 裸名迁移窗口 | `162` 两个分支都 GREEN;**四个 xlings 相关 job 由 fail 变 pass**;`mcpp.lock` 里不出现裸拼写 | +| **P4** | R7 独立 review pass(ELF / verdict cache / AUR reconciler) | 三块各自补齐定向 RED;verdict 缓存 mismatch 必须保持失败的用例存在 | +| **P5** | C4 版本对齐 + 文档 + 验证账本 | 版本号 = 实际发版日期;`.agents/docs/...validation.md` 里所有 "not started" 段落被终态替换 | +| **P6** | 用户 review → 转 ready → 普通合并 | 全矩阵**同一个 HEAD**上 terminal 全绿;pending/skipped/cancelled/superseded 一律不算 PASS | +| **P7** | 发版 + release manifest 首跑核验 | tag、四平台资产、sidecar、不可变 `mcpp-release.json` 全部核验;索引 main 的 latest 指向它 | +| **P8** | AUR 分级放行(C3 三步) | AUR RPC 可见目标版本;真实 Arch fresh install 通过;之后才打开 `schedule` | +| **P9** | GitCode / xim-pkgindex / fresh-home 全生态 | 隔离 HOME/XLINGS_HOME/SubOS 下:安装、`[ns.]name[@version][:tname]`、唯一默认模板、多 SubOS/glibc、build/run/test、provider provenance 的 PASS/FAIL/NOT_EXERCISED 表 | +| **P10** | issue 收口 | #398 随 PR 关闭;#380/#392/#396 凭发布后二次验收证据关闭;残留写回 #397 | + +**并行关系**:P0–P3 是 #400 的关键路径;xlings #521 的 review/合并/发版 +与 P0–P6 完全并行,其 pin 更新是合并后的独立小 PR。 + +> 关于"发布完成"的判据,沿用记忆 `release-publish-pipeline`: +> **判据是索引 main 的 latest 指向它**,不是 tag 存在、也不是 PR 已合。 +> `gh pr merge` 不带 `--admin` 可能是静默空操作(退出 0),合完必须回查 `state`。 + +--- + +## 5. 需要你拍板的决策点 + +| # | 决策 | 选项 | 我的建议 | +|---|---|---|---| +| **D1** | 裸名迁移窗口 | (a) 加一个 release 的弃用窗口(主线 B)
(b) 索引侧加 34 个 `mcpplibs.*` 桥接
(c) 硬切,只靠文档通知 | **(a)** —— 与本 PR 自己对 `ns:name` 的处理一致;能保住存量用户;并把 xlings 摘出关键路径 | +| **D2** | `ed4cf64` 的 `weakly_canonical → lexically_normal` | (a) 用符号链接 RED 证明并保留
(b) 还原到 main 的行为 | **(a) 先试**,一天内证不出来就走 (b)。不允许"留着不动" | +| **D3** | AUR 首次放行 | (a) 合并时 `schedule` 降级为 dry-run,手工验证后再打开
(b) 照现状合并 | **(a)** —— 第一次对外真实推送应该有人盯着 | +| **D4** | 发版版本号 | (a) 发版当天重新对齐为 `2026.8.{当天}.1`
(b) 保持 `2026.8.9.1` | **(a)**,按日期版本约定 | +| **D5** | R7 三块的 review 深度 | (a) 合并前补定向 RED
(b) 合并后跟进 | **(a) 只对 verdict 缓存那条不变量**("mismatch 不得靠重跑变绿"),其余可 (b) | + +--- + +## 6. 明确不做 + +- **不拆 #400**(31 commit + 不改写历史的约束下,拆的成本大于收益); + 规则记录下来给下一次。 +- **不在 mcpp 里恢复"静默"的跨命名空间回退**。主线 B 的窗口是**带警告、 + 写规范身份进 lock、`add` 时自动迁移 manifest** 的,与被修掉的静默回退不是同一件事。 +- **不动 `mcpp-m`**(文件、远端、发布路径)与 `mcpp-git`。 +- **不引入 `--variant`、不引入 CLI SubOS override、不让 mcpp 探测 GPU/驱动/ICD** + —— #398 冻结的四条产品边界不变。 +- **不处理 #397 的 C1–C9**。它们是独立缺陷(fast path 不重建 CDB、 + 模块扫描器 raw-string 假阴性、`--no-color` 空操作 …), + 与本 PR 无关,按 #397 自己的节奏走。 +- **不碰特殊保留项**:#43、#260,以及标记为保留/Draft/do-not-merge 的项。 + +--- + +## 附:本文结论的可复核路径 + +| 结论 | 复核方式 | +|---|---| +| 失败在 step (15) 而非 (14) | `gh api repos/mcpp-community/mcpp/actions/jobs/93271793304/logs`,看 `exit 2` 与缺失的横幅 | +| step (15) 是本 PR 新增 | `git diff origin/main...HEAD -- tests/e2e/12_add_command.sh` | +| main 的 Windows E2E 是绿的 | run `31275102045` | +| `ed4cf64` 前后失败一致 | run `31318089536` vs `31321961040` | +| 四个 fail 同因 | job `93271797138` / `93271798469` / `93271802557` / `93271790099` | +| AUR `schedule` 即 publish | `.github/workflows/aur-publish.yml` 的 plan 步骤 | +| 身份 e2e 在 Windows 被跳过 | Windows E2E 日志中的 `SKIP: 203_… (missing capability: gcc)` | + +--- + +## 附录 A:实施结果(2026-08-10 收尾) + +本节是执行完 D1–D5 之后回填的**事实**,不是计划。凡与正文冲突的,以本节为准。 + +### A.1 D1–D5 的落地结果 + +| 决策 | 结果 | +|---|---| +| **D1** 裸名迁移窗口 | 已实施。精确未命中 + namespace 被省略 → 试 `(compat,name)` 与无 namespace rung;命中打印弃用警告 + 可粘贴片段,规范身份进 lock/install/cache;`mcpp add` 直接迁移 manifest。`2026.9` 移除,常量 `kBareNameFallbackRemovedIn`。 | +| **D2** `ed4cf64` 证明或还原 | **已证明,保留**。换回 `weakly_canonical` 让 `unit/test_pm_index_route` 失败(71 passed / 1 failed)。但理由与原提交描述不同:不是 Windows 短名,而是「anchoring 不得把 index 移出作者声明的那棵树」——经符号链接访问的 workspace 会被重定位,而 `prepare` 的报错会打印这个路径。 | +| **D3** AUR 首次放行 | 已实施。`workflow_run` 与 `schedule` 都需要仓库变量 `AUR_AUTOPUBLISH == "true"` 才推送;契约测试直接执行 workflow 自己的判定 shell。 | +| **D4** 版本对齐 | 已实施。`2026.8.9.1` → `2026.8.10.1`(版本号是日期,评审期间日期变了;`2026.8.9.1` 从未打 tag)。 | +| **D5** verdict 缓存不变量 | 已实施为 e2e `209`。**该不变量本来就是对的**(`validated_artifact_snapshot` 在任一 stored status ≠ Pass 时拒绝快速路径),只是没有测试;现在有了。 | + +### A.2 P0 的根因判定被推翻了一次,值得单独记住 + +正文 §1 的推断(step (15) 的 MSYS 路径)经原生 CI 证实。三条可复用的判据: + +1. **先看退出码,再看错误文本。** 被 `|| { …; exit 1; }` 包住的步骤失败只能是 exit 1; + 日志里的 exit 2 是 mcpp 自己的码经 `set -e` 直传,只可能来自裸调用。 +2. **失败横幅缺席本身是证据。** harness 会显示脚本 stdout,所以「该步骤失败时必然打印的 + 那行」没出现,就说明失败不在那一步。 +3. **Wine 通过 = 零信息**(对路径语义而言)。Wine 把 `Z:` 映射到 `/`, + `\tmp\…` 落回真实 `/tmp/…`。 + +修法按「一类」而不是「一行」:`_host_path.sh` + `00_fixture_path_hygiene.sh` lint + +全仓 24 文件 40 处迁移。大多数此前只是因为对应测试在 Windows 上因缺 capability 而 SKIP。 + +### A.3 计划外收进来的两项 + +- **#401(私有 glibc 泄漏进子进程环境)。** 已修,收敛为新模块 + `src/platform/runtime_env_contract.cppm` 的**作用域**决策(不是条件判断)。 + 实测对照:已发布 mcpp `LDLP=[…/runtime:…/xim-x-glibc/2.39/lib64]`,本分支 `LDLP=[…/runtime]`; + 产物 RUNPATH 改动前后逐字节相同,说明这条环境项本来就没有收益。 +- **覆盖盲区。** 身份/索引路由的 e2e 全部要 `gcc` 或 `fresh-sandbox`,Windows 两者皆无。 + 新增 `210_local_index_addressing_on_every_host.sh`:无编译器、无沙箱、无网络,三平台都跑。 + +### A.4 主线 C 的实际阻塞点变了 + +正文假设瓶颈是「等 xlings #521 合并 + 发版」。加入 D1 的迁移窗口后,#521 确实离开了关键路径。 + +**但按要求把 xlings pin 提到最新 `2026.8.10.1` 之后出现了新的、更硬的阻塞:** +冷 home 装不上 `xim:gcc@16.1.0` —— 依赖解析下载 glibc 2.44,而 gcc 的 config hook +找不到该 payload(诊断建议的是 2.39)。同一 workflow、同一 runner 镜像、两次都确认 +cache miss 的 A/B: + +| xlings | 冷缓存 | 结果 | +|---|---|---| +| `2026.8.9.2` | 已确认 cache miss | gcc 安装成功(run `31317627461`) | +| `2026.8.10.1` | 已确认 cache miss | 失败(run `31335075557`,4/4 Linux job) | + +所以不是「一直坏、被热缓存掩盖」。已带证据上报 +[openxlings/xlings#524](https://github.com/openxlings/xlings/issues/524), +mcpp 侧把 pin 停在 `2026.8.9.2`,等修复发布后用独立 commit 提升。 + +> 这条本身也是一个判据:**「pin 到最新」是一个需要被验证的动作,不是一次文本替换。** +> 它之所以在这里被抓到,只是因为换 pin 同时换掉了 CI 缓存键,把冷启动路径暴露了出来。 diff --git a/.agents/docs/2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md b/.agents/docs/2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md new file mode 100644 index 00000000..a16fe01a --- /dev/null +++ b/.agents/docs/2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md @@ -0,0 +1,500 @@ +# 图形栈剩下的那一半:链接期看得见、运行期看不见 + +> 日期:2026-08-11 +> 基线:mcpp `main` `1bc6074`(源码版本 `2026.8.11.1`,本机已装 `2026.8.10.3`)、 +> xlings `2026.8.11.2`、本机 x86_64-linux-gnu / gcc 16.1.0 / NVIDIA + X11 +> 来源:xlings [#532](https://github.com/openxlings/xlings/issues/532)(构建侧标签,mcpp 侧已完成一半)、 +> [#537](https://github.com/openxlings/xlings/issues/537)(subos info 低报)、 +> [#540](https://github.com/openxlings/xlings/issues/540)(E2b 落地前的接口谈判)、 +> [#543](https://github.com/openxlings/xlings/issues/543)(Windows 上 `mcpp build|test` 直接失败) +> 前置阅读(只需结论):`2026-08-10-graphics-closure-and-distribution-tiers-design.md` +> +> 本文所有"已验"结论都在本机跑过,命令与输出在正文里。凡未实测一律标 **未验**。 +> file:line 全部核于上述基线。 +> +> **实施状态(2026-08-11):全部已实施,版本 `2026.8.11.2`。** +> 实施计划见 `2026-08-11-graphics-runtime-search-closure-implementation-plan.md`。 +> **与本文的三处出入,以实施为准:** +> - **§2.6 的"`system` 档剥离"不需要写代码** —— 实测 `--mode system` 早就把 rpath +> 清空并把 `PT_INTERP` 改回 `/lib64/ld-linux`,产物里没有任何 `$MCPP_HOME` 路径。 +> 真正的缺口是 §2.6 那条**已有守卫抓不到它**,所以这一项从"实现"变成"补测试"。 +> - **§2.6 关于"从 farm 解析到的 NEEDED 升级为 host requirement"没有做**。 +> `HOST-REQUIREMENTS` 存在的理由是记录**产物本身看不出来**的东西(经 dlopen 链 +> 到达的驱动);而一个 `NEEDED` 本来就写在产物里,再抄一遍是冗余。 +> - **§2.1 说 farm 走 `linkIntent.runtimeSearchDirs`,实施改成了 `BuildPlan` 上的 +> 独立字段**。原因见实施计划 §4:那个字段还喂 `runtimeLibraryDirs`,而后者会变成 +> `mcpp run` 的 `LD_LIBRARY_PATH` —— **farm 绝不能进环境变量**,那正是本文 +> §6"不做什么"里禁掉的东西。差点自己踩进去。 + +--- + +## 0. 结论先行 + +这一轮要修的是**两个缺陷**,它们看起来一个属于图形、一个属于 Windows,底下是同一句话: + +> **一个决策在一处做了,在另一处没做 —— 而"没做"的那一处不报错。** + +| | 缺陷 A(图形) | 缺陷 B(xlings#543) | +|---|---|---| +| **一句话** | subos 在**编译/链接**期是 sysroot,在**运行**期不是任何东西 | subos **没有声明**,被当成 subos **有矛盾** | +| **症状** | `mcpp build` rc=0,`./bin/app` → `libGL.so.1: cannot open shared object file` | `mcpp build\|test` 直接 error 退出,文案在讲 GL 驱动 —— 在一台 Windows 上 | +| **判据行** | `flags.cppm:797`(运行期路径只有载荷目录) | `runtime_binding.cppm:217`(`!info.present` ⇒ 硬失败) | +| **谁掩盖了它** | `elf_runtime.cppm:282` 回落到**宿主**默认目录 ⇒ `validation: pass` | 无 —— 它很响,只是响错了地方 | +| **回归窗口** | 一直如此(不是回归) | **PR #400**,首次随 `2026.8.10.2` 发布 | + +两条修法也是同一句话的两面: + +> **A:运行期的搜索集合必须与链接期的视图同源(同一个 binding 推导一次)。** +> **B:缺声明必须降级为"未验证",不得升级为"构建失效"。** + +本轮**不新增任何用户可见语法**。用户侧的可见变化只有一条:原来链得上、跑不起来的图形程序,现在跑得起来。 + +--- + +## 1. 实测基线(先看现场,再谈设计) + +### 1.1 复现:零 flag 的 GL 程序 + +```toml +# mcpp.toml +[package] +name = "glprobe" +version = "0.1.0" +standard = "c++23" + +[build] +ldflags = ["-lGL", "-lX11"] +``` + +```console +$ mcpp build + Finished dev [unoptimized + debuginfo] in 0.05s # rc=0 ✅ + +$ readelf -d target/x86_64-linux-gnu/*/bin/glprobe + (NEEDED) [libGL.so.1] + (NEEDED) [libX11.so.6] + (NEEDED) [libm.so.6] [libgcc_s.so.1] [libc.so.6] + (RPATH) [/data/xpkgs/xim-x-glibc/2.39/lib64: + /data/xpkgs/xim-x-gcc/16.1.0/lib64] ← 没有 /lib + +$ ./target/.../bin/glprobe +error while loading shared libraries: libGL.so.1: cannot open shared object file # rc=127 ❌ + +$ mcpp run # rc=1,同样的错误 +$ mcpp why runtime +validation: pass (source post_link) # ⚠️ 假绿 + - …/bin/glprobe: pass +``` + +**顺带记一条**:`[build] libraries = ["GL"]` 不是合法键(`warning: unsupported key 'libraries' (ignored)`), +今天唯一的写法是 `ldflags = ["-lGL"]`。这属于用户语法层,**本轮范围外**(见 §6)。 + +### 1.2 为什么链接期通得过:subos 已经是 sysroot 了 + +`build.ninja` 头部(原文): + +```ninja +cxxflags = -std=c++23 -fmodules -O0 -g --sysroot=/subos/default -B<…>/xim-x-binutils/2.42/bin +ldflags = --sysroot=/subos/default \ + -Wl,--dynamic-linker=<…>/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2 \ + -L<…>/xim-x-glibc/2.39/lib64 -Wl,-rpath,<…>/xim-x-glibc/2.39/lib64 \ + -L<…>/xim-x-gcc/16.1.0/lib64 -Wl,-rpath,<…>/xim-x-gcc/16.1.0/lib64 \ + -B<…>/xim-x-binutils/2.42/bin -specs=<…>/mcpp-clean-link.specs -lGL -lX11 + +build bin/glprobe : cxx_link obj/main.o + unit_ldflags = -static-libstdc++ -Wl,--disable-new-dtags ← 标签契约,已生效 ✅ +``` + +`-lGL` 之所以解析得到,是因为 `--sysroot=` 让 ld 把 `/lib` 当默认库目录。 +**mcpp 今天已经把整个 subos 当作自己的根**,只是这个事实没有传到运行期。 + +| 阶段 | 对 subos 的视图 | 机制 | 状态 | +|---|---|---|---| +| 编译 | 整个 subos | `--sysroot=`(`linkmodel.cppm:83`) | ✅ | +| 链接 | 整个 subos | `--sysroot=`(`linkmodel.cppm:109`) | ✅ | +| **运行** | **两个载荷目录** | `flags.cppm:800-803` 只遍历 `plan.toolchain.linkRuntimeDirs` | ❌ | + +**这不是"缺一个 flag",是同一个决策在两处独立推导,而其中一处的输入集合小了一整个 farm。** + +### 1.3 假绿的机制:闭包模型用错了加载器 + +`elf_runtime.cppm:262-288` 的 `resolve_needed()` 搜索顺序: + +```cpp +for (auto const& raw : requester.runpaths) append_unique_path(dirs, expand_origin(raw, …)); +for (auto const& dir : additionalSearchDirs) append_unique_path(dirs, dir); +for (auto const& dir : binding.libraryDirs) append_unique_path(dirs, dir); +for (auto const& dir : host_library_dirs()) append_unique_path(dirs, dir); // :282 +``` + +`host_library_dirs()`(`:245`)返回 `/lib/x86_64-linux-gnu`、`/usr/lib64`、`/usr/lib`…… +本机实测宿主确实有 `/usr/lib/x86_64-linux-gnu/libGL.so.1` ⇒ `libGL.so.1` **在模型里解析成功**, +`resolution.unresolved` 为空,判决落到干净的 `Pass`。 + +**但产物跑在私有 loader 下**(`PT_INTERP` = 载荷 2.39 的 `ld-linux`),它的内建默认路径里没有宿主目录。 + +> **闭包模型模拟的是宿主加载器,产物用的是私有加载器。两者在"默认目录"这一项上不同, +> 而这一项恰好是唯一没被 binding 覆盖的输入。** + +即使 `unresolved` 非空,后果也只是 `inconclusive`(`elf_runtime.cppm:759-768`), +而 `has_proven_mismatch()`(`runtime_validation.cppm:41-47`)只对 `ProvenMismatch` 失败。 +**⇒ 今天没有任何一条路径能让"这个产物加载不了"变成红色。** + +这条要和 §2.1 一起修:**只修路径不修判据,等于把新行为建在一个不会响的报警器上面。** + +### 1.4 ⚠️ 为什么不能直接用 `$XLINGS_SUBOS_LIB` + +这是本轮最硬的一条实测,它直接否掉"加两个 flag 就完事"的写法: + +```console +$XLINGS_SUBOS_LIB = ~/.xlings/subos/current/lib + └─ libc.so.6 → ~/.xlings/data/xpkgs/xim-x-glibc/2.39/lib/libc.so.6 + +mcpp 的 RuntimeBinding = ~/.mcpp/registry/subos/default/lib + └─ libc.so.6 → ~/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib/libc.so.6 +``` + +**两个 home、两份物理 glibc 载荷、两个不同的 subos。** mcpp 有自己的 registry home, +它解析的 subos 与 shell 的 active subos **不是同一个**,并且没有任何机制保证它们同步。 + +把环境变量里的 farm 塞进 DT_RPATH,等于给产物挂上一个 mcpp 不管理、不 pin、随时会被 +`xlings use` 换掉的第二套 libc / libstdc++ —— 正是 rule B 存在的那句话: +*"one process cannot mix runtime payloads"*。 +本机两边版本号恰好都是 2.39,所以今天侥幸能跑;**版本一旦不同就是静默错**。 + +> **⇒ farm 路径只能从 mcpp 自己已解析的 `RuntimeBinding.subosDir` 推导。环境变量在这里是噪声,不是契约。** + +### 1.5 xlings#543:Windows 上直接失败 + +报告(xlings 2026.8.11.1 + mcpp 2026.8.10.3,Windows 11): + +``` +error: selected SubOS 'default' cannot provide a RuntimeBinding: + subos 'C:\Users\…\.mcpp\registry\subos\default' does not describe itself + (no `subos_info` block), so programs run from here get no environment it + declares — a GL application will not find its drivers. … +``` + +三处都错: + +1. **它是硬失败。** `runtime_binding.cppm:216-221`,`resolve_runtime_binding` 在 + `!info.present` 时返回 `unexpected`;`prepare.cppm:1292-1294` **无平台条件地**调用它并 + `return std::unexpected(...)`。⇒ Windows 上每一次 `mcpp build` / `mcpp test` 都停在这里。 +2. **它在 Windows 上讲 GL 驱动。** 这段文案是给 Linux 图形场景写的,被一个跨平台调用点复用了。 + Windows 既没有 ELF、没有 `PT_INTERP`、没有私有 libc,`validate_runtime_artifact` 自己第一句就是 + *"non-Linux platform; ELF/glibc rules are not applicable"*(`elf_runtime.cppm:629-633`)。 +3. **回归窗口已确认。** `git log -S "cannot provide a RuntimeBinding"` 只命中 `0006ce6`(PR #400), + 且 `git merge-base --is-ancestor 54d29fc 0006ce6` 为真 ⇒ #400 在 `2026.8.8.4` 之后落地, + **首次随 `2026.8.10.2` 发布**。与报告"降级到 2026.8.8.4 就能跑"完全吻合。 + +**同一位置还埋着第二颗雷**(尚未爆,爆了就是全平台): + +```cpp +// runtime_binding.cppm:222 +if (info.schema != mcpp::xlings::subos::kSupportedSchema) { /* hard error */ } +``` + +而它的**上游读取器**明写着相反的规矩(`subos_info.cppm:45-49`): + +> *"A HIGHER one on disk is still read — we take the fields we know and say so. +> Refusing outright would let a newer xlings break an older mcpp, which is the failure +> shape the index-floor incident already paid for once: publishing data must not +> invalidate the program that reads it."* + +**读取器按"上限"设计,消费者按"相等"检查。** xlings 写出 schema 2 的那一天, +所有平台上的所有 mcpp 构建同时停摆。这是 index-floor 事故的第二次转生,必须一起修。 + +--- + +## 2. 缺陷 A:运行期搜索闭包 + +### 2.1 A1 — farm 进 `runtimeSearchDirs`,从 binding 推导,排在末位 + +**做什么**:`RuntimeBinding` 增加一个字段 `searchDirs`(farm 目录,通常是 `/lib`), +由 `resolve_runtime_binding` 在**已有的那次探测**里顺手产出 —— 就是 +`runtime_binding.cppm:253-284` 里已经在走的 `{subosDir/"lib64", subosDir/"lib"}` 循环。 +**不新增一处布局知识**:今天那个循环是为了找 `libc.so.6`,顺路把命中的目录本身记下来即可。 + +然后经**已有的专用通道**下发: + +``` +RuntimeBinding.searchDirs + → BuildPlan.linkIntent.runtimeSearchDirs (plan.cppm,与包描述符的同一个字段汇合) + → render_link_intent_flags → "-Wl,-rpath," (flags.cppm:304-310) +``` + +选这个通道不是随意的,`flags.cppm:804-806` 的注释已经把语义写死了: + +> *"runtimeSearchDirs contributes RUNPATH only; it must never become a link-time `-L` path."* + +**正是我们要的**:链接期的解析已经由 `--sysroot` 覆盖(§1.2 实测),这里只补运行期。 +**不发 `-L`** 还额外省下链接行长度(128KiB 上限,真实 workspace 已用掉 43%)。 + +**排序不变式(硬要求)**: + +> **farm 目录必须排在所有载荷目录之后。** + +理由是**可变性**,不是风格: + +| 目录 | 可变性 | 谁在写 | +|---|---|---| +| `/xim-x-glibc/2.39/lib64` | **不可变** | 装一次就不再动 | +| `/lib` | **可变** | 每次 `xlings install` / 重解析都重写符号链接 | + +载荷目录在前 ⇒ `libc.so.6` / `libm.so.6` / `libstdc++.so.6` 永远从被 pin 的载荷解析, +farm **只补没人提供的那些**(libGL / libX11 / libEGL / libwayland / libvulkan…)。 +farm 若在前,一次 `xlings install` 就能在事后悄悄换掉一个**已经构建好**的产物的 libc。 + +**这一条要被断言,不能只被写下来**(§5.1)。 + +### 2.2 A2 — 与 2026-08-10 那份设计的分歧:不是推翻,是"不继承 ≠ 不使用" + +上一份设计明确写过两条,必须正面回应: + +> §1.3.1 表格:**`-rpath /lib`(路径)| mcpp 必须不继承** +> §不做什么:**不把 `/lib` 放进任何环境变量或全局搜索路径。……vendor 只能按对象可达(RPATH)。** + +逐条对齐: + +| 上一份的原话 | 本轮的位置 | +|---|---| +| "不把 `/lib` 放进**任何环境变量或全局搜索路径**" | **完全保留**。本轮不碰 `LD_LIBRARY_PATH`,不碰任何全局路径。§1.4 又给它加了一条独立证据 | +| "**vendor 只能按对象可达(RPATH)**" | **本轮正是在执行这一条** —— 把 farm 放进产物**自己的 DT_RPATH**,per-object,不外溢 | +| "`-rpath /lib` mcpp 必须**不继承**" | **保留,并加强**:mcpp 不接受**别人注入**的那条路径(§2.5 声明式退出),而是发**自己推导**的那条 | +| 理由:"一条**未经审查的**、带 libc 的目录" | 关键词是**未经审查**。而 `/lib` 恰恰是 mcpp **解析自己 libc 时穿过的那个目录**(`runtime_binding.cppm:257-262` 就是从 `/lib/libc.so.6` 跟到载荷的)。它不是第二个 libc,是**同一个 libc 的第二条路径**;再叠上排序不变式,连"第二条路径"都不会被走到 | + +还有一条更硬的、上一份没有的证据: + +> **链接期已经在信任 farm 了。** `--sysroot=` 意味着 `-lGL` 就是从 `/lib` 解析出来的。 +> 运行期拒绝同一个目录,不构成任何安全边界 —— 只构成"链得上、跑不了"。 + +**⇒ 分歧点只有一处、且是措辞层面的:"不继承"被读成了"不使用"。本轮把它读回"不继承"。** + +### 2.3 A3 — 闭包判据必须能变红 + +两处改动,缺一不可: + +**(a) 默认目录按 binding 分档。** `resolve_needed`(`elf_runtime.cppm:282`)的 +`host_library_dirs()` 回落**只在 binding 就是宿主运行时**(非 hermetic、无私有 `PT_INTERP`)时才加入。 +hermetic binding 的默认集合 = 产物自身 RPATH/RUNPATH + binding 载荷目录 + binding farm。 +今天这个回落把宿主的 `libGL.so.1` 冒充成答案,是 §1.1 那个 `pass` 的直接成因。 + +**(b) "解析不到"要有自己的名字。** 今天 hermetic 产物的一个无法解析的 `NEEDED` +是**可证的失败**(私有 loader 一定打不开),把它归到 `Inconclusive` 是**把可证的事说成没查过**。 +`RuntimeVerdict::Status` 增加 `UnresolvableNeeded`,并让 `runtime_validation` 的失败门收下它。 + +> 三值的用途是区分"没查"和"查了没事"。**"查了,而且证明它跑不起来"是第四种,不该被前两种吸收。** + +分档保留了 `gcc@system` 一类非 hermetic binding 的正确行为:那里宿主目录**确实**是加载器的默认值。 + +### 2.4 A4 — 两条护栏 + +| 护栏 | 规则 | 为什么 | +|---|---|---| +| **交叉目标** | 目标三元组与 binding 的 platform/arch 不一致时,**不发 farm** | farm 是宿主 subos 的产物。aarch64-musl / mingw / wasm 目标拿到一条 x86_64-glibc 的路径,轻则无效重则误解析 | +| **非 ELF** | 与标签契约同构:Mach-O / PE **无此概念**,不进任何分支 | `loader_contract` 已经用 `NotApplicable` 立过这个形状,照抄 | + +### 2.5 A5 — 对 xlings E2b 的声明式退出:mcpp 主动关掉,不是被动容忍 + +xlings#540 的 E2b 会让 `ld` 包装器追加 `-rpath "$XLINGS_SUBOS_LIB" --disable-new-dtags`。 +它落地之后,mcpp 链出来的产物会同时拿到**两条** farm 路径:mcpp 推导的那条,和包装器注入的那条 —— 而后者 +在 §1.4 已被实测证明**可能指向另一个 home**。 + +**mcpp 在它落地之前就把退出声明出来**:mcpp 驱动的每一次链接,环境里带上 +`XLINGS_SUBOS_LD_PATHS=0`(键名以 xlings#540 最终敲定的为准)。 + +- 今天设它 = 无操作(变量还没人读),**零风险**; +- E2b 落地当天自动生效,mcpp 的 DT_RPATH **仍然只包含 mcpp 决定的内容**; +- 与标签那一半的处理**同构**:mcpp 覆盖大量不经过那个 `ld` 的链路(交叉 musl / mingw、 + `-fuse-ld=lld`、`gcc@system` / `msvc@system`、host 工具子构建),**必须处处一个答案**。 + +> **这一条是本设计对 xlings 唯一的跨仓依赖,而且是"先声明后生效"式的 —— 不阻塞本轮实施。** +> 若 xlings 最终选择了别的退出形式,只改这一个常量。 + +### 2.6 A6 — `mcpp pack` 三档行为 + +`pack` 的依赖解析走的是同一个闭包(`elf_runtime`),所以 A1 落地后 +**pack 才第一次有能力看见 libGL / libX11** —— 今天它连这些依赖都找不到,更谈不上打包或申报。 + +| 档位 | farm 路径 | 从 farm 解析到的 `NEEDED` | +|---|---|---| +| `vendored` / `self-contained` | **天然消失**:`set_search_path`(`pack.cppm:408-426`)整体重写为 `$ORIGIN/../lib` | 随 bundle 走(vendor 驱动除外,见下) | +| `static` | 不适用 | — | +| **`system`** | **剥掉**(本轮新增) | **升级为 `HOST-REQUIREMENTS` 条目**(`host_requirements.cppm` 已有机制) | + +`system` 档的语义是"依赖由目标机器提供",而一条构建机绝对路径**不是**目标机器提供的东西 —— +它是一条会跟着 tarball 走、到别的机器上指向不存在或不同内容的目录的路径。剥掉之后信息不丢: +它变成 `HOST-REQUIREMENTS` 里可读的一行。 + +#### ⚠️ 已有的守卫抓不到它 —— 这一条是核验时发现的,必须一并修 + +`tests/e2e/215_pack_has_no_build_machine_paths.sh` 正是为"产物不得残留构建机路径"存在的,但它的 +机器本地前缀只取到 store: + +```bash +STORE="$(cd "$MCPP_HOME/registry/data/xpkgs" && pwd)" # 215:113 +… if [[ "$paths" == *"$STORE"* ]] ; then FAIL … # 215:124 +``` + +而 farm 在 **`$MCPP_HOME/registry/subos/default/lib`** —— **不在 `data/xpkgs` 之下**。 + +> **⇒ A1 落地后,一条泄漏的 farm 路径会让 215 保持绿色。** + +两处补齐,与 A6 同一个 PR: + +1. **前缀扩到整个 mcpp home**(至少加 `registry/subos`)。判据应当是"这台机器的私有状态", + 而 store 只是它的一个子集 —— 今天这条断言的**覆盖面窄于它自己的标题**。 +2. **补 `--mode system` 的用例**。215 只跑默认档(`vendored`);`system` 档从未被任何用例 + 检查过路径泄漏,而它恰恰是唯一不重写 rpath 的那一档。 + +**vendor 驱动无论哪一档都不进 bundle** —— 它与运行中的内核模块版本锁定,且专有栈禁止再分发。 +这是 `host_requirements` 模块开篇就写明的事,本轮不动。 + +--- + +## 3. 缺陷 B:缺声明必须降级,不得使构建失效(xlings#543) + +### 3.1 B1 — `!info.present` ⇒ 降级 + +`resolve_runtime_binding` 返回一个**总是成功**的 binding,携带 `present` 与 `note`: + +| 情况 | 今天 | 改为 | +|---|---|---| +| 无 `.xlings.json` / 无 `subos_info` 块 | **hard error**(全平台) | `present=false` + `note`,**构建继续**;rule A/B 报 `Inconclusive` 并说明是因为**没有声明** | +| `runtime` 字段为空 | hard error | 同上 | +| 用户 `[xlings] subos = "x"` 而 `x` 不存在 | hard error | **保留 hard error** | + +最后一行是这条规则的边界,值得单独说清: + +> **矛盾要报错,缺席要降级。** +> "你点名要 subos `x`,而 `x` 不在" 是矛盾 —— 用户的输入无法被满足。 +> "subos 存在但没有自我描述" 是缺席 —— 有一部分事实不可知,**其余全部照常可用**。 +> 今天这两者被同一条 `return unexpected` 处理。 + +这与 `subos_info.cppm` 自己的 *"A NOTE ON SILENCE"* 一节完全一致:那一节要求每一次降级都填 `note` +并由调用方打印 —— 它从设计上就假定了**降级是存在的**。今天没有降级路径,只有失败路径。 + +**Windows 上的具体后果**:binding 退化为 `platform="windows"` + `present=false`, +没有 loader、没有 libc、没有 farm;`validate_runtime_artifact` 走它第一句已有的 +*"ELF/glibc rules are not applicable"*;`mcpp build` / `mcpp test` 正常完成。 + +### 3.2 B2 — schema 检查改成上限,与读取器对齐 + +```cpp +- if (info.schema != kSupportedSchema) → hard error ++ 高于 kSupportedSchema → 读懂的字段照用,note 说明忽略了什么(读取器已经这么做了) ++ 低于/等于 → 照用 ++ 结构性无法使用 → present=false + note(走 B1 的降级) +``` + +**判据一句话**:**发布数据不得使读它的程序失效。** 这是 index-floor 事故的原话, +`subos_info.cppm:45-49` 也已经把它写进注释 —— 只是消费者没照做。 + +### 3.3 B3 — 文案按调用方分层 + +那句 *"a GL application will not find its drivers"* 是**图形场景**的解释,不是 binding 层的事实。 +binding 层只说事实(*"subos '' 没有 `subos_info` 块;运行期环境声明不可用"*), +GL 那一句留给真正与图形相关的调用点。 + +**判据**:一条诊断不该提到调用方**可能根本不存在**的概念。在 Windows 上讲 GL 驱动, +使读者去找一个不存在的问题 —— 与 xlings#537 里 `subos info` 低报 EGL 是同一种伤害。 + +### 3.4 B4 — `The system cannot find the path specified.` + +报告里这一行**在 2026.8.8.4 上同样出现**(只是当时不致命)⇒ **它是独立的、更早的缺陷,不是本次回归**。 +该文案是 `cmd.exe` 的,说明有一处**经 shell 调用了一个在 Windows 上不存在的路径**。 + +**本轮只做到"定位并单开 issue",不盲改**:没有 Windows 现场,任何修法都是猜。 +需要报告者补一条 `mcpp build --verbose`(或设 `MCPP_LOG=debug`)输出以定位是哪次调用。 +**B1 落地后这条会从"致命+噪声"降为"纯噪声"**,不再阻塞任何人。 + +--- + +## 4. 可观测性:让"为什么我的 GL 程序能跑"可回答 + +三处,都是既有载体的补齐,不新增机制: + +1. **`resolution.json`** —— 运行期搜索目录逐条记 provenance: + `payload`(不可变载荷)/ `subos_farm`(可变符号链接农场)/ `package`(描述符声明)。 + **顺序即语义**,所以记录必须保序。 +2. **`mcpp why runtime`** —— 打印 `NEEDED → 从哪个目录解析到`,并标出该目录的 provenance。 + §1.1 里那个 `pass` 之所以骗过人,正是因为它没说"从哪解析到的"。 +3. **`mcpp doctor`** —— binding 处于 `present=false` 时,把 `note` 作为一条 **info**(不是 warn)呈现, + 并说明后果范围(rule A/B 不可评估;运行期环境声明不可用),而不是让它只在构建输出里一闪而过。 + +--- + +## 5. 测试策略:怎么保证它不空转 + +这个仓库反复付学费的形状是**测试与被测对象共享同一个错误假设**。逐条设防: + +### 5.1 必须存在的四条断言 + +| # | 断言 | 为什么这一条不能省 | +|---|---|---| +| **T1** | 产物 `DT_RPATH` 的**最后一项**是 binding 的 farm 目录 | 直接钉死 §2.1 的排序不变式。**判据是生成物**,不是源码 | +| **T2** | `libc.so.6` 解析到**载荷目录**,不是 farm | 排序若被改反,T1 可能仍过而 T2 必红,并说明原因 | +| **T3** | 一个 `NEEDED` 只存在于 farm 的产物 **能真正运行**(rc=0) | **唯一能戳破 §1.3 假绿的断言**。宿主也有 `libGL.so.1`,所以只有"真的跑"能区分两个加载器 | +| **T4** | 一个 `NEEDED` 谁都提供不了的产物,构建**变红**并指名是哪个 so | 防止 A3 只改了状态枚举而没接到失败门上 | +| **T5** | `pack --mode system` 的产物里**不含**任何 `$MCPP_HOME` 下的路径 | 见 §2.6:现有的 215 前缀只取到 `data/xpkgs`,farm 从它下面漏过去 | +| **T6** | Windows job 上,一个 `subos_info` 缺失的 fixture 能 `mcpp build` 成功 | 缺陷 B 的直接判据,且**不需要任何图形能力** | + +### 5.2 ⚠️ 三个已知会让测试空转的坑 + +1. **不要断言 `readelf` 里"有 farm 这条路径"就收工。** 本机宿主同时有 `libGL.so.1`, + 路径正确但顺序错误、或私有 loader 打不开,`readelf` 都看不出来。**T3 必须真的 exec。** +2. **不要用 `# requires: gcc` 之类在 Windows/macOS 不授予的能力**,否则整条 case 静默跳过 —— + #272 / #412 已经各踩一次。缺陷 B 的用例**必须在 Windows job 里真的跑到**, + 而且它**不需要**任何图形能力:一个空 `subos_info` 的 fixture 加一次 `mcpp build` 就够。 +3. **不要依赖 CI 的 subos 里装了 GL。** T3 需要"一个只存在于 farm 的库", + **不必是 libGL** —— 从 binding 的 farm 与载荷目录做一次差集,取任意一个即可。 + 差集为空时用例应当 **skip 并打印原因**,不得静默通过。 + +### 5.3 对照组 + +沿用 `tests/e2e/214_executable_carries_dt_rpath.sh` 已经证明有效的手法:**用共享库当对照**。共享库不带 `--disable-new-dtags`, +它的标签就是链接器默认值 —— 默认值哪天变了,是**库那条**先红并说明原因, +而不是可执行那条为了别的理由静默地继续通过。farm 这一轮同理: +farm 对可执行与共享库都要发,但**排序断言只在可执行上做**,库那条负责钉住默认行为。 + +--- + +## 6. 不做什么 + +- **不新增 `[build] libraries` / capability 语法。** 用户今天用 `ldflags = ["-lGL"]` 能表达, + 本轮的目标是让这条已经能写的东西**跑得起来**。语法层是独立议题,值得单独一份设计 + (它要和 mcpp-index 描述符的 `linkIntent` 对齐,范围比本轮大)。 +- **不读 `$XLINGS_SUBOS_LIB`。** §1.4 已实测它在本机指向另一个 home。 +- **不碰 `LD_LIBRARY_PATH` 或任何全局搜索路径。** 沿用上一轮的结论,§1.4 只是又加了一条证据。 +- **不在 mcpp 里出现任何库名。** 不出现 `libGL`、不出现 `LIBGL_DRIVERS_PATH`、不判断"这是不是图形程序"。 + farm 是一个目录,mcpp 只知道它的**可变性**和**顺序**。图形只是第一个被它治好的病人。 +- **不改标签契约。** `loader_contract` 已经是对的(§1.2 实测 `unit_ldflags` 生效),本轮一行不动。 +- **不替 xlings 修 #537。** 那是 `subos info` 面板的判定语义(它以消费者标签为条件而没表达这个条件), + 归 xlings。mcpp 侧的对应事实已由 rule E 记录在 `resolution.json` 里。 +- **不盲改 B4 的 Windows 噪声。** 没有现场就没有判据。 + +--- + +## 7. 实施顺序与风险 + +| 步 | 内容 | 依赖 | 风险 | +|---|---|---|---| +| **1** | **B1 + B2 + B3**(binding 降级 / schema 上限 / 文案分层) | 无 | **低**。纯粹放松约束,且**立刻解掉 Windows 用户的阻塞**。应当先发 | +| **2** | **A3**(闭包分档 + `UnresolvableNeeded`) | 无 | 中。**必须先于 A1** —— 否则 A1 落地后没有任何断言能证明它对,§1.1 的 `pass` 会继续 `pass` | +| **3** | **A1 + A4**(farm 入 `runtimeSearchDirs`、末位、护栏) | 步 2 | 中。**改动 `resolution.json` 与产物 RPATH ⇒ 指纹与既有校验缓存需要一并考虑** | +| **4** | **A6**(pack `system` 档剥离 + 升 host requirement + 扩 `215` 的前缀与档位) | 步 3 | 低 | +| **5** | **A5**(声明 `XLINGS_SUBOS_LD_PATHS=0`) | 键名待 xlings#540 敲定 | 低。今天是无操作 | +| **6** | 观测(§4)+ 测试(§5) | 步 3 | 低 | + +**顺序上唯一不可交换的是 2 在 3 之前。** 理由在 §1.3:先加路径再补判据,等于在一个不会响的报警器上面加功能 —— 而这正是这一片区域已经付过两次学费的形状。 + +**步 1 可以独立成一个 PR 先发**:它与图形无关,解的是一个正在阻塞 Windows 用户的回归。 + +### 跨仓状态 + +| 事项 | 归属 | 本轮是否阻塞 | +|---|---|---| +| E2b 的声明式退出键名 | xlings#540 | **否**(A5 先声明,后生效) | +| `subos_info` 增 `library_dirs` 声明(E2a) | xlings#540 | **否**。A1 从 binding 推导,已足够;E2a 落地后把推导换成读声明,是**同一处**的替换,不是第二处推导 | +| `subos info` EGL 低报 | xlings#537 | 否,归 xlings | +| Windows `subos_info` 块是否该写 | xlings | 否 —— B1 之后写不写都不再影响 mcpp 能否构建 | diff --git a/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md b/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md new file mode 100644 index 00000000..1782ce7f --- /dev/null +++ b/.agents/docs/2026-08-11-graphics-runtime-search-closure-implementation-plan.md @@ -0,0 +1,396 @@ +# 实施计划:运行期搜索闭包 与 binding 降级 + +> 设计:`2026-08-11-graphics-runtime-search-closure-and-binding-degradation.md` +> 分支:`feat/runtime-search-closure-and-binding-degradation` +> 基线:`main` `1bc6074`(`2026.8.11.1`)。**单 PR、一档全发,版本 `2026.8.11.2`。** +> +> **实施状态:已完成。** 实施过程中改动本计划的四处,均在对应小节以 +> **【实施修正】** 标出 —— 每一处都是被**实测**推翻的,不是改主意。 +> 落地后 CI 与自查又推翻三处,见 §12。 + +--- + +## 0. 模块划分:协议独占一个 `.cppm`,平台特化留在 `platform/` + +三个及以上的读写方共享同一条规则 ⇒ 独占一个模块。这是本仓库反复付学费后立下的形状 +(`loader_contract` / `graph_shape` / `host_requirements` 都是这么来的)。 + +| 新模块 | 协议内容 | 谁写 | 谁读 | +|---|---|---|---| +| **`src/platform/runtime_search.cppm`**
`mcpp.platform.runtime_search` | **运行期搜索路径契约**:一条目录的**来源**、**次序**、**是否机器本地** | `runtime_binding`(组装)、`plan`(链接期) | `elf_runtime`(闭包解析)、`pack`(剥离判定)、`runtime_validation`(记录) | + +**为什么放 `platform/` 而不是 `build/`**:它描述的是**加载器怎么搜**(物理), +不是 mcpp 怎么选(策略);而且 `elf_runtime`(platform)必须读它 —— +放 `build/` 会让 `platform → build` 反向依赖。 + +**它只 `import std`**,不认识 `RuntimeBinding`、不认识 ELF。纯策略,可单测,零耦合。 + +平台特化各归各位,不外溢: + +| 平台位置 | 本轮改动 | +|---|---| +| `src/platform/runtime_binding.cppm` | farm 目录发现(与既有 libc 探测同一次遍历);`declared`/`note` 降级字段 | +| `src/platform/elf_runtime.cppm` | 宿主默认目录按 binding 分档;`Status::Unresolvable` | +| `src/platform/linux/`、`macos`、`windows` | **不动** —— farm 是"有没有 DT_RPATH"的函数,已由 `platform::supports_rpath` 表达 | + +--- + +## 1. M1 — `src/platform/runtime_search.cppm`(新) + +```cpp +export module mcpp.platform.runtime_search; +import std; + +export namespace mcpp::platform::search { + +// 一条运行期搜索目录的来源。次序与"是否可分发"都由它决定, +// 调用方不再各自推导。 +enum class Origin { + Payload, // 不可变载荷目录(/xim-x-glibc/2.39/lib64) + Package, // 包描述符声明的 runtime 目录 + SubosFarm, // subos 符号链接农场(/lib)—— 可变 + HostDefault, // 宿主加载器的内建默认目录 —— 仅非 hermetic binding 适用 +}; + +// 次序 = 不可变性递减。 +int rank(Origin); + +// 这条目录是否是"这台机器的私有状态"⇒ 不得随产物分发。 +bool is_machine_local(Origin); // Payload / SubosFarm ⇒ true + +std::string_view to_string(Origin); + +struct Dir { std::filesystem::path path; Origin origin; }; + +// 唯一一次排序 + 去重。stable,同 rank 内保持插入序。 +std::vector ordered(std::vector dirs); +} +``` + +**`rank` 的理由写进注释,因为它是本轮唯一的不变式**: + +> 载荷目录不可变(装一次不再动),farm 每次 `xlings install` 都重写符号链接。 +> 载荷在前 ⇒ libc / libm / libstdc++ 永远从被 pin 的载荷解析,farm 只补没人提供的。 +> farm 在前 ⇒ 一次安装能在事后悄悄换掉一个**已经构建好**的产物的 libc。 + +--- + +## 2. M2 — `src/platform/runtime_binding.cppm` + +### 2.1 结构 + +```cpp +struct RuntimeBinding { + … + bool declared = false; // subos 是否自我描述(subos_info 块存在) + std::string note; // 降级原因;非空则调用方必须呈现 + std::vector searchDirs; // farm 视图目录(可变) +}; +``` + +`libraryDirs`(载荷,不可变)与 `searchDirs`(farm,可变)**必须是两个字段** —— +合成一个就把 rank 的信息丢了,而 rank 是本轮的全部。 + +### 2.2 `resolve_runtime_binding`:矛盾报错,缺席降级 + +| 情况 | 今天 | 改为 | +|---|---|---| +| 点名的 subos 目录不存在 | error | **error(保留)** —— 用户输入无法被满足 | +| 无 `.xlings.json` / 无 `subos_info` 块 | error | `declared=false` + `note`,**返回可用 binding** | +| `runtime` 字段为空 | error | 同上 | +| `schema > kSupportedSchema` | error | 读懂的字段照用 + `note`(与 `subos_info::read` 对齐) | +| `schema < kSupportedSchema` | error | 照用 | + +降级 binding 的内容:`platform`/`arch`/`providerId`/`subosDir`/`selection` 照填, +`runtimeId` 留空,`loader`/`libc` 无值 ⇒ rule A/B 自然落到 `Inconclusive` 并说明 +**是因为没有声明**,而不是因为查过了。 + +### 2.3 farm 发现:与既有 libc 探测同一次遍历 + +今天 `{subosDir/"lib64", subosDir/"lib"}` 那个循环找的是 `libc.so.6`(找到即 `break`)。 +farm 需要的是**目录本身是否存在**,两件事一次走完: + +``` +for candidate in {lib64, lib}: + if is_directory(candidate): searchDirs.push_back(candidate) // farm + if is_regular_file(candidate/libc.so.6) and libraryDirs.empty(): + …既有的 canonical → 载荷目录 → libraryDirs / loader… +``` + +**不新增第二处布局知识。** 只在 `if constexpr (is_linux)` 内。 + +### 2.4 序列化 + +- `search_dirs`、`declared`、`note` 进 JSON。 +- `searchDirs` + `declared` **进 `canonical_contract`** ⇒ contract hash 变 ⇒ + farm 变化会正确地让快路径与校验缓存失效。**这会让所有既有缓存失效一次,是预期的。** +- `deserialize` 的完整性检查放宽:`declared=false` 的 binding 允许 `runtimeId` 为空 + (今天 `schema==0 || runtimeId.empty()` 直接判 incomplete)。 + +--- + +## 3. M3 — `src/platform/elf_runtime.cppm` + +### 3.1 搜索顺序按契约,宿主默认目录分档 + +```cpp +// resolve_needed 内 +dirs = requester.runpaths (expand $ORIGIN) + + additionalSearchDirs + + binding.libraryDirs // Origin::Payload + + binding.searchDirs // Origin::SubosFarm + + (is_hermetic(binding) ? {} : host_library_dirs()); // ← 分档 +``` + +**`is_hermetic(binding)` = `binding.loader.has_value()`** —— 产物的 `PT_INTERP` 指向私有 +加载器时,宿主的内建默认目录**不在它的搜索路径里**。今天无条件加宿主目录,是 +`validation: pass` + `cannot open shared object file` 同时成立的直接成因。 + +非 hermetic(`gcc@system`、macOS、Windows)保持原状:那里宿主目录**确实**是默认值。 + +### 3.2 第四种判决 + +```cpp +enum class Status { Pass, ProvenMismatch, Unresolvable, Inconclusive }; +``` + +hermetic binding 下一个解析不到的 `NEEDED` 是**可证的失败**(私有 loader 一定打不开), +把它塞进 `Inconclusive` 是把可证的事说成没查过。 + +- `elf_runtime.cppm:759` 的 `inconclusive(...)` 在 hermetic 下改走 `unresolvable(...)`, + 非 hermetic 保持 `inconclusive`(宿主可能在 `ld.so.cache` 里有,mcpp 不读 cache)。 +- `runtime_validation`:`has_proven_mismatch()` → `has_blocking_failure()`, + 收下 `ProvenMismatch | Unresolvable`;`status_name`/`parse_status` 补 `"unresolvable"`。 +- `ninja_backend.cppm:1794` 的门同步。 +- `doctor.cppm:279` 的三分支补第四支。 + +--- + +## 4. M4 — `src/build/plan.cppm`:farm 进闭包,末位 + +### 【实施修正 ①】不能塞进 `linkIntent.runtimeSearchDirs` + +原计划让 farm 复用那个字段。**不行**:它有三个消费者,其中一个是 + +``` +plan.linkIntent.runtimeSearchDirs → plan.runtimeLibraryDirs → compute_run_env() + → LD_LIBRARY_PATH(`mcpp run` 的子进程环境) +``` + +而 farm 进 `LD_LIBRARY_PATH` 正是设计 §6「不做什么」第三条禁掉的东西 —— +它会污染 `mcpp run` 拉起的每一个子进程,包括宿主二进制(实测会让 `xdg-open` / +`notify-send` 死于 `__pointer_chk_guard`)。 + +**改为 `BuildPlan` 上的独立字段** `runtimeSearch`(`vector`, +全部四种 origin 的有序记录),farm 的**唯一**消费者是 `flags.cppm` 渲染的 +`-Wl,-rpath` 尾巴。**per-object 可达,绝不 per-process。** + +### 【实施修正 ②】载荷目录不能只读 `linkRuntimeDirs` + +`plan.toolchain.linkRuntimeDirs` **只有 clang 会填**(`clang.cppm:142`)。 +GCC 的载荷 `-rpath` 来自**链接模型**(`lm.libDirs`)。第一版记录出来只有一条 +farm,而产物 DT_RPATH 有三条 —— 记录与产物不一致,正是这份设计要消灭的形状。 + +改为向**发出它们的同一个函数**要:`resolve_link_model(plan.toolchain).libDirs` +(纯函数,可在 plan 层调用),再叠 `linkRuntimeDirs`,顺序与 `flags.cppm` 的拼接一致。 + +### 【实施修正 ③】装配点在 `merge_runtime_binding_contract`,不在 `build_plan` + +`plan.runtimeBinding` 在 `prepare.cppm:5313` 才被赋值,晚于 `make_plan` 返回。 +装配放进 `merge_runtime_binding_contract`(紧随其后调用),那里三个输入齐全。 + +**次序天然正确,不需要额外机制**(已核 `flags.cppm:975-978`): + +``` +f.ld = full_static + link_toolchain_flags + b_flag + runtime_dirs + + link_intent_ld + atomic_ld + payload_ld + user_ldflags + link_extra + ↑ 载荷 -L/-rpath ↑ linkIntent(farm 在其末尾) +``` + +且 `runtimeSearchDirs` 的既有语义正是我们要的(`flags.cppm:804-806` 原文): +*"contributes RUNPATH only; it must never become a link-time `-L` path"* —— +链接期已由 `--sysroot` 覆盖,这里只补运行期,还省下链接行长度。 + +### 4.1 两条护栏 + +| 护栏 | 判据 | +|---|---| +| **交叉目标** | `targetTriple` 非空且(`os != "linux"` 或 `arch != binding.arch`)⇒ 不发 | +| **非 ELF** | `elfTarget == false` ⇒ 不发(与 `loader_tag_flag` 同一个判据,复用) | + +--- + +## 5. M5 — pack:`215` 扩面(剥离不需要写代码) + +### 【实施修正 ④】5.1 的前提是错的 —— `system` 档早就剥干净了 + +原计划断言「`Mode::None` 是唯一不重写 rpath 的档」。**实测推翻**: + +```console +$ mcpp pack --mode system && tar -xzf …-system.tar.gz +$ readelf -d bin/glprobe | grep RPATH + (RPATH) Library rpath: [] ← 已清空 +$ readelf -p .interp bin/glprobe + /lib64/ld-linux-x86-64.so.2 ← 已改回平台标准解释器 +$ grep -rl "$HOME/.mcpp" / ← 无命中 +``` + +`pack.cppm:718` 对 `Mode::None` 把每个依赖都标 skip ⇒ `toBundle` 空 ⇒ +`rpath = ""` ⇒ `set_search_path` 整体清空。**这一项从"实现"变成"补测试"。** + +「从 farm 解析到的 `NEEDED` 升级为 host requirement」也**不做**: +`HOST-REQUIREMENTS` 存在的理由是记录**产物本身看不出来**的东西(经 dlopen 链到达 +的驱动),而 `NEEDED` 本来就写在产物里 —— 再抄一遍是冗余,还会污染 +`mcpp publish` 对 `[runtime].requirements` 的投影。 + +### 5.2 `215` 的两处扩面(设计 §2.6 核出来的) + +```bash +STORE="$MCPP_HOME/registry/data/xpkgs" # ← 今天只到这里 +MACHINE_LOCAL="$MCPP_HOME" # ← farm 在 registry/subos/…,不在 store 下 +``` + +并补 `--mode system` 的用例 —— 今天 215 只跑默认 `vendored` 档。 + +--- + +## 6. M6 — `XLINGS_SUBOS_LD_PATHS=0`:声明式退出 + +mcpp 在**驱动 ninja 之前**把它设进自己的进程环境(子进程继承 ⇒ 覆盖 ninja / 驱动 / ld), +**不进 ninja 命令行**(链接行有 128KiB 上限)。 + +- 今天 = 无操作(xlings 还没读它); +- xlings E2b 落地当天自动生效,mcpp 的 DT_RPATH 仍只含 mcpp 决定的内容; +- 键名与语义在 `runtime_search.cppm` 里以常量声明一次,**不散落**。 + +--- + +## 7. M7 — 可观测性 + +| 载体 | 补什么 | 实际落地 | +|---|---|---| +| `resolution.json` | `runtime_search` 数组:`[{path, origin}]`,**保序** | ✅ 位置是 `runtime.search.closure`,并多带一列 `machine_local` | +| `mcpp why runtime` | `search:` 行按 origin 展开;binding 未声明时打印 `note` | ✅ 另加一行 `binding: (undeclared) … — this SubOS did not describe itself` | +| `mcpp doctor` | `declared=false` 作为 **info** 呈现 | **改到 `mcpp why runtime`**。`doctor` 面向「这台机器健康吗」,而 binding 是**某一次构建**的决定 —— 它属于解释那次构建的命令。`doctor` 本轮只补了 `unresolvable` 那一支判决 | + +--- + +## 8. 测试 + +### 8.1 单测(`tests/unit/`) + +| 文件 | 断言 | +|---|---| +| `test_runtime_search.cpp`(新) | `rank` 次序;`ordered` 去重且 stable;`is_machine_local` 逐值 | +| `test_subos_info.cpp`(补) | schema 高于支持值时**不失败**,填 note | +| `test_runtime_contract.cpp`(补) | 降级 binding 的 serialize↔deserialize 往返;contract hash 含 searchDirs | + +### 8.2 e2e + +| # | 文件 | 断言 | 防空转 | +|---|---|---|---| +| T1 | `219_runtime_search_farm_is_last.sh` | 可执行文件 `DT_RPATH` **最后一项**是 binding 的 farm | 读**生成物** | +| T2 | 同上 | `libc.so.6` 解析到载荷目录,不是 farm | 次序反了它先红 | +| T3 | `220_farm_only_needed_runs.sh` | 一个只有 farm 提供的 `NEEDED` 的产物 **rc=0 真的跑起来** | **唯一能戳破假绿的断言**;库从 farm∖载荷 差集里取,差集空则 **skip 并打印原因** | +| T4 | 同上 | 谁都提供不了的 `NEEDED` ⇒ 构建**变红**并指名 | 防止状态枚举没接到失败门 | +| T5 | `215`(扩) | `--mode system` 产物不含任何 `$MCPP_HOME` 路径 | 前缀扩到整个 home | +| T6 | `221_subos_without_info_still_builds.sh` | `subos_info` 缺失的 fixture 能 `mcpp build` | **不要求任何图形能力**,Windows/macOS 都要真跑到 | + +**`# requires:` 只用 `run_all.sh` 真授予的能力**;T6 **不得**带 `elf`/`gcc`,否则它在 +Windows 上被跳过,而 Windows 正是它要防的回归。 + +--- + +## 9. 文档 + +| 文件 | 改什么 | +|---|---| +| `docs/08-toolchain-internals.md` | 新增"运行期搜索闭包"一节:四种 origin、次序与理由 | +| `docs/02-pack-and-release.md` | `system` 档会剥机器本地路径并升为 host requirement | +| `docs/11-machine-output.md` | `resolution.json` 的 `runtime_search` 字段 | +| `docs/zh/` 对应件 | 同步 | +| 设计文档 | 顶部标注实施状态与 PR 号 | + +--- + +## 10. 版本与 pin + +- `mcpp.toml` `[package].version` + `src/version.cppm` `MCPP_VERSION` → **`2026.8.11.2`**(同一 commit) +- `src/xlings.cppm` `kXlingsVersion` → **最新 xlings**(实施时以 `xlings --version` / 索引为准) +- `.xlings.json` 的 bootstrap pin **本 PR 不动**(发布并进索引后才前移) +- `bash .github/tools/check_version_pins.sh` 必须过 + +--- + +## 11. 实施顺序 + +**唯一不可交换:M3 在 M4 之前。** 先加路径再补判据 = 在不会响的报警器上加功能。 + +``` +M1 契约模块 → M2 binding(降级+farm) → M3 闭包判据 → M4 链接期 → M5 pack → M6 退出声明 → M7 观测 → 测试 → 文档 → 版本 +``` + +M2 的降级半边(§2.2)与图形无关,是正在阻塞 Windows 用户的回归 —— 它在同一个 PR 里, +但**提交上独立成一个 commit**,以便必要时单独 cherry-pick。 + +--- + +## 12. 落地后被推翻的三处(CI 与自查) + +这一节是这轮最有价值的部分:**每一条都是「可证」被用得太宽**,而这份设计本身 +就是在修同一种病。方向对不代表范围对。 + +### 12.1 ⚠️ CI 抓到:交叉产出的 PE 被 ELF 规则判死 + +`mingw-cross linux→windows` 变红: + +``` +error: runtime closure validation failed +runtime closure for …/crosswin.exe cannot be satisfied: + artifact '…/crosswin.exe' is not ELF not found on the search path … +``` + +三层错误叠在一起,每一层单独看都像对的: + +1. binding 是**宿主的**(Linux/glibc/私有加载器 ⇒ `hermetic()` 为真),产物却是 PE。 + **产物的格式由产物决定,不由 binding 决定。** +2. `resolution.unresolved` 是个**混装袋**:「找不到的 SONAME」「读不了的对象」 + 「512 上限」同住一个 vector。**只有第一种可证**;后两种是关于**检查本身**的陈述, + 而没能看的检查什么都没证明。⇒ 拆出 `unresolvedSonames`,升级只看它。 +3. 于是「这不是 ELF」被读成了「你缺一个库」,报给一个连 `DT_NEEDED` 都没有的文件。 + +### 12.2 ⚠️ CI 抓到:`206` 的「安全宿主 DSO 对照」本来就是假绿 + +`e2e 206` 断言一个 `allow_host_libs` + `-ltinfo` 的产物 `status == pass`。 +`LD_DEBUG=libs` 实测: + +``` +search path=…/xim-x-glibc/2.39/lib64 : …/xim-x-gcc/16.1.0/lib64 : /lib (RPATH) +search path=/home/xlings/.xlings_data/xim/xpkgs/fromsource-x-glibc/2.39/lib (system search path) + ↑ 载荷自己的构建期前缀,本机不存在 +./tinfoprobe → libtinfo.so.6: cannot open shared object file (127) +``` + +**私有加载器的内建默认路径是载荷自己的构建期前缀,`/usr/lib` 从不被查。** +它此前报 pass,只是因为闭包模型回落到宿主目录 —— 与那个被当成 pass 的 GL 程序 +**同一形状,就在 mcpp 自己的测试套件里**。而这份文件自己的注释 +*"execution is not part of this link-physics control"* 正是让这条假绿站住的理由: +**从来没人运行过它。** + +修法不是放回宿主目录,而是让 `[build] allow_host_libs` **同时退出两个阶段**: +它本就关掉链接期 hermeticity 检查,既然解析责任已归用户,mcpp 就不能再断言产物 +起不来。⇒ 该档下报 `inconclusive` 并指名,不阻断。**一条声明,一个含义。** + +### 12.3 自查:三处「模型比产物宽」 + +| 处 | 问题 | 修法 | +|---|---|---| +| `resolve_needed` | 直接读 `binding.searchDirs`,而**护栏在 plan 上** ⇒ 会拿宿主 x86_64 farm 解析 aarch64 产物的 `NEEDED`,报一个目标机不兑现的 pass | 经 `additionalSearchDirs` 从 `plan.runtimeSearch` 取,与发出去的逐条同源 | +| farm 护栏 | 只看 os + arch。`x86_64-linux-musl` 两者都对,glibc farm 会落到 musl 程序上 | 补 libc 轴。**但「未声明」不等于「不匹配」** —— 第一版这么写,当场让 e2e 220 变红 | +| `runtime_search.cppm` | `paths_of` / `parse_origin` 在 `src/` 下零消费者 | 删掉。导出面只有测试在撑,就是没有理由存在的导出面 | + +### 12.4 一条方法论 + +> **`unresolved` 这种「混装袋」是这轮所有误判的共同结构。** +> 一个 vector 里装了三种不同强度的事实,而消费者只能按最强的那种去解读它。 +> 拆开之后,每条判断的证据强度就写在类型上了。 diff --git a/.agents/docs/2026-08-11-origin-precedence-implementation-plan.md b/.agents/docs/2026-08-11-origin-precedence-implementation-plan.md new file mode 100644 index 00000000..9f69ad56 --- /dev/null +++ b/.agents/docs/2026-08-11-origin-precedence-implementation-plan.md @@ -0,0 +1,240 @@ +# `$ORIGIN` 优先级 + 共享库运行时契约 —— 实施计划 + +配套分析:`2026-08-11-runtime-search-origin-precedence-analysis.md` +范围:**A + B + C2 + C1**,单 PR(2026.8.11.3) +状态:**已实施**(实测结果见 §2.2,与计划的偏差见 §2.1) + +--- + +## 0. 四个角度的取舍 + +### 优雅设计 —— 把「顺序」从字符串拼接提升为声明 + +缺陷的形状是:**一条链接命令行的顺序,由两个互不知情的生产者用 `+=` 决定。** +`flags.cppm` 把 farm 拼在全局 ldflags 末尾并注释「so it is LAST」;`plan.cppm` 把 +`$ORIGIN` 拼在 per-unit;`ninja_backend` 渲染成 `$ldflags $unit_ldflags`。 +三处都对,合起来是错的。 + +对策不是「换个地方拼」,而是新增 `src/build/link_line.cppm` +(`mcpp.build.link_line`):把 per-unit 尾部声明成**具名槽位**,相对顺序写在类型里, +由单测钉死。加一个新的调用方不再可能把顺序拧错 —— 它必须选一个槽。 + +### 架构稳定性 —— 消灭同一决策的第二处推导 + +`dist::default_contract(Role)` 自称「The role -> contract policy, in one place」, +**却没有任何生产调用方**(只有单测);真正的策略在 `flags.cppm:704-709` 独立算了 +一遍。这正是 `distribution.cppm` 开篇声讨的那类债(「used to be derived +independently in five places」),只是换了个位置复发。 + +本次把 `default_contract` 变成**活的唯一真源**,`flags.cppm` 调它。 + +### 兼容性 —— 只有一个平台的行为改变,且正是有缺陷的那个 + +| 目标格式 | 共享库契约 变化 | 产物字节 | +|---|---|---| +| **ELF** | SelfContained → **ToolchainCoupled** | 变(这是修复) | +| Mach-O | 不变(SelfContained) | **不变** | +| PE | 不变(SelfContained) | **不变** | + +`-Wl,-rpath,` 换槽位后在**没有共享库依赖的工程**上字节完全不变 +(槽位为空即不渲染);有共享库依赖的工程只有 rpath 次序变化。 + +用户逃生舱保留:`cxx_runtime = { shared = "self-contained" }` 可恢复旧行为, +且此时自动补 C1 护栏,不会重新打开符号泛滥。 + +**缓存**:`.so` 的链接边在工程自己的 ninja 图里(`build bin/libX11.so : cxx_shared +…`),不来自依赖缓存;ninja 按命令行变化重跑链接。契约只影响链接标志、不影响编译 +标志,因此对象缓存键无需改动。发版 bump 会改工程指纹 ⇒ 全量重建,不存在旧契约残留。 + +### 跨平台 —— 平台差异只准出现在已有的格式维度里 + +`distribution.cppm` 的机制表本来就是 `(contract × stdlib × format) → flags`。 +共享库的危害本身就是格式相关的,所以把 `Format` 提到 `default_contract` 的入参, +是让既有维度承担它,而不是新开一条平台分支: + +- **ELF**:一个全局符号命名空间,先加载的定义胜出 ⇒ 静态内嵌 libstdc++ 的 `.so` + 会把 2931 个 std 符号无版本导出(其中 **777 个是 GLOBAL 定义**,只可能来自 + `libstdc++.a`),可执行文件的 std 引用被绑到它身上 +- **Mach-O**:self-contained 的机制本就是 `-Wl,-load_hidden,`,符号是 + hidden,dyld 不会归一 ⇒ **危害不存在**;且 toolchain-coupled 在 macOS 是已知死路(#202) +- **PE**:没有全局命名空间,导入按 DLL 逐个按名解析 ⇒ **危害不存在**;`-static` + 是那里的标准约定 + +`link_line::UnitTail` 保持格式中立:槽位按**职责**命名,不按标志拼写。 +PE 的 `runtimeFallback`/`loaderTag` 天然为空,Mach-O 的 `dependencies` 装 +`@loader_path` —— 不需要任何 `if (platform)`。 + +--- + +## 1. 步骤拆分 + +每一步都有独立判据,可单独 revert。 + +### S1 — 新增 `mcpp.build.link_line` 协议模块 + +`src/build/link_line.cppm`,导出: + +```cpp +struct UnitTail { + std::string dependencies; // 1. -L/-l + $ORIGIN / @loader_path + std::string cxxRuntime; // 2. -static-libstdc++ / -load_hidden / --exclude-libs + std::string runtimeFallback; // 3. 兜底运行期搜索(今天=SubOS farm 视图) + std::string loaderTag; // 4. --disable-new-dtags,必须字面最后 + std::string render() const; // 按上述顺序连接,自动补分隔空格 + bool empty() const; +}; +``` + +每个槽位的注释必须写清**为什么在这个位置**,尤其: +`dependencies` 在最前,因为那些目录装的正是本次链接解析到的物理文件; +`runtimeFallback` 必须晚于它们,因为 farm 是 `xlings install` 会重写的视图; +`loaderTag` 字面最后,因为 ld 认最后一个 `--enable/--disable-new-dtags`。 + +**判据**:`tests/unit/test_link_line.cpp` 断言四槽顺序 + 空槽不产生多余空格 ++ 任意槽为空时其余顺序不变。 + +### S2 — `CompileFlags` 增加 `ldRuntimeFallback`,farm 不再进 `f.ld` + +`flags.cppm`:`farm_ld` 从 `f.ld`(`:996-999`)移出,赋给 `f.ldRuntimeFallback`。 +字段名取 provider 中立的 "runtime fallback",与 `link_line` 的槽位同名。 + +**判据**:`test_build_flags.cpp` 断言 `f.ld` 不含 farm 路径、`f.ldRuntimeFallback` 含。 + +### S3 — `ninja_backend` 改用 `UnitTail` 组装 `unit_ldflags` + +`ninja_backend.cppm:1400-1418` 的三次 `unit +=` 换成填槽 + `render()`。 + +**判据**:`test_ninja_backend.cpp` 新增用例 —— 带 `$ORIGIN` 的链接单元, +在 `ldflags + " " + unit_ldflags` 合成串里 `$ORIGIN` 位置 **早于** farm。 +(仅 Linux 分支产出 farm,其它宿主 `GTEST_SKIP`) + +### S4 — `Role` 拆出 `SharedLibrary`,`default_contract` 变 `(Role, Format)` 并成为唯一真源 + +1. `distribution.cppm`:`Role` **末尾**追加 `SharedLibrary`(不动既有枚举值, + 因为 `ldStdlibByRole` 按枚举值索引);导出 `kRoleCount` +2. `default_contract(Role, Format)`:`(SharedLibrary, Elf) → ToolchainCoupled`, + 其余一律 `SelfContained`(= 今天的行为) +3. `to_string(Role)` 补 `"shared-library"` +4. `flags.cppm:83-92` `role_of`:`LinkUnit::SharedLibrary → Role::SharedLibrary` +5. `flags.cppm:52,54`:`std::array<…, 3>` → `kRoleCount` +6. `flags.cppm:704-709`:`base` 改为调用 `default_contract`,并新增 + `sharedContract`;`mi.format` 的推导上移到契约计算之前 +7. `wantsArchives`(`:768`)把 `sharedContract` 纳入 + +**判据**:`test_distribution.cpp` 逐格断言 `(Role × Format) → Contract` 全表。 + +### S5 — C1 护栏:ELF 上显式 self-contained 的共享库必须隐藏归档符号 + +`distribution.cppm` ELF 分支,`Role::SharedLibrary` 且 `effective == +SelfContained` 时追加: + +- libstdc++:`-Wl,--exclude-libs,libstdc++.a` +- libc++:`-Wl,--exclude-libs,libc++.a -Wl,--exclude-libs,libc++abi.a` + (有 libunwind.a 时再加一条) + +用**归档基名**而非路径:`--exclude-libs` 按归档文件名匹配,GNU ld 与 lld 一致。 +显式列名而非 `ALL`,以免连用户自己的静态库一起隐藏。 + +**判据**:`test_distribution.cpp` 断言该单元格产出含 `--exclude-libs`, +且 `Role::Distributable` 不含。 + +### S6 — manifest:`cxx_runtime` 增加 `shared` 键 + +`types.cppm` 加 `cxxRuntimeShared`(`[build]` 与 `[target.]` 两处); +`toml.cppm:954-968` 的键白名单加 `"shared"`,错误文案同步。 + +**判据**:`test_manifest.cpp` 解析 `cxx_runtime = { default=…, tests=…, shared=… }`; +未知键仍报错。 + +### S7 — 测试:把假绿换成真判据 + +1. `tests/e2e/219_runtime_search_farm_is_last.sh`:断言 farm 是 DT_RPATH 的 + **字面**最后一项,删掉「last ABSOLUTE entry」放宽与那段理由 +2. **新增 e2e:行为不变量**。构造 `$ORIGIN` 与 farm 同名 SONAME 的局面, + 用 `LD_DEBUG=libs` 断言解析到 `$ORIGIN` 那一份。 + ⚠️ **不得依赖崩溃** —— C2 落地后崩溃会消失,依赖崩溃的断言会立刻假绿 +3. 新增 e2e:ELF 共享库**不得**导出 std 符号(`nm -D` 计数为 0) + +### S8 — 文档 + 版本 + pin + +- `docs/` 用户文档:`cxx_runtime` 的 `shared` 键、共享库默认契约按平台的说明 +- 版本 bump(`YYYY.M.D.N`,月日不补零) +- pin 最新 xlings(唯一真源 `src/platform/xlings/xlings.cppm::kXlingsVersion`, + 由 `check_version_pins.sh` 机器校验 16 处) + +--- + +## 2. 合并后的验证清单(缺一不可) + +| # | 判据 | 方法 | +|---|---|---| +| V1 | farm 是 DT_RPATH 字面最后一项 | `readelf -d` | +| V2 | `libX11.so.6` 解析到 `$ORIGIN` | `LD_DEBUG=libs`,**看行为不看形状** | +| V3 | `bin/libX11.so` 导出 std 符号数 = 0 | `nm -D \| grep -cE '_ZNSt\|_ZNKSt\|_ZSt'` | +| V4 | exe 不再有 `U _ZNKSt13runtime_error4whatEv` | `nm -D --undefined-only` | +| V5 | helloegui GUI 真的起来 | 本机跑,`timeout` 退 143 | +| V6 | **revert 掉 S2+S3 后 V2 必须重新变红** | 证明 C2 没吃掉 A 的判据 | +| V7 | PE / Mach-O 产物字节不变 | CI 对应 job | +| V8 | 生态:mcpp-index workspace 全绿 | compat 包全是 `.so`,是重灾区 | + +--- + +## 2.1 实施记录 —— 计划没写对的三处 + +**① `default_contract` 根本没有生产调用方。** +它自称「The role -> contract policy, in one place」,实际只有单测调用;真正的策略 +在 `flags.cppm:704-709` 独立算了第二遍。所以 S4 不只是"加一个入参",而是把这个 +函数**接回主路径**。这也是本次改动里架构收益最大的一处。 + +**② 「共享库导出零个 std 符号」这个判据一开始是错的。** +实测:一个用了 `std::string` 的 C++ 共享库,即使 toolchain-coupled,也会导出 **31 个** +std 符号 —— 全部是 `W`(weak/COMDAT 模板实例化,从头文件实例化进它自己的 TU), +GLOBAL 为 0。这些是 C++ ABI 的预期行为,进程内归一它们是**对的**。 + +真正的判据是 **GLOBAL 计数为 0**:`T` 符号只可能来自 `libstdc++.a`。 +对照数据:坏版本的共享库是 **713 GLOBAL**(+ weak),好版本是 **0 GLOBAL**。 +如果按"总数为零"写,断言不可满足,最后只会被删掉 —— 一条真不变量换成没有不变量。 + +**③ e2e 的工程形状换了两次才对。** +- `int main()`:DT_RPATH 里**根本没有 `$ORIGIN`**,整条断言链空转(这正是旧断言 + 能被写成那样的原因 —— 它从未在有 `$ORIGIN` 的产物上跑过) +- 同包内的 `kind = "shared"` target:mcpp 把该包的模块对象**直接链进可执行文件**, + 没有 `-l`、也没有 `$ORIGIN` +- **消费一个 path 依赖提供的共享库**:才产生 `-Lbin -Wl,-rpath,'$ORIGIN' -lgreetdep`, + 与出问题的真实产物同形 + +另外接口里写内联定义(`export int f() { return 7; }`)会让符号被实例化进消费者、 +依赖边消失,所以接口与实现必须分文件。 + +## 2.2 实测结果(helloegui:imgui + GLFW + X11) + +| 判据 | 修复前 | 修复后 | +|---|---|---| +| DT_RPATH 尾部 | `… : /lib : $ORIGIN` | `… : $ORIGIN : /lib` | +| `libX11.so.6` 解析到 | farm(`xim:libX11 1.8.10`) | `$ORIGIN`(farm 未被试到) | +| `bin/libX11.so` 导出 std 符号 | 2931(777 GLOBAL) | **0** | +| `bin/libXau.so` | 9 557 936 B | **39 368 B** | +| `bin/libXdmcp.so` | 9 561 504 B | **41 552 B** | +| exe 未定义 `runtime_error::what` | 有 | **无** | +| 运行 | `symbol lookup error` | **GUI 正常启动** | + +红测(两条新 e2e 对已发布 2026.8.11.2):219 报「farm is not the last entry」, +222 报「exports 713 GLOBAL standard-library symbols」—— 均以正确理由失败。 + +## 3. 已知风险 + +1. **C2 掩盖 A 的症状** —— 见 V6,这是本 PR 最大的假绿风险 +2. **本机 e2e 噪声** —— 共享 gcc specs 污染、`pipefail`+`grep -q` 的 SIGPIPE + flake;本机红必须逐条与已发布二进制比对,不可直接当回归 +3. ~~**`--exclude-libs` 的链接器覆盖面**~~ —— **已实测**。两条路径都验证过: + - libstdc++ 分支(`-lstdc++` 形式):e2e 222 不变量 4,GLOBAL 导出 713 → 0 + - libc++ 分支(**按完整路径**给归档):直接对拍 + `g++ -shared -nostdlib++ /libstdc++.a` ± `-Wl,--exclude-libs,libstdc++.a` + ⇒ `_ZNKSt13runtime_error4whatEv` 导出数 **1 → 0**,证明它按**基名**匹配、 + 与归档是 `-l` 还是完整路径给出无关 + + 若将来接入其它链接器,应在机制表里加格式/链接器维度,而不是加平台分支。 + +4. **`dist::CompileFlags::contractByRole` 只写不读**(既有,非本次引入)。 + 它与 `TargetEntry::cxxRuntimeTests`(既解析不了也不生效)是同一类死字段, + 本次没有顺手清理 —— 删一个公开结构体字段是另一件事,不该混进修缺陷的 PR。 diff --git a/.agents/docs/2026-08-11-runtime-search-origin-precedence-analysis.md b/.agents/docs/2026-08-11-runtime-search-origin-precedence-analysis.md new file mode 100644 index 00000000..01862979 --- /dev/null +++ b/.agents/docs/2026-08-11-runtime-search-origin-precedence-analysis.md @@ -0,0 +1,453 @@ +# `$ORIGIN` 被 SubOS farm 遮蔽 —— helloegui 运行期 undefined symbol 分析与修复方案 + +日期:2026-08-11 +起因:`mcpp run`(helloegui,依赖 imgui 0.0.6)链接成功、运行即死 +涉及版本:2026.8.11.2(PR #413 首次把 SubOS 库视图写进 DT_RPATH) +状态:**分析完成,方案已定,未实施** +本批 PR 范围:**A + B + C2 + C1**(决策记录见 §6.1) + +--- + +## 0. 一句话 + +PR #413 把 SubOS farm(`/registry/subos/default/lib`,一个 300 条目的 +平铺符号链接视图)加进了产物的 DT_RPATH,但它落在 **`$ORIGIN` 之前**;于是产物 +运行期加载的 `libX11.so.6` 不是链接时的那一个,而是 farm 里 xim 上游的另一份构建。 +两份 libX11 不可互换 —— mcpp 自建的那份因为 `-static-libstdc++` 把整个 libstdc++ +烙了进去并**对外导出**,链接期正是它满足了可执行文件的 `std::runtime_error::what()`。 + +模块自己写着「FARM LAST」这条不变量(`src/build/plan.cppm:660`),而 +`$ORIGIN` 由另一个生产者在另一条通道上发出,两者从未在同一个排序里相遇。 + +--- + +## 1. 现象 + +``` +$ mcpp run + Finished dev [unoptimized + debuginfo] in 3.26s + Running `target/x86_64-linux-gnu/eb46e2850893f013/bin/helloegui` + +.../bin/helloegui: symbol lookup error: .../bin/helloegui: + undefined symbol: _ZNKSt13runtime_error4whatEv +``` + +同时刷出 13 条 runtime closure 警告(`rule B inconclusive … has no loader path / +has no library directory`)。**这两件事无关**,见 §5。 + +复现:稳定,100%。第二次 `mcpp build`(指纹变为 `4ab95c7547486246`)警告消失, +**崩溃依旧** —— 崩溃与首次运行无关。 + +--- + +## 2. 根因链(每一环都有实测) + +### 环 1 — 产物的 DT_RPATH 里 `$ORIGIN` 排在 farm 之后 + +``` +$ readelf -d bin/helloegui | grep RPATH + RPATH: [ …/xim-x-glibc/2.44/lib64 + : …/xim-x-gcc/16.1.0/lib64 + : …/compat-x-glx-runtime/…/glx_runtime/lib + : /home/speak/.mcpp/registry/subos/default/lib ← farm + : $ORIGIN ] ← 载荷目录,最后 +``` + +**两个生产者,一条 ninja 命令行,没有共同的排序:** + +| 条目 | 生产者 | 落入 | +|---|---|---| +| glibc / gcc / glx_runtime | `flags.cppm` runtime_dirs | 全局 `$ldflags` | +| **farm** | `flags.cppm:508-526` → `f.ld`(`:996-999`) | 全局 `$ldflags`(末尾) | +| **`$ORIGIN`** | `plan.cppm:450-467` `shared_library_link_flags` | per-unit `$unit_ldflags` | + +链接规则(`ninja_backend.cppm:867-872`): + +``` +command = $cxx $in -o $out $ldflags $unit_ldflags +``` + +`$ldflags` 在前 ⇒ farm 在前 ⇒ **`$ORIGIN` 永远最后**。 + +`flags.cppm:509` 的注释写的是「appended after everything else so it is LAST in +the artifact's DT_RPATH」—— 它只在 `$ldflags` 内部为真,而 `unit_ldflags` 还在后面。 + +### 环 2 — 同一个 SONAME 在两个目录里都存在 + +「同名」指的是 **SONAME,不是包名**。本工程里同时存在两个物理文件: + +| | mcpp compat 包 | xim 上游包 | +|---|---|---| +| 包身份 | `compat.x11 v1.8.13`(源码包) | `xim:libX11@1.8.10` | +| 谁产出 | **mcpp 自己从源码编译**(`obj/compat_x11/src/*.o`) | xlings 装的预编译产物 | +| 落在哪 | `target/…/bin/libX11.so`(= `$ORIGIN`) | `…/xpkgs/xim-x-libX11/1.8.10/lib/`,经 farm 符号链接暴露为 `/lib/libX11.so.6` | +| **SONAME** | **`libX11.so.6`** | **`libX11.so.6`** | + +上游版本都不同(1.8.13 vs 1.8.10),SONAME 却相同 —— 而**加载器只按 SONAME 找**。 +farm 是 `xlings install` 每次都会重写的平铺视图,凡是 mcpp 有 compat 源码包、 +xim 又有同一个库的预编译包,就会撞上。这不是边缘情况,是常态。 + +**两条通道,两套顺序,从未被约束成一致:** + +| | 构建期通道 | 运行期通道 | +|---|---|---| +| xim 包 | `--sysroot=` ⇒ `/lib` 成为链接器默认目录 | farm 进 DT_RPATH(#413 新加) | +| mcpp compat 包 | `-Lbin` | `$ORIGIN` | + +- `-L` 序:`-L` `-L` **`-Lbin`** … sysroot 默认目录在最后 + ⇒ `-lX11` 选中 **`bin/libX11.so`** +- RPATH 序:`glibc : gcc : glx : /lib : $ORIGIN` + ⇒ 运行期选中 **farm 里的 xim 那份** + +即:**mcpp 拿 A 链接,却让产物去加载 B。** + +链接期用了哪一份不必推理,**产物自己证明了**:exe 里 +`_ZNKSt13runtime_error4whatEv` 是动态未定义且没有 `NEEDED libstdc++.so.6`, +而只有 `bin/libX11.so` 导出该符号(见环 3)。若链接期用的是 farm 那份,该符号 +要么链接期报错,要么从 `libstdc++.a` 拉入 —— 两种都产生不出现在这个产物。 + +`LD_DEBUG=libs` 实测(前 4 个 rpath 目录逐个 miss,farm 命中): + +``` +find library=libX11.so.6 [0]; searching + trying file=…/xim-x-glibc/2.44/lib64/libX11.so.6 ✗ + trying file=…/xim-x-gcc/16.1.0/lib64/libX11.so.6 ✗ + trying file=…/glx_runtime/lib/libX11.so.6 ✗ + trying file=…/registry/subos/default/lib/libX11.so.6 ✓ ← 命中 farm +``` + +`$ORIGIN` 根本没被走到。 + +### 环 3 — 两份 libX11 不可互换 + +| | `bin/libX11.so`(mcpp 自建) | `xim-x-libX11/1.8.10`(farm) | +|---|---|---| +| 导出 std 符号数 | **2931**(777 T + 2777 W) | **0** | +| `_ZNKSt13runtime_error4whatEv` | 导出 | 无 | +| 符号版本 | 无(`GLIBCXX_*` verdef 不存在) | — | + +mcpp 自建的那份为什么会导出整个标准库: + +1. `flags.cppm:86-90` 把 `SharedLibrary` 映射为 `dist::Role::Distributable` + (`distribution.cppm:47-51`:"Binary / SharedLibrary — leaves this machine") +2. `distribution.cppm:330-338`:ELF + libstdc++ + SelfContained ⇒ `-static-libstdc++` +3. **每个链接单元都带 `obj/std.o`**(`import std` 的模块对象)。实测 + `bin/libXau.so` 的输入是 8 个 `.o`(纯 C)**加一个 `obj/std.o`**, + 于是 `libstdc++.a` 被整个拖进来 —— libXau 从应有的 ~20KB 变成 **9.5MB** + +### 环 4 — 可执行文件的 `-static-libstdc++` 因此落空 + +链接行(`build.ninja` 第 1957 行的 unit_ldflags): + +``` +… -Lbin -Wl,-rpath,'$ORIGIN' -lX11 … -lXext -static-libstdc++ -Wl,--disable-new-dtags +``` + +`-lX11` 在驱动追加的 `-lstdc++`(此处即 `libstdc++.a`)**之前**。ld 处理到 +`-lX11` 时该符号已被这个**共享库**满足 ⇒ 归档成员从不被拉入 ⇒ 引用留在动态未定义: + +``` +$ nm -D --undefined-only bin/helloegui | grep runtime_error + U _ZNKSt13runtime_error4whatEv +$ readelf -d bin/helloegui | grep NEEDED + … libX11.so.6 … libm.so.6 libgcc_s.so.1 libc.so.6 ← 没有 libstdc++.so.6 +``` + +即:**helloegui 的 C++ 运行时事实上来自 `libX11.so`**。它声称的 +self-contained 是假的,而这份「假」在环 1 把 libX11 换成上游那份的瞬间变成硬崩溃。 + +### 因果验证(A/B,不是推理) + +| 实验 | 结果 | +|---|---| +| 原样运行 | `symbol lookup error` | +| `LD_PRELOAD=bin/libX11.so.6` | **正常启动,GUI 窗口出现**(挂起到被 kill) | +| 副本 patchelf,载荷目录提到 RPATH 首位 | **正常运行 8s 被 timeout 杀掉(exit 143)** | + +第三条是决定性的:**只调整 RPATH 顺序,不改任何代码,崩溃消失。** + +--- + +## 3. 为什么测试没拦住 —— 一个被实测结果反向驯化的断言 + +PR #413 新增了 `tests/e2e/219_runtime_search_farm_is_last.sh`,标题就叫 +"farm is last"。它绿着,而真实产物里 farm 不是最后一位。 + +`tests/e2e/219…sh:151-171`: + +```bash +# The farm must be the last ABSOLUTE entry — not literally the last entry. +# +# `$ORIGIN`-relative entries are a different kind: they address the artifact's +# own directory, not this machine, so they travel with it and their position +# says nothing about which machine-local directory wins. … +# (Measured on a real GLFW app, whose DT_RPATH ends `… : /lib : $ORIGIN`.) +RPATH_LAST_ABS="$(… [x for x in DT_RPATH.split(':') if x.startswith('/')] … [-1])" +``` + +作者**实测到了本文分析的这个真实顺序**,判定它是可接受的,于是把断言从 +「字面最后」放宽到「最后一个绝对路径条目」。 + +那条理由恰好反了:决定胜负的**正是**「同一个 SONAME 在 `$ORIGIN` 和 farm 里都有」, +而这在 mcpp 生态里是常态而非例外(见环 2)。 + +**准确地说,这条断言不是「把坏顺序钉成预期」,而是对它结构性失明** —— 因为 +`$ORIGIN` 不以 `/` 开头,被那行 python 过滤掉了: + +| | 绝对路径条目 | 最后一项 | 判定 | +|---|---|---|---| +| 未修 `… : /lib : $ORIGIN` | `[glibc, gcc, glx, /lib]` | farm | ✓ 通过 | +| 修好 `… : $ORIGIN : /lib` | `[glibc, gcc, glx, /lib]` | farm | ✓ 通过 | + +两者**完全一样**。所以它在坏产物和好产物上给出同一个结论,而它的名字叫 +"farm is last" —— 它在一个 farm 并非最后的二进制上报告「farm 最后」。 +这比「会在修复后变红」更糟:修复根本不会惊动它。 + +> 教训(与既有记录一致):断言被实测结果驯化时,要先证明「实测到的形态是对的」, +> 而不是把它当成基准。这条不变量的正确判据是**行为**(同名库解析到谁), +> 不是**形状**(哪一项排在末尾)—— 形状断言在这里天生不够,这正是 §4-B 必须补 +> 一条行为不变量的原因。 + +模型层面同样有缺口:`runtime_search.cppm` 的 `rank()`(`:82-92`)只有 +Payload / Package / SubosFarm / HostDefault 四档,**`$ORIGIN` 不在闭包里**。 +`plan.cppm:660` 声称「`search::ordered` is what enforces it」,但它管不到 +一个由别的通道发出的条目 —— 这个排序模型对最关键的目录是装饰性的。 + +--- + +## 4. 修复方案 + +### A(必须,修崩溃)— farm 移到整条链接行的尾部 + +**改动** + +1. `src/build/flags.cppm`:`CompileFlags` 新增 `std::string ldFarmTail;` + `farm_ld` 不再拼进 `f.ld`(`:996-999`),改为 `f.ldFarmTail = farm_ld;` +2. `src/build/ninja_backend.cppm:1400-1418`:组装 `unit_ldflags` 时, + 在 `flags.ldStdlibFor(role)` **之后**、`lu.loaderTagFlag` **之前**追加 + `flags.ldFarmTail`;`StaticLibrary` 跳过(与 `ldStdlibFor` 对 Intermediate + 返回空一致)。 + - `loaderTagFlag` 必须保持字面最后 —— 加载器标签由 ld 看到的最后一个 + `--enable/--disable-new-dtags` 决定(该处注释已写明) + +**结果** + +``` +RPATH: glibc/lib64 : gcc/lib64 : glx_runtime/lib : $ORIGIN : /lib +``` + +载荷目录仍在最前(libc/libstdc++ 来自钉住的载荷),`$ORIGIN` 次之(产物链接时 +看见的正是这些同级库),farm 真正兜底。 + +**否决的替代** + +- *把 `$ORIGIN` 提到全局 ldflags*:`-Lbin` 与 `-Wl,-rpath,'$ORIGIN'` 是成对的 + per-unit 事实(只有消费共享库的单元才需要),提成全局会给每个产物无条件加一条 +- *调换 ninja 规则里 `$ldflags` / `$unit_ldflags` 的次序*:会同时移动所有 `-L` + 搜索序与 `-specs`,影响面远超本问题 + +**风险**:低。`verify_hermetic_link`(`hermetic.cppm:103`)检查的是 `flags.ld` +解析出的库路径是否落在允许根内;farm 路径位于 `tc.sysroot`(= ``)之下, +本就在允许集合里,把它从被检字符串中移走不改变判定。 + +### A+(单独 PR,紧随本批)— 让闭包模型真正拥有这个顺序 + +`runtime_search.cppm` 增加 `Origin::Artifact`,`rank()` 置于 `Package` 与 +`SubosFarm` 之间;`runtime_search_closure`(`plan.cppm:667`)把产物输出目录 +(记为 `$ORIGIN`)纳入闭包。收益: + +- `resolution.json` 记录的闭包与 DT_RPATH 变得**逐项可比**(今天记录里没有 + `$ORIGIN`,e2e 219 的「记录 vs 产物」比对因此天生有个缺口) +- 「FARM LAST」不再靠两个生产者各自自觉 + +代价:`is_machine_local()` 需为 Artifact 定义语义(`$ORIGIN` 随产物走 ⇒ 非 +machine-local)。 + +**不涉及 `pack`(已核)**:`is_machine_local` 全仓只有一个生产消费方 +(`prepare.cppm:6417` → 写进 `resolution.json` → `doctor.cppm:703` 打 +`[machine-local]` 标签);`src/pack/pack.cppm` 不 import `mcpp.platform.runtime_search`, +它自己用 patchelf 把所有 RPATH 重写成 `$ORIGIN/../lib`(`pack.cppm:745-779`)。 + +A+ 有两个档位,建议只做 (i): + +- **(i) 轻**:产物输出目录以 `Origin::Artifact` 进入闭包**记录**。收益是记录与 + DT_RPATH 变得逐项可比,测试可以硬比对。改动 = enum + 3 处 switch + + `plan.cppm` 加一条 + doctor 显示。 +- **(ii) 重**:让闭包成为**唯一**的 rpath 生产者,即把 `$ORIGIN` 的发出也从 + `shared_library_link_flags` 挪进来。这才真正消灭「两个生产者」,但 `$ORIGIN` + 本质是 per-unit 的(只有消费共享库的单元才需要),挪进全局闭包意味着要给闭包 + 引入 per-unit 概念 —— 改动量与风险都明显更大。记 issue,不急。 + +### B(必须)— 把测试改回真不变量 + +1. `tests/e2e/219_runtime_search_farm_is_last.sh:151-171`:断言 farm 是 DT_RPATH + 的**字面最后一项**,删除「last ABSOLUTE entry」的放宽与那段理由 +2. **新增行为不变量**(比形状断言更硬):构造一个同时存在于 `$ORIGIN` 与 farm 的 + SONAME,断言 `LD_DEBUG=libs` 解析到 `$ORIGIN` 那一份。这是本次缺陷的直接判据 +3. 单测 `tests/unit/test_ninja_backend.cpp`:给带 `-Wl,-rpath,'$$ORIGIN'` 的 + 链接单元设置 `plan.runtimeSearch` 含一条 `Origin::SubosFarm`,断言在 + `ldflags + " " + unit_ldflags` 的合成串里 `$ORIGIN` 的位置 **早于** farm。 + (仅 Linux 分支产出 farm_ld,其它宿主 `GTEST_SKIP`) + +先写测试、确认变红,再改代码。 + +### C2(已批准,与 A 同一个 PR)— 共享库的 C++ 运行时契约改为 toolchain-coupled + +**今天**:mcpp 每建一个 `.so` 都按「要离开这台机器的成品」处理 ⇒ +`-static-libstdc++` ⇒ 把整个 libstdc++ 塞进这个 `.so` 并对外导出。 + +**C2**:`.so` 不再自带 std,而是 `NEEDED libstdc++.so.6`,运行期从 gcc 载荷目录 +解析 —— 那个目录本来就是 DT_RPATH 第 2 项,已经在了。 + +| | 今天(self-contained) | C2(toolchain-coupled) | +|---|---|---| +| 一个进程里几份 libstdc++ | exe 一份 + 每个 `.so` 一份 | 一份(见「残留」) | +| `bin/libXau.so` | 9.5 MB | ~20 KB(叠加 C3 后) | +| `.so` 单独拷走能不能跑 | 能(自带) | 不能,需带上 `libstdc++.so.6` | +| 跨 `.so` 边界抛 std 异常 | 有风险(两份 typeinfo) | 正常 | + +**改动点** + +1. `distribution.cppm:47-51` `Role` 拆出 `SharedLibrary` —— 今天 `Binary` 与 + `SharedLibrary` 共用 `Distributable`,注释就写着 "Binary / SharedLibrary — + leaves this machine",这一行正是本缺陷的策略源头 +2. `distribution.cppm:123` `default_contract`:新角色 → `Contract::ToolchainCoupled`; + `to_string(Role)`(`:97`)补一项 +3. `flags.cppm:83-92` `role_of`:`LinkUnit::SharedLibrary` 不再落到 `Distributable` +4. `flags.cppm:52,54`:`std::array<…, 3>` → `4` +5. manifest:`cxx_runtime` 已经是 role-aware 的表形式 + (`{ default = …, tests = … }`,`toml.cppm:933-949`),补一个 `shared` 键; + `types.cppm:421-426` 与 target 段的 `:621-622` 同步 +6. **机制表无需改动**:`distribution.cppm:330-338` 已把 ELF + libstdc++ + + ToolchainCoupled 处理成「不发任何标志」,驱动默认链 `libstdc++.so`,而 gcc + 载荷目录本来就在 `-L`/`-rpath` 里 + +**预期效果(可直接测)** + +- `nm -D --defined-only bin/libX11.so | grep -cE '_ZNSt|_ZNKSt|_ZSt'` 从 2931 → 0 +- exe 链接期 `-lX11` 不再满足 `runtime_error::what()` ⇒ 从 `libstdc++.a` 拉入 + ⇒ exe 的 `-static-libstdc++` **恢复为真** + +**⚠️ C2 会掩盖 A 的症状 —— 同 PR 时这是首要风险** + +C2 之后,即使 RPATH 顺序仍然是坏的,helloegui 也**不会再崩** —— 符号已经在 exe +内部。但产物加载的**仍然是 farm 里的 libX11 1.8.10,而不是链接时的 1.8.13**: +一次响亮的崩溃被换成一个静默的版本错配。 + +**所以 A 的回归测试绝不能依赖崩溃。** §4-B-2 那条行为不变量(同名 SONAME 必须 +解析到 `$ORIGIN`)不是锦上添花,它是 A 在 C2 之后唯一还能变红的判据。 +写测试的顺序必须是:先在未打 A 也未打 C2 的二进制上确认它红,再分别验证。 + +### C1(与 C2 同批)— 逃生舱的护栏 + +C2 之后,用户仍可显式写 `cxx_runtime = { shared = "self-contained" }` 让 `.so` +静态链 libstdc++。此时必须同时发 `-Wl,--exclude-libs,libstdc++.a` +(视情况含 `libsupc++.a` / `libgcc.a`),否则符号泛滥原样复现。 + +C1 不再是止血手段,而是让「非默认选项」不至于重新打开这个洞。 + +### C3(单独 PR,不阻塞本批)— 别把 `obj/std.o` 塞进不需要它的单元 + +**接缝已定位**:`ninja_backend.cppm:1362-1381` 对 `Binary` / `TestBinary` / +`SharedLibrary` **无条件**追加 `obj/std.o`,完全不看该单元是否真的 `import std`。 +实测 `bin/libXau.so` 的输入 = 8 个纯 C `.o` + 一个 `obj/std.o`。 + +C2 之后 `.so` 不再内嵌 libstdc++,但**仍然会因为 std.o 而 `NEEDED +libstdc++.so.6`** —— 纯 C 的 compat 包(libXau / libXdmcp / libX11)凭空多一条 +依赖。C3 把它摘掉,顺带让 libXau 回到 ~20KB、libX11 回到上游量级。 + +需要先确认的:`import std` 的传递性(依赖的 BMI 传递 import std 的历史坑见 +`dep-bmi-cache-cross-version-poisoning`),以及静态库单元的处理。 + +### 残留(记 issue,不在本批)— 两份 libstdc++ + +C2 之后,exe(SelfContained,静态)+ 真正用 C++ 的 `.so`(ToolchainCoupled, +动态)= 一个进程两份 std,跨 `.so` 边界抛 std 异常会失败(typeinfo 不同)。 +今天不会发生(compat 包都是纯 C,C3 还会把 std.o 摘掉),但这是 C2 引入的新形态。 + +顺带一个观察,值得写进那个 issue:本工程产物的 PT_INTERP 是 +`/xim-x-glibc/2.44/lib64/ld-linux-x86-64.so.2` —— **它本来就离不开载荷**, +所以 exe 上的 `-static-libstdc++` 在这个配置下几乎买不到东西。 +「exe 是否也该 ToolchainCoupled」才是那个 issue 的真正问题。 + +### D(低优先)— 首次运行 rule B 全线 inconclusive + +实测: + +| | `binding.loader` | `binding.library_dirs` | 警告 | +|---|---|---|---| +| 首次运行(同时安装工具链) | `""` | `[]` | 13 条 | +| 第二次 `mcpp build` | `…/xim-x-glibc/2.44/lib/ld-linux-x86-64.so.2` | `[…/xim-x-glibc/2.44/lib]` | 无 | + +`runtime_binding.cppm:337-380` 从 `/lib64`、`/lib` 里找 +`libc.so.6` 与唯一的 `ld-linux-*` 来填这两个字段;磁盘上二者都在(`lib/libc.so.6` +符号链接、唯一一个 `ld-linux-x86-64.so.2`),说明**首次运行时求值早于 farm 落盘**。 +精确接缝(binding 解析点 vs 载荷/farm 写入点)还需要一次探针确认,不要照着推理改。 + +影响:新用户的第一次构建看到 13 条自己无法处理的警告,而 rule B 恰在最该生效的 +那一次运行里失效。 + +**必须说清楚:即使 rule B 完全正常,它也抓不到本文的崩溃。** 它只比对 +libc / PT_INTERP 的同一性(`elf_runtime.cppm:761-801`),不管其它 SONAME 解析到谁。 +把 D 修好不能替代 A。 + +--- + +## 5. 两件事无关 + +首次运行的 13 条警告(D)与崩溃(A)在时间上同时出现,容易被读成一件事。 +第二次构建警告消失、崩溃照旧,已经把它们分开。 + +--- + +## 6. 实施顺序 + +**本批 PR = A + B + C2 + C1。** A+(i)、C3、D、以及「两份 libstdc++」各自独立。 + +1. **先写测试,并在未打任何补丁的二进制上确认全红** + - B-3 单测(`test_ninja_backend.cpp`):`$ORIGIN` 必须早于 farm + - B-1 e2e 219:断言 farm 是 DT_RPATH 的**字面**最后一项 + - B-2 e2e **行为**不变量:同名 SONAME 必须解析到 `$ORIGIN` + —— ⚠️ 这一条**不得依赖崩溃**,否则 C2 一落地它就假绿(见 §4-C2) +2. **A**:farm 移到 per-unit 尾部 → B-1 / B-3 转绿,B-2 转绿 +3. **C2 + C1**:角色拆分 + 契约改判 + `--exclude-libs` 护栏 + - 判据:`nm -D bin/libX11.so | grep -cE '_ZNSt|_ZNKSt|_ZSt'` = 0 + - 判据:exe 不再有 `U _ZNKSt13runtime_error4whatEv` +4. **合并验证(缺一不可)** + - helloegui 全链复现 ⇒ GUI 启动 + - 产物的 `libX11.so.6` 解析到 `$ORIGIN`(`LD_DEBUG=libs` 实证,不看形状) + - **把 A 单独 revert 掉,B-2 必须重新变红** —— 证明 C2 没有把 A 的判据吃掉 +5. 全量单测 + e2e(注意既有本机噪声:共享 gcc specs 污染、`pipefail`+`grep -q` + 的 SIGPIPE flake —— 本机红需逐条与已发布二进制比对,不可直接当回归) +6. 生态验证:C2 改的是 `.so` 的运行期契约,发版前必须在真实 mcpp-index + workspace 上跑一遍(compat 包全是 `.so` 的重灾区) +7. 独立开:**A+(i)**、**C3**、**D**、**两份 libstdc++** + +## 6.1 决策记录(2026-08-11) + +| 项 | 决定 | +|---|---| +| C2(共享库 → toolchain-coupled) | **采纳**,与 A 同一个 PR | +| C1(`--exclude-libs` 护栏) | 随 C2 一起 | +| A+ | 只做 (i) 轻档,**单独 PR**;(ii) 记 issue | +| C3(`std.o` 无条件追加) | 单独 PR,不阻塞 | +| D(首次运行 rule B inconclusive) | 单独 issue,需先探针定位接缝 | + +--- + +## 7. 附:关键实测命令 + +```bash +# 顺序 +readelf -d bin/helloegui | grep RPATH +# 谁被加载 +LD_DEBUG=libs <私有 loader> bin/helloegui 2>&1 | grep -A6 'find library=libX11' +# 两份库的差别 +nm -D --defined-only bin/libX11.so | grep -cE '_ZNSt|_ZNKSt|_ZSt' # 2931 +nm -D --defined-only /xim-x-libX11/1.8.10/lib/libX11.so.6 | grep -cE '_ZNSt|_ZNKSt|_ZSt' # 0 +# 决定性 A/B +patchelf --force-rpath --set-rpath ":<原有其余项>" ./helloegui-copy && timeout 8 ./helloegui-copy +``` + +> 注:本机 `readelf` / `ldd` 被 xlings shim 劫持(`readelf` 那条还指向一个已消失的 +> scratchpad 路径),诊断一律走 `/usr/bin/` 绝对路径。 diff --git a/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md b/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md new file mode 100644 index 00000000..e52e09e8 --- /dev/null +++ b/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md @@ -0,0 +1,973 @@ +# 源文件角色表 与 build.mcpp 运行上限 —— 把两个硬编码变成两条声明 + +> 日期:2026-08-11 +> 基线:main `e53204a`(`2026.8.10.3`) +> 来源:[PR #272](https://github.com/mcpp-community/mcpp/pull/272)(OPEN,未合)、 +> [issue #410](https://github.com/mcpp-community/mcpp/issues/410)(OPEN) +> 本文引用的 file:line 全部核于上述基线;行号是证据,不是装饰。 + +--- + +## 0. 结论先行 + +两个诉求表面无关,底下是同一个形状:**一个本该是数据的决策,现在是代码。** + +| | PR #272 | issue #410 | +|---|---|---| +| **诉求** | `.ccm` / `.cxxm` / `.ixx` 也要能当模块接口 | build.mcpp 跑久一点别被直接杀掉 | +| **现在的形态** | 「扩展名 → 角色」在 **20 处独立推导**,分成 **8 份互不一致的清单** | `run_timeout()` 是个**无参函数**,唯一入口是环境变量 | +| **改成** | 单一分类器 `SourceKind` + `[build] module_extensions` | `[build] build_program_timeout` | +| **默认行为** | **图形态零差分**(内置表不动,仍只有 `.cppm`;rule 文本会变,见 §3.8.3) | **零差分**(仍是 600s) | +| **进不进指纹** | **进**(它改构建图的形态) | **不进**(它是策略标量,不改图) | +| **老版 mcpp 遇到它** | 警告+忽略 ⇒ **构建出错的东西** ⚠️ | 警告+忽略 ⇒ 退回 600s,**报错文案清晰** ✅ | + +最后两行是这份方案里唯一需要记住的架构判据: + +> **一个配置键进不进指纹,只问一个问题:它改变了「图是什么」,还是只改变了「跑图时的策略」。** +> `module_extensions` 决定哪个文件产 BMI、哪个 `.o` 进链接 ⇒ 进。 +> `build_program_timeout` 不改任何一条边 ⇒ 不进(进了会让「把超时从 600 调到 1800」重建全世界)。 + +第四行(**默认图形态零差分**)是这份方案的稳定性支点,也是它与 PR #272 现有实现最大的分歧点。见 §1.3。 + +还有一条贯穿全文的判据,来自 §3.8.1 那次实测: + +> **在「维护一张会过期的知识表」和「无条件做一件已证明幂等的事」之间,永远选后者。** +> 前者错了是**退出码 0 的静默空转**,后者错了什么都不会发生。 + +--- + +## 1. 为什么 PR #272 不能按现在的样子合 + +PR #272 改了 4 个文件(`plan.cppm` +4/-4、`execute.cppm` +3/-2,加两个测试), +把链接侧的 `.cppm` 判断换成了 `cu.providesModule`(**这一步是对的,本方案保留**), +并让 4 种扩展名共享 `.m` 对象名前缀。 + +**先说两个状态事实**(核于 2026-08-11): + +- PR 基线是 `27d250c`,**main 已领先它 79 个提交**;`plan.cppm` 的行号从 155 漂到 303。 +- **CI 当前 10 个 job 红**(linux/windows/macOS 的 e2e、unit、cross 全红)。 + 本文**不把红全部归因给下面的缺陷** —— 79 个提交的漂移足以单独解释一部分。 + +下面三条是与基线漂移无关的、机制层面的缺陷。 + +### 1.1 漏了 `pick_rule` —— Clang 侧不产 BMI + +`src/build/ninja_backend.cppm:1005`: + +```cpp +auto pick_rule = [](const std::filesystem::path& src) -> std::string { + auto ext = src.extension(); + if (ext == ".cppm") return "cxx_module"; // ← 只认 .cppm + ... + return "cxx_object"; +}; +``` + +两个调用点(`:1191` dyndep 模式、`:1235` static 模式)都只传 `cu.source`。 +PR #272 **完全没有动这个文件**。 + +GNU 方言下,`cxx_module`(`:668`)与 `cxx_object`(`:696`)的差别就是一个 +`module_output_flag`(`:523`,`traits.needsExplicitModuleOutput` 时展开为 +`-fmodule-output=$bmi_out`)。于是一个 `.ixx` / `.ccm` 单元: + +- 边上**照样声明了 BMI 产物**(`:1193`,`if (cu.providesModule) out_line += " | " + bmi_path(...)`) +- 命令行里**没有 `-fmodule-output=`** + +⇒ Clang 上 ninja 报「declared output not produced」; +GCC 上 `gcm.cache` 是驱动的自动行为,可能照旧产出 ⇒ **同一份代码两个编译器两种结果**。 + +而 e2e `152_module_extensions.sh` 的第 2 行是 `# requires: elf gcc` —— +**Clang 与 MSVC 路径从未被这条测试走到**。 + +> 注:`module_src_flags`(`:667`)是 `msvcDeps ? " /interface /TP" : ""` —— +> **只对 MSVC 生效**,GNU 方言下是空串。也就是说 GCC/Clang 侧现在**一个 `-x` 都不发**。 +> 这条直接引出 §3.8 那个必须靠实测才能定下来的问题。 + +### 1.2 `.m` 前缀给四种扩展名 = 新开一个对象名碰撞 + +`src/build/plan.cppm:293`: + +```cpp +if (ext == ".S" || ext == ".s" || ext == ".asm") + return src.filename().string() + objExt; // 保留完整扩展名 —— 这是对的解法 +auto stem = src.stem().string(); +return stem + (ext == ".cppm" ? ".m" + objExt : objExt); +``` + +PR #272 把条件放宽成「四种扩展名都用 `.m`」。于是同目录下的 +`foo.cppm` 与 `foo.ccm` **双双变成 `foo.m.o`**。 + +`object_for`(`:1099`)的兜底救不了它:`rootBasenameCount[fname] > 1` 时的处理是 +**把源码目录结构镜像进对象路径**,而这两个文件本来就在同一个目录,镜像之后仍然重名。 + +注意汇编分支(`:298`)早就把这题做对了 —— **不认识的扩展名就保留完整文件名**。 +正确的推广是沿用它,而不是扩大 `.m` 的适用面。见 §3.5。 + +⚠️ PR 新增的单测 `tests/unit/test_module_extensions.cpp` **把这个碰撞钉成了预期行为**: + +```cpp +TEST(ModuleExtensions, CppmGetsDotMPrefix) { EXPECT_EQ(object_filename_for("src/foo.cppm", ".o"), "foo.m.o"); } +TEST(ModuleExtensions, CcmGetsDotMPrefix) { EXPECT_EQ(object_filename_for("src/foo.ccm", ".o"), "foo.m.o"); } +``` + +同一个函数、同一个输入 stem、同一个输出。**采纳本方案时这两条断言必须反过来写** +(`foo.ccm` → `foo.ccm.o`),否则测试会保护住缺陷。 +e2e 里的 `grep -q "info.m.o"` 等四条同理。 + +### 1.3 直接写进内置默认 = 对已发布包的破坏性变更 + +这是决定方案形态的一条,也是**本方案与 PR #272 的根本分歧**。 + +若把 `.ixx` 加进内置默认,`src/manifest/toml.cppm:1610` 的默认 sources glob 必然要跟着变成 +`src/**/*.{cppm,ccm,cxxm,ixx,cpp,cc,c,S,s,asm}`。后果: + +> **任何一个 `src/` 下躺着 `.ixx` 的已发布包,升级 mcpp 之后会突然开始编译它。** + +`.ixx` 尤其危险 —— 它是 MSVC 的拼法,现实中最常见的存在形式就是 +「vendored 的 MSVC-only 源码,当前平台上根本编不过」。 +包描述符按版本冻结,而 mcpp 是滚动升级的 ⇒ **一次 mcpp 升级会让一批已发布包同时变红, +且包作者无法通过改包来避免**(旧版本的 tarball 已经发出去了)。 + +这是「索引下限把旧客户端变砖」那条判据的另一面: +**发布出去的数据不得让程序失效;升级的程序也不得让已发布的数据失效。** + +⇒ **内置表保持现状(只 `.cppm`),四种扩展名全部走配置、opt-in。** +默认路径上生成的 `build.ninja` 与今天逐字节相同。 + +**PR #272 自己的 e2e 已经证明了这个形态是对的** —— 它的 fixture 里有这么一行注释和一份手写 manifest: + +```bash +# Default glob only picks up .cppm/.cpp/.cc/.c — explicitly add .ccm/.cxxm/.ixx +cat > mcpp.toml <<'EOF' +[modules] +sources = ["src/**/*.cppm", "src/**/*.ccm", "src/**/*.cxxm", "src/**/*.ixx", "src/**/*.cpp"] +EOF +``` + +也就是说:**即使按 PR #272 的实现,用户仍然必须在 mcpp.toml 里显式 opt-in**, +只不过 opt-in 的方式是手写五条 glob、且**语义分散在两处** +(`sources` 决定「编不编」,代码里的硬编码清单决定「算不算模块」)。 +本方案做的事就是把这两处合成一处声明。 + +(顺带:那份 fixture 用的是 `[modules] sources` —— 遗留镜像键;新文档一律用 `[build] sources`。) + +--- + +## 2. 共同根因:同一决策 20 处推导 + +`grep -rn 'extension()\s*==\|ext ==\|ext !=' src/ --include=*.cppm` 的完整结果, +按「它在回答什么问题」归并: + +| # | 位置 | 在问什么 | 用的清单 | `.ixx` 走到这里 | +|---|---|---|---|---| +| 1 | `modgraph/scanner.cppm:573` | 是否 C-like(跳过模块扫描) | `.c .m .S .s .asm` | ✅ 落入 C++ 扫描 | +| 2 | `modgraph/scanner.cppm:690` | 是否实现单元 | `.cpp` | ⚠️ 与 `.cc/.cxx` 同病 | +| 3 | `modgraph/p1689.cppm:388` | 是否模块接口(兜底) | `.cppm` | ⚠️ 全靠编译器 ddi | +| 4 | `modgraph/p1689.cppm:389` | 是否实现单元 | `.cpp .cxx` | ⚠️ **与 #2 不同的清单** | +| 5 | `build/plan.cppm:293` | 对象名怎么起 | `.S .s .asm` / `.cppm` | ❌ 与 `foo.cpp` 撞名 | +| 6 | `build/plan.cppm:388` | 是否实现单元 | `.cpp .cc .cxx .c .m .S .s .asm` | ⚠️ **第三份清单** | +| 7 | `build/plan.cppm:1363` | 模块对象进不进链接 | `.cppm` | ❌ **`.o` 不进链接** ← #272 修了 | +| 8 | `build/plan.cppm:1425` | 同上(link unit) | `.cppm` | ❌ 同上 ← #272 修了 | +| 9 | `build/ninja_backend.cppm:235/240/247` | C / GAS / NASM / 免扫描 | `.c .m` / `.S .s` / `.asm` | ✅ | +| 10 | `build/ninja_backend.cppm:1005` | 用哪条 ninja rule | `.cppm` | ❌ **不产 BMI** ← #272 **漏了** | +| 11 | `build/execute.cppm:591` | 快路径要不要作废 | `.cppm .cpp .cc .cxx .c .h .hpp` | ❌ **静默陈旧** | +| 12 | `build/directives.cppm:649` | 生成物可不可编译 | `…… .cppm .ixx` | ⚠️ **唯一含 `.ixx` 的清单** | +| 13 | `build/compile_commands.cppm:81` | CDB 里算不算 C | `.c .m` | ✅ | +| 14 | `build/compile_commands.cppm:236` | CDB 里算不算 GAS | `.S .s` | ✅ | +| 15 | `build/prepare.cppm:5465` | MSVC 要不要拒绝 GAS | `.S .s` / `.asm` | ✅ | +| 16 | `manifest/toml.cppm:1610` | 默认 sources glob | `cppm cpp cc c S s asm` | ❌ **根本 glob 不到** | +| 17 | `manifest/toml.cppm:1642` | 自动推断 lib target | `.cppm` | ❌ 纯 `.ixx` 库无 target | +| 18 | `build/prepare.cppm:3371` | stage 的兜底 glob | `cppm cpp cc c` | ⚠️ **比 #16 少 asm** | +| 19 | `manifest/types.cppm:1027` | `[lib]` 约定路径 | `.cppm` | — | +| 20 | `toolchain/clang.cppm:196` | std 模块源要不要 `-x c++-module` | `.ixx` | (仅 std) | + +**8 份清单**(#2 / #4 / #6 三份「什么算实现单元」互不相同;#11 / #12 / #16 / #18 四份「什么算源文件」互不相同)。 + +### 2.1 最危险的一处:#11 快路径漏扫 + +`src/build/execute.cppm:591` 是**唯一一处「漏了会静默出错、不会报错」**的: + +```cpp +for (auto& f : expand_glob(projectRoot, "src/**/*")) { + auto ext = f.extension().string(); + if (ext != ".cppm" && ext != ".cpp" && ext != ".cc" && + ext != ".cxx" && ext != ".c" && ext != ".h" && ext != ".hpp") + continue; // ← .ixx 在这里被跳过 + if (last_write_time(f) > ninjaTime) return true; +} +``` + +这个 sweep 回答的不是「文件变了没有」(那是 ninja 的活),而是 +**「构建图的形态可能变了没有」**。一个 `.ixx` 里新加一句 `import foo;`: + +- 扫描器不会重跑(快路径判定为 fresh) +- dyndep 仍是上一次的 +- ninja 照旧重编这个 `.o`(它的 mtime 变了),**但 BMI 依赖边是旧的** +- 结果:链接成功 / 运行出错,或者一个指向别处的 `Bad import dependency` + +**这就是「修补放在控制流到不了的地方」的镜像版本 —— 判据放在了控制流会跳过的地方。** +PR #272 改了这一行(加了 3 个扩展名),但仍然是第 4 份手写清单; +下一个新增语义还会漏第 5 次。 + +--- + +## 3. 设计 A:分类一次,当数据传下去 + +### 3.1 判据 + +| # | 判据 | 检验方式 | +|---|---|---| +| A-1′ | 未配置 `module_extensions` 时,构建图的**形态**相同:哪些文件被编、产不产 BMI、哪些 `.o` 进链接、快路径扫哪些文件 | 归一化 diff `build.ninja`,**diff 里只允许出现 rule 定义行**(理由见 §3.8.3) | +| A-2 | 「扩展名 → 角色」在**产品代码里只有一处**表达 | `grep -c 'extension() ==' src/` 只剩分类器 | +| A-3 | 分类**发生一次**(单元入图时),下游读字段而非重新判定 | `CompileUnit::kind` 的读者数 ≫ `classify` 的调用点数 | +| A-4 | 改 `module_extensions` 必然重新 prepare,且落到不同的 `target///` | 改键前后 `mcpp build --print-fingerprint` 不同 | +| A-5 | 任意两个源文件永不共享对象路径 | 单测穷举 `object_filename_for` | + +### 3.2 新叶子模块 `src/source_kind.cppm` + +**放在 `src/` 根、只 `import std`**,不是 `src/build/` 下 —— +因为 `mcpp.manifest.toml`(#16/#17)和 `mcpp.modgraph.scanner`(#1/#2)都要用它, +而 `mcpp.build.*` 依赖 `mcpp.manifest`,反向依赖会成环。 + +```cpp +export module mcpp.source_kind; +import std; + +export namespace mcpp { + +enum class SourceKind { + ModuleInterface, // 产 BMI;.o 无条件进链接单元 + Cxx, // C++ 实现单元(含模块实现分区) + C, // .c / .m —— 走 C 规则,不扫描 + GasAsm, // .S / .s —— C driver + NasmAsm, // .asm + Header, // .h / .hpp —— 不编译,但改动会改图形态 + Other, // 不是构建输入 +}; + +// 内置表 + manifest 追加项。按值传,构造极廉价(一次 vector 拷贝), +// 因为 prepare 是多包的:root 与每个依赖各有自己的表, +// 全局单例会让依赖包被消费者的配置分类 —— 那是 #405 那类跨包污染的形状。 +struct ExtensionTable { + // 内置:{".cppm"} —— **不含** .ccm/.cxxm/.ixx,见 §1.3 + std::vector moduleInterface; + // 其余四轴当前是常量。留结构不留键:加 cxx_extensions 时 + // 只需在这里加一个 vector + 一处 parse,不需要动 classify 的形状。 +}; + +ExtensionTable builtin_extension_table(); +ExtensionTable extension_table_for(std::span manifestExtras); + +SourceKind classify(const std::filesystem::path& p, const ExtensionTable& t); + +// 由 SourceKind 导出的谓词 —— 供只关心一个轴的读者用, +// 保证它们不会各自再写一份 switch。 +bool produces_bmi(SourceKind k); // == ModuleInterface +bool is_scan_exempt(SourceKind k); // C / GasAsm / NasmAsm +bool links_unconditionally(SourceKind k); // ModuleInterface +bool affects_graph_shape(SourceKind k); // 除 Other 之外全部 —— #11 用它 + +// 默认 sources glob 由表导出,而不是与表并列手写。#16/#18 的两份清单 +// 就是「并列手写」的产物。 +std::vector default_source_globs(const ExtensionTable& t); + +} // namespace mcpp +``` + +`ExtensionTable` 的构造要**规范化**:补前导 `.`、小写化(Windows 文件系统大小写不敏感, +`FOO.IXX` 与 `foo.ixx` 必须同类)、去重、拒绝空串。规范化只在构造时做一次, +`classify` 里不做 —— 它在扫描热路径上,每个源文件调用一次。 + +### 3.3 分类只发生一次 + +`SourceUnit`(`src/modgraph/graph.cppm:15`)已经有 `packageName` / `relPath`, +`scan_one_into`(`src/modgraph/scanner.cppm:747`)的签名里**已经有 `manifest`**。 +所以不需要任何新的管道 —— 这是本方案最省的一处: + +```cpp +// scanner.cppm,scan_one_into 内,每个源文件一次 +u.kind = classify(file, table); // table 来自这个包自己的 manifest +``` + +然后: + +| 结构 | 新增字段 | 谁写 | 谁读 | +|---|---|---|---| +| `SourceUnit` | `SourceKind kind` | scanner(#1/#2)、p1689(#3/#4) | plan | +| `CompileUnit` | `SourceKind kind` | `make_plan` 从 `SourceUnit` 拷贝 | #5 #6 #7 #8 #9 #10 #13 #14 #15 | + +`SourceUnit::isModuleInterface` / `isImplementation` 两个 bool 变成 `kind` 的导出量。 +**保留它们**(约 20 处读者),但改成从 `kind` 赋值的派生字段,不再各自判断扩展名 —— +一次性删掉它们会把这个 PR 的改动面翻倍,而收益是 0。 + +于是 `ninja_backend.cppm:1005` 变成: + +```cpp +auto pick_rule = [](const CompileUnit& cu) -> std::string { + switch (cu.kind) { + case SourceKind::ModuleInterface: return "cxx_module"; + case SourceKind::C: return "c_object"; + case SourceKind::GasAsm: + return cu.source.extension() == ".S" ? "asm_object" : "asm_object_raw"; + case SourceKind::NasmAsm: return "nasm_object"; + default: return "cxx_object"; + } +}; +``` + +> **`.S` vs `.s` 是 GasAsm 内部唯一保留的扩展名判断**,因为它区分的是 +> 「过不过预处理器」,那是同一角色下的两种编译方式,不是两个角色。 +> 强行拆成 `GasAsmPreprocessed` / `GasAsmRaw` 会让枚举去表达编译器旗标 —— 那是越界。 + +**唯一不能读字段的是 #11(快路径)** —— 它跑在 prepare 之前,手里没有 plan。 +它调 `classify(f, table)`,`table` 来自它已经加载的 manifest。这是**唯一**一处二次分类, +而且它与 scanner 读的是同一个 manifest 的同一个键,不可能漂移。 + +### 3.4 配置面 + +```toml +[build] +module_extensions = [".ixx", ".ccm", ".cxxm"] +``` + +| 决策 | 取值 | 理由 | +|---|---|---| +| 语义 | **追加**到内置 `[".cppm"]`,不能删 | 删掉 `.cppm` 没有任何正当用例,却能让整个包静默变成「无模块」 | +| 任意后缀 | **全放行** | §3.8.1 之后,放行的代价是零 —— mcpp 不再需要知道编译器认不认。`.mpp` / `.cppmi` / 任何拼法都行 | +| 唯一的守卫 | **拒绝**与内置非模块角色冲突的后缀(`.cpp` `.cc` `.cxx` `.c` `.m` `.mm` `.h` `.hpp` `.S` `.s` `.asm`) | 「声明 `.c` 是模块接口」不存在正当用例,却会让 C 文件走 C++ 模块规则,炸在一个没法诊断的地方。解析期硬错误,不是警告 | +| 排除某文件 | 用 `sources` 的 `!` 前缀 | 已有机制,不再造第二个 | +| 作用域 | **每个包读自己的 manifest** | 与 `cflags` / `globFlags` 同构;消费者不该决定依赖的源码怎么分类 | +| 传播 | **不传播** | 它是包的私有构建属性,不是 usage requirement | +| 默认 sources glob | 随表**自动扩展** | 否则声明了 `module_extensions` 却什么都不发生 —— 见 §3.6 | + +### 3.5 对象名:一个总函数,零碰撞 + +`object_filename_for` 改成对 `SourceKind` + 扩展名的**总函数**,规则只有三条: + +``` +.cpp → foo.o (今天的行为,不动) +.cppm → foo.m.o (今天的行为,不动) +其余 → foo..o (汇编分支 :298 早就这么做,现在推广到全部) +``` + +于是 `foo.ixx → foo.ixx.o`、`foo.ccm → foo.ccm.o`,与 `foo.cppm → foo.m.o` 三者互不相撞。 + +**为什么 `.cpp` / `.cppm` 必须原样不动**:对象名是全局依赖缓存条目的**内部布局** +(`CompileUnit::packageObjectRel`,`plan.cppm:1094`)。缓存键不变而条目内布局变了, +后果不是「缓存未命中」而是 **「命中一个不含所需对象的条目」** ⇒ 链接期缺 `.o`。 +`plan.cppm:1091-1094` 的 `mcpp#344` 注释守的就是这件事 +(「a pure function of the owning package」)。改这两个的代价是缓存键必须同步改版, +而收益是 0(它们今天不撞)。 + +⚠️ **已知遗留(不在本方案范围)**:同目录下的 `foo.c` 与 `foo.cpp` 今天都产出 `foo.o`, +真实碰撞,且 `rootBasenameCount` 兜底无效(理由同 §1.2)。 +修它需要动缓存条目布局 ⇒ **单独一个 issue,配缓存键版本号**。 +本方案在单测里把这条**显式标记为已知失败**,而不是让穷举测试假装通过。 + +### 3.6 联动:不联动的话这个键什么都不做 + +| 位置 | 改法 | +|---|---| +| #16 `toml.cppm:1610` | 默认 glob 由 `default_source_globs(table)` 生成 | +| #17 `toml.cppm:1642` | `hasCppm` → `hasModuleInterface`,用 `classify` | +| #18 `prepare.cppm:3371` | 删掉这份兜底清单,复用 `default_source_globs` | +| #19 `types.cppm:1027` | `[lib]` 约定路径仍产 `.cppm` —— **这是写路径,不是读路径**,不动 | + +**顺序**:`module_extensions` 在 `apply_defaults_and_infer` 之前解析完成 +(`toml.cppm` 的 parse → defaults 顺序天然满足),不需要调整。 + +用户显式写了 `sources` 时,他的 glob 优先,和今天一样。 + +### 3.7 指纹与缓存键 + +折进 `prepare.cppm` 的规范化 compile-flags 串,**root 块与 per-package 块各一处** +(`:265` 与 `:370`),形式抄 `globFlags`: + +```cpp +for (auto const& e : m.buildConfig.moduleExtensions) { s += " modext:"; s += e; } +``` + +per-package 那一处不能省。理由与 `#253` 给依赖的 `globFlags` 补指纹是同一条: +path / git 依赖不按版本冻结,它的 `module_extensions` 改了就是另一份产物。 + +### 3.8 编译器方言:两组实测,和一张被删掉的表 + +这一节原本写的是推理。第一组实测推翻了推理,第二组实测又推翻了第一组得出的设计。 +两次都记在这里 —— **推导路径本身是这份方案里最有价值的部分**。 + +**这不是「编译器不支持模块」的问题**,而是低一层的东西:每个驱动都有一张 +「后缀 → 这是什么语言」的映射表,**表里没有的后缀被当成链接输入**(和 `.o`/`.a` 一类), +转手给链接器,根本不进编译前端。`.ixx` 是 MSVC 的约定(`cl.exe` 的原生模块接口后缀), +Clang 的表收了 `.cppm`/`.ccm`/`.cxxm`,**没收它**。 + +**实测**(2026-08-11 本机,GCC 16.1.0 / Clang 22.1.8)。 +判据不是「有没有警告」而是**给文件塞一个语法错误,看驱动有没有真的编它**: + +``` +$ printf 'export module m;\nthis is not valid c++ @@@\n' > bad. +$ -std=c++23 -fsyntax-only bad. +``` + +| 后缀 | GCC 16.1.0 | Clang 22.1.8 | MSVC `cl` | +|---|---|---|---| +| `.cppm` | ✅ 报语法错误(进了前端) | ✅ 报语法错误 | ❌ 需 `/interface /TP` | +| `.ccm` / `.cxxm` | ✅ | ✅ | ❌ 推定同 `.cppm`(**未验证**) | +| `.ixx` | ✅ | ❌ **`'linker' input unused`** —— 没编 | ✅ **原生** | + +**三个编译器三张表,而 MSVC 与 Clang 正好互为盲区 —— 没有一个后缀三家通吃。** + +MSVC 两格的证据等级与前两列不同(本机无 MSVC),但都取自本仓库: + +- `.ixx` 原生:`toolchain/msvc.cppm:517-537` 编 MSVC STL 的 `modules/std.ixx` 用的是 + `cl /nologo /EHsc /O2 /W0 /c std.ixx /ifcOutput ... /Fo:...` —— + **没有 `/interface`,没有 `/TP`**,裸 `/c` 就产出 IFC,且这条路在 Windows CI 上跑着。 +- `.cppm` 非原生:`ninja_backend.cppm:666` 的注释原话是 + 「cl.exe needs /TP (our module interfaces are .cppm, **unknown to cl**) and /interface」。 +- `.ccm` / `.cxxm` 两格是**推定**,⚠️ 必须在 Windows CI 上补测,不得当结论用。 + +**最阴的是它不报错**: + +``` +$ clang++ -std=c++23 ok.ixx --precompile -o ok.pcm +clang++: warning: ok.ixx: 'linker' input unused +$ echo $? → 0 # 成功 +$ ls ok.pcm → 不存在 # 但 BMI 没有 +``` + +**退出码 0、零个 error、产物不存在。** 这正是 §1.1 那个缺陷在 Clang 上的实际形态: +命令跑完退 0 什么也没产出,报错要等到下游 `import` 时才炸,且指向别处。 + +解药一行,实测有效: + +``` +$ clang++ -std=c++23 -x c++-module ok.ixx --precompile -o ok.pcm + → ok.pcm, 18892 字节 +``` + +`toolchain/clang.cppm:193-200` 早就为 MSVC STL 的 `std.ixx` 记着同一件事 +(「Clang doesn't recognize the .ixx extension as a module source by default」), +只是那段今天只服务 `stdModuleSource`。 + +**到这里为止,自然的设计是「一张 per-方言 的原生后缀表 + 按需补旗标」。 +下一节的第二组实测把这个设计否掉了。** + +### 3.8.1 结论:把「谁认哪个后缀」这张表**整个删掉** + +初稿在这里设计了一张 `nativeModuleSuffixes` 表 + 一个 `declarePolicy`。 +**实测把它否掉了。** 关键的一组测量: + +| | GCC 16.1.0 | Clang 22.1.8 | MSVC `cl` | +|---|---|---|---| +| 显式声明模块接口的旗标 | `-x c++` | `-x c++-module` | `/interface /TP` | +| 该旗标**加在原生后缀上**是否幂等 | ✅ 尺寸相同,差异位置=噪声(下注) | ✅ **逐字节相同**(18896 = 18896) | ✅ **今天就在无条件发** | +| 该旗标能否**救回**不认识的后缀 | ✅ `.weirdext` 产出 gcm | ✅ `.ixx` 产出 18892 字节 pcm | ✅(它就是为此存在的) | +| 交叉误用 | `-x c++-module` → `language not recognized` | `-x c++` → **174 字节空壳 pcm** | — | + +> ⚠️ **方法论下注**:GCC 的 gcm **本身不可复现** —— 同一条命令跑两次, +> 差异出现在 byte 605;`-x c++` 那次差异在 byte 602,**同一处噪声**。 +> 我第一次读成「`-x c++` 改变了产物」,是**缺对照**。 +> 任何「加了旗标产物就变了」的判断,都必须先跑一次「不加旗标跑两遍」的对照。 + +⇒ **既然旗标幂等,就没有理由去判断「要不要发」。永远发。** + +``` +dialect.moduleInterfaceFlag // gcc: "-x c++"; clang: "-x c++-module"; msvc: "/interface /TP"(现状) +``` + +`cxx_module` 规则无条件带上它。**没有第二张表,没有 policy 枚举,没有版本知识。** + +注意这个常量是按**编译器家族**分的,不是按 `CommandDialect`(gnu/msvc)—— +gcc 与 clang 同属 gnu 方言但取值不同(GCC 直接拒绝 `-x c++-module`)。 + +### 3.8.2 为什么 X 优于查表:失败模式不对称 + +| | 查表 | 永远显式 | +|---|---|---| +| 表错了会怎样 | **退出码 0、无 BMI、下游 `import` 时炸在别处**(§3.8 那个静默空转) | 多发一个幂等旗标,什么都不发生 | +| 维护成本 | 3 家 × N 版本,每次升编译器重验 | 一个家族常量,零 | +| 知识时效 | 会过期(GCC 16 认 `.ixx`,GCC 14 呢?) | 无时效 | + +**一张会过期、错了还不报错的表,不该存在。** + +顺带:**MSVC 是三家里唯一一开始就做对的** —— 它今天就是「永远显式说」。 +本方案只是把 GNU 侧对齐到 MSVC 已有的做法,不是发明新机制。 + +### 3.8.3 这推翻了 A-1 的措辞(不是推翻它的意图) + +不敢做 X 的唯一理由是「会改掉每条现存 `.cppm` 命令行」。**这条站不住**: + +`toolchain/fingerprint.cppm` 的**第 8 个字段就是 mcpp version**。 +任何 mcpp 升级都已经换 `target///` 并重建全部, +而这个 `-x` 改动**只可能随一个新 mcpp 版本发布** ⇒ 成本已被版本指纹全额吸收, +**增量为零**。A-1 当初防的是一笔 mcpp 自己每次发版都在付的钱。 + +⇒ **A-1 改写为 A-1′**(§3.1 同步): + +> **未配置 `module_extensions` 时,构建图的形态相同** —— +> 哪些文件被编、产不产 BMI、哪些 `.o` 进链接、快路径扫哪些文件。 +> **rule 文本可以变**;验证方式从「空 diff」改为「diff 里只有 rule 行」。 + +--- + +## 4. 设计 B:`build_program_timeout` + +### 4.1 先否掉 issue #410 的字面诉求 + +issue 原文是「希望能够在超时时**询问用户**, 而不是直接中断」。**不做**,理由三条: + +1. **没有可用的交互通道。** build.mcpp 通过 `capture_exec_deadline` + (`build_program.cppm:766`)运行,stdout/stderr 被 dup2 进管道用于解析 + `mcpp:` 指令协议。要弹交互,得先把这个通道劈开。 +2. **构建的多数发生地没有人。** CI、`mcpp build` 进流水线、被 ninja 当子进程调 —— + 一个卡在 prompt 上的构建比一个失败的构建更难诊断(它不会失败,它会一直挂着)。 +3. **构建结果不该依赖一次击键。** 同一份源码 + 同一份 lock,两次构建应当等价。 + +**替代品是把诊断做对**:报错要指名**改哪个文件的哪个键**。见 §4.5 —— +issue #410 的作者真正需要的信息就是这个。 + +同时 maintainer 在 issue 里已经指出:「build.mcpp 一般只做代码生成/预处理/后处理, +不应该把整个构建期放到 build.mcpp 里」。这条**判断是对的,但不构成拒绝配置的理由** —— +600 s 对「opencv 全量编译」不够,对「protoc 生成 400 个文件」同样不够, +而后者完全是 build.mcpp 的正当用法。 + +### 4.2 键与语义 + +```toml +[build] +build_program_timeout = 1800 # 秒;0 = 不限 +``` + +```cpp +// manifest/types.cppm — BuildConfig +std::optional buildProgramTimeoutSecs; +``` + +⚠️ **`std::optional` 是承重的,不是风格。** 用 `int = 0` 的话「没写」与「写了 0」不可区分, +而 0 的含义是**不限** ⇒ 每个没写这个键的工程都会静默失去运行上限。 +`execute.cppm:1010-1014` 的注释已经把这条判据写死过一次: +「`--timeout 0` still means "no limit", it just has to be asked for」。 + +解析期校验:非整数 → 错误;负数 → 错误。**不接受 `"30m"` 时长串** —— +`--timeout SECS`、`--build-timeout SECS`、`MCPP_BUILD_PROGRAM_TIMEOUT` 全是秒制, +第二种拼法只会制造「哪个键吃哪种格式」的记忆负担。 + +### 4.3 优先级 + +``` +MCPP_BUILD_PROGRAM_TIMEOUT (每次调用的显式覆盖) + > 该包自己的 [build] build_program_timeout (包作者的知识) + > 内置 600s (基线) +``` + +与 `macos_deployment_target` 已文档化的优先级同构(env > manifest > 内置), +不新造一套。 + +**「该包自己的」是关键**:依赖包的 build.mcpp 用依赖包 manifest 里的值。 +消费者不知道 opencv 的生成器要跑多久,opencv 的作者知道。 +消费者需要拔高时用 env(它是全局的,这正合适 —— 卡住的是**这一次**构建)。 + +### 4.4 唯一的策略函数 + +`src/build/directives.cppm:377` 的 `run_timeout()` 现在是无参的,而且**自己 `getenv`** +—— 于是优先级逻辑无法单测(只能改进程环境)。拆成两个函数, +**优先级在纯函数里解一次**,而不是在调用点拼: + +```cpp +// directives.cppm +inline constexpr int kDefaultRunTimeoutSecs = 600; + +// 唯一读环境的地方;解析失败 / 负数 → nullopt(沿用现有的宽容行为) +std::optional env_timeout_override(); + +// 纯函数:两个 optional 进,一个时长出。优先级只在这里表达。 +std::chrono::milliseconds run_timeout(std::optional envSecs, + std::optional manifestSecs); +``` + +调用点(`build_program.cppm:766`)写成 +`run_timeout(env_timeout_override(), m.buildConfig.buildProgramTimeoutSecs)`; +`m` 本来就在手边(`:770` 已经在用 `m.package.name`)。 + +T-3 于是可以穷举 3 × 3 = 9 种组合,一次进程环境都不用碰。 + +### 4.5 诊断:指名改哪个文件 + +现在的文案(`build_program.cppm:768`)只说 env。新文案必须回答 +**「我该改哪一个 mcpp.toml」** —— 依赖包超时时,用户的直觉是改自己的,那是错的: + +``` +error: build.mcpp for 'opencv' exceeded its 600s time limit and was killed. + Raise it in that package's own manifest: + /opencv-4.10.0/mcpp.toml → [build] build_program_timeout = 1800 + Or for this invocation only: + MCPP_BUILD_PROGRAM_TIMEOUT=1800 (0 = no limit) + Output so far: + ... +``` + +根工程超时则只打第二行的本地路径。**路径必须是真实存在的那一个** —— +`m` 里已经有包根,不要拼一个「大概是这里」的路径。 + +### 4.6 不进指纹 —— 显式记下来 + +`build_program_timeout` **不进** compile-flags 串、**不进** 缓存键。 +它不改任何一条边。若进了,把超时从 600 调到 1800 会换一个 `target///`, +**重建全世界** —— 而这恰好发生在用户正因为构建太慢而调这个键的时候。 + +这条要写进代码注释,否则下一个人「为了一致性」把它加进去。 + +### 4.7 Windows:诚实地说它不生效 + +`platform/process.cppm:598` 的 `capture_exec_deadline`: + +```cpp +#if defined(__linux__) || defined(__APPLE__) + ... posix_spawn + SIGKILL ... +#else + return capture_exec(argv, extraEnv, cwd); // ← 无界,deadline 被丢弃 +#endif +``` + +⇒ **Windows 上 `build_program_timeout` 是个 no-op**,和 `MCPP_BUILD_PROGRAM_TIMEOUT` 一样。 + +处理方式: + +- **不加每次构建的告警**(设了键的包在 Windows 上每次构建都告警 = 噪声) +- `mcpp doctor` 报一次 +- `docs/05-mcpp-toml.md` 与 `docs/07-build-mcpp.md` 各说一次(zh 镜像同步) + +**要真正修**需要 Windows 侧改用 `CreateProcess` + `WaitForSingleObject(timeout)` + +`TerminateProcess`,替换现在的残留 shell launcher。这是独立工作量, +**单开 issue,不塞进本方案** —— 否则这两个键会被一个平台移植卡住。 + +--- + +## 5. 兼容性与降级 + +| 场景 | `module_extensions` | `build_program_timeout` | +|---|---|---| +| 新 mcpp + 没写这个键的老工程 | 零差分 | 零差分(仍 600s) | +| **老 mcpp + 写了这个键的包** | ⚠️ **警告+忽略 ⇒ 构建出错的东西**(`.ixx` 被当普通 TU) | ✅ 警告+忽略 ⇒ 退回 600s,失败文案清晰 | +| `--strict` | 老 mcpp 上是硬错误 ✅ | 同 | + +`[build]` 的未知键是**警告**不是错误(`toml.cppm:1029-1058`,`kKnownBuildKeys` + +`m.schemaWarnings`),所以老 mcpp 不会拒绝加载整份 manifest —— +这躲开了「一个不认识的键让整个包永远无法被采用」那个坑。 + +但 `module_extensions` 的降级是**静默错误**,不是干净失败:老 mcpp 忽略这个键之后, +`.ixx` 仍会被 `sources` glob 到(如果作者写了),然后被当成普通 TU 编译 —— +产出一个不含模块的、链接期才炸的东西。而 mcpp.toml **没有** `min_mcpp` 之类的版本下限键 +(核实过:`grep -rn 'min_mcpp\|minMcpp' src/manifest/` 为空)。 + +⇒ **发布约束(必须写进 `docs/10-publishing-a-library.md`)**: +用了 `module_extensions` 的包,索引描述符必须声明 mcpp 版本下限。 +索引侧的下限机制必须**可降级** —— 「因版本太低而不可用」要如实拼成「不可用」, +不能拼成「不存在」,否则客户端会去做无穷的重复刷新。 +**这一条是 PR-2 发布前的阻塞项**,见 §9.3。 + +**两个键都要加进 `kKnownBuildKeys`**(`toml.cppm:1040`), +以及同一处的「Supported keys:」文案 —— 那句话是硬编码的,漏了它会现场打脸。 + +--- + +## 6. 测试计划 + +⚠️ 这一节里带 ⚠️ 的,都是**会让缺陷通过测试**的写法。 +`build.ninja` 相关的回归测试有过三次「测试自己把缺陷盖住」的记录。 + +| # | 层 | 断言 | 陷阱 | +|---|---|---|---| +| T-1 | 单测 | `classify()` 对内置表的全部扩展名 × 配置追加后的全部组合 | — | +| T-2 | 单测 | `object_filename_for` 穷举:任意两个不同源文件不共享对象名 | `foo.c`/`foo.cpp` **标记为已知失败**(§3.5),不要为了绿而放宽断言 | +| T-3 | 单测 | `run_timeout` 优先级穷举(env × manifest × 未设 × 0) | env 读取必须已上移,否则测试要改进程环境 | +| T-4 | e2e `217` | 四种扩展名 build → link → **run**,GCC / Clang 双 dialect | ⚠️ **必须 `# requires:` 到 Clang** —— 只跑 GCC 会同时放过 §1.1 的 BMI 缺陷**和** §3.8 的 `.ixx` 驱动不识别 | +| T-5 | e2e `217` | **未配置**时 `.ixx` 不被编译(零差分) | 反向断言,防止有人「顺手」把它加进内置表 | +| T-6 | e2e `218` | 改 `.ixx` 里的 `import` ⇒ 快路径作废 ⇒ 全量 prepare | ⚠️ **不能用 `touch`** —— 要真正加一句 `import`;⚠️ **不能先删产物** —— 那会让 ninja 失败被读成「图过期」,回退全量 prepare,**缺陷被盖住** | +| T-7 | e2e `218` | 改 `module_extensions` ⇒ 指纹变 ⇒ 落到不同 `target///` | 比对目录名,不是比对「构建成功」 | +| T-8 | 单测 | `moduleInterfaceFlag`:gcc=`-x c++`、clang=`-x c++-module`、msvc=`/interface /TP` | ⚠️ 两者**不可互换**(实测:`-x c++-module` 在 GCC 上 `language not recognized`;`-x c++` 在 Clang 上产出 174 字节空壳 pcm)。这条断言就是防「gnu 方言共用一个常量」的简化 | +| T-10 | e2e `217` | **幂等性回归**:`.cppm` 加了旗标之后仍能正常 build → link → run | 这是 §3.8.1 那个「幂等」实测结论的守卫。它一旦不成立,整个「永远显式」的设计就塌了 | +| T-9 | e2e `186` 扩写 | manifest 键生效;env 覆盖 manifest;报错文案含 manifest 路径 | 已有 `# requires: gcc`;Windows 上这条测不了,如实 skip | + +**T-6 的两个陷阱是记录在案的真实事故**:先删产物会让 ninja 失败被读成图过期; +先 `touch` 源码会让快路径按 mtime 直接失效、**根本没走到被测代码**。 +两种写法都会得到一个绿色的、什么都没测的测试。 + +**验证方法**(而不是「看起来对」):归一化 diff 实施前后的 `build.ninja`, +在未配置 `module_extensions` 的工程上,**diff 里只允许出现 `rule cxx_module` 的定义行** +—— 任何一条 `build ...` 边的变化都是 A-1′ 失守。 + +⚠️ **还有一条方法论**(§3.8.1 的下注):任何「加了旗标之后产物变了」的判断, +必须先跑「不加旗标连跑两遍」的**对照**。GCC 的 gcm 不可复现, +没有对照的 `cmp` 会给出一个看起来很硬、实际是噪声的结论 —— 我在这份文档里踩过一次。 + +--- + +## 7. 实施顺序 + +分两个 PR,因为它们的风险面完全不同。 + +### PR-1 · `build_program_timeout`(小、独立、可先合) + +1. `manifest/types.cppm`:`BuildConfig::buildProgramTimeoutSecs`(`optional`) +2. `manifest/toml.cppm`:解析 + 校验 + `kKnownBuildKeys` + 「Supported keys:」文案 +3. `build/directives.cppm`:`run_timeout(optional, optional)` + `env_timeout_override()` +4. `build/build_program.cppm`:传值 + 重写诊断(含 manifest 路径) +5. 显式注释:**不进指纹**,附理由 +6. T-3 / T-9;`docs/05-mcpp-toml.md`、`docs/07-build-mcpp.md` + zh 镜像 +7. 单开 issue:Windows `capture_exec_deadline` 无界 + +### PR-2 · `SourceKind` 收敛 + `module_extensions` + +按「先收敛、后开放」两步走,**中间必须停下来验一次零差分**: + +1. 新建 `src/source_kind.cppm`(内置表 = 今天的行为,**不加新扩展名**) +2. `SourceUnit::kind` / `CompileUnit::kind`;scanner + p1689 写入 +3. 20 处判定点逐个改读字段或读 `classify` +4. `object_filename_for` 改成总函数(`.cpp`/`.cppm` 原样) +5. **停:归一化 diff `build.ninja`,此处必须是【真·空 diff】** ← 这一步之前不写配置解析, + 也**还没加**第 9 步的旗标,所以 A-1′ 的「rule 行例外」在这里不适用 —— 一个字节都不许变 +6. `[build] module_extensions` 解析 + `kKnownBuildKeys` + 文案 +7. 默认 glob / 自动 target 推断 联动(#16 #17 #18) +8. 指纹:root + per-package 两处 +9. `toolchain/` 加**一个**按编译器家族取值的 `moduleInterfaceFlag` + (gcc=`-x c++`、clang=`-x c++-module`、msvc=`/interface /TP` 保持现状); + `cxx_module` 规则**无条件**带上它(§3.8.1) + —— **没有后缀表、没有 policy 枚举**;⚠️ 每个方言 leg 跑一次 T-10 幂等回归 +10. T-1/T-2/T-4/T-5/T-6/T-7/T-8/T-10;文档 + zh 镜像 + `docs/10-publishing-a-library.md` 的发布约束 + +第 5 步是这个 PR 唯一的安全带。跳过它,后面任何一处 diff 都无法归因。 + +--- + +## 8. 明确不做 + +| 不做 | 理由 | +|---|---| +| 把 `.ccm/.cxxm/.ixx` 加进内置默认 | §1.3,对已发布包的破坏性变更 | +| 超时时交互询问用户 | §4.1,没有交互通道 + 构建不该依赖击键 | +| `[build.extensions]` 四轴角色表 | C/asm 三轴的清单已完整且稳定;结构留位(§3.2),等真实用例 | +| 维护「哪个编译器认哪个后缀」表 | 会过期、错了还**不报错**(退出码 0 的静默空转);改为永远显式发一个已实测幂等的旗标(§3.8.1) | +| 用「任意后缀全放行」当借口不做校验 | 唯一的守卫仍要留:**拒绝**与内置非模块角色冲突的后缀(§3.4) | +| 时长串 `"30m"` | 与三个既有秒制旗标不一致 | +| `module_extensions` 沿依赖边传播 | 它是私有构建属性,不是 usage requirement | +| 删 `SourceUnit::isModuleInterface` / `isImplementation` | 改动面翻倍,收益 0;改成 `kind` 的派生字段即可 | +| 修 `foo.c` / `foo.cpp` 对象名碰撞 | 要动缓存条目布局 ⇒ 单独 issue + 缓存键版本 | +| Windows 的 deadline 实现 | 独立工作量,不该卡住这两个键 | + +--- + +## 7.1 实施记录:设计与现实的四处偏差 + +实施 PR-2 步 1–5 时发现的、设计阶段没看到的事实。记在这里,因为其中三条是**潜在缺陷**, +不是风格问题。 + +### ① `isModuleInterface` / `isImplementation` 是**写而不读**的死字段 + +设计文档写「保留它们(约 20 处读者)」—— **错的**。 +`grep -rn 'isImplementation\|isModuleInterface' src/ tests/` 的结果是:**5 处写,0 处读**。 + +而这 5 处写里有 **3 份互不一致的推导**(scanner 说 `.cpp`,p1689 说 `.cpp || .cxx`, +scan_overrides 说「有 provides」)。 + +⇒ **删掉,而不是收敛。** 收敛三份对一个没人读的值的推导,仍然是三份推导。 +`provides` 回答「是不是接口」,`kind` 回答其余。 + +### ② `is_implementation_source` 漏了 `.mm` + +旧清单 `{.cpp .cc .cxx .c .m .S .s .asm}` **没有 `.mm`**。 +所以一个 Objective-C++ 单元会被编译、然后**永远不进链接**。 +改成 kind-based(`Cxx` 含 `.mm`)顺带修掉。 + +### ③ stage 兜底 glob 漏了全部三种汇编扩展名 + +`prepare.cppm` 的 `{cppm,cpp,cc,c}` 比约定默认少了 `.S/.s/.asm`。 +⇒ stage 一个含汇编的依赖时**静默丢掉那些源文件**。改成 `default_source_globs()` 后消失。 + +### ④ A-1′ 的验证方式:要归一化两条环境量 + +fixture(2 个 `.cppm` + 2 个 `.cpp` + 1 个 `.c` + 1 个 `.S`,覆盖全部角色) +在实施前后 `build.ninja` 的差异**只有两行**,且都与语义无关: + +``` +mcpp = <这次跑的 mcpp 二进制自己的路径> +ldflags = ... -specs=/mcpp-clean-link.specs # build dir 名含指纹 +``` + +归一化掉这两条之后是**真·空 diff**,且**指纹目录名逐字符相同**(`7ca4d5d84ff1fd98`), +11 个指纹字段逐个相同 —— 包括 `[7] compile flags hash`。 + +> ⚠️ **验证过程本身踩了一个记录在案的坑**:`find target -name mcpp | head -1` 取到的是 +> **上一个会话留下的旧二进制**(指纹目录随版本变,`head -1` 不是「最新」)。 +> 它报 `[8] 2026.8.10.2`,让我一度以为自己改动了指纹。 +> **取二进制必须按 mtime 排序,并核对 `--version`。** + +--- + +## 7.2 ⚠️ GCC 16.1.0 地雷:新模块的**导出接口**里出现 `std` 类型会毒化下游 BMI + +实施 Windows deadline(P6)时撞到的,**与本方案的两个功能都无关,但会咬所有人**。 + +### 现象 + +给 `mcpp.platform.process` 加一个 `import`,指向一个**新建的**模块,之后: + +``` +failed: obj/runtime_selection.m.o gcm.cache/mcpp.xlings.runtime_selection.gcm +mcpp.manifest.xpkg: error: failed to read compiled module cluster 192: Bad file data +mcpp.manifest.toml: error: failed to read compiled module cluster 392: Bad file data +... fatal error: failed to load pendings for 'std::allocator' +``` + +**下游几十个 BMI 全部报废**,报错指向的模块(`mcpp.manifest.xpkg`)和改动毫无关系。 + +### 逐步二分(每一步都是干净重建) + +| 变体 | 结果 | +|---|---| +| 模块存在,但没人 import 它 | ✅ 通过 | +| 被 import,只导出 `int probe()`,**purview 里没有 `import std;`** | ✅ 通过 | +| 被 import,只导出 `int probe()`,**purview 里有 `import std;`** | ✅ 通过 | +| 被 import,**导出接口里出现 `std::string` / `std::vector` / `std::chrono`** | ❌ 下游 BMI 全坏 | +| `import` 换成一个**已存在**的模块(`mcpp.platform.fs`) | ✅ 通过 | + +⇒ 触发条件是 **「新模块 × 被 import × 导出接口里有 std 类型」** 三者同时成立。 +模块内部随便用 `std`,没问题。 + +### 排除项(都实测过,都不是) + +- **不是竞态**:`ninja -j1` 串行同样失败,且三次运行完全一致。 +- **不是陈旧产物**:每次都整目录重建。 +- **不是命名冲突**:换模块名、换目录都一样。 +- **不是那条 `-Wglobal-module` 警告**:把 `extern "C" char **environ;` 从 + global module fragment 移进 purview(这本身是对的,消掉了一条真实警告),现象不变。 + +### 采取的做法 + +平台 shim 的**导出接口做成 std-free**:输出走回调 `void(*)(void*, const char*, unsigned long)`, +环境变量走 `const char* const*` 的 `"K=V"` 数组,返回值只有 `bool`/`int`。 +`std` 只在模块**内部**使用。编组代码放在调用方(`mcpp.platform.process`), +那边本来就持有这些 vector。 + +这不是权宜之计:一个平台 shim 用 C 风格边界本来就是标准做法,而且它把 +「这个模块不得把 std 类型放进接口」变成了一条**写在文件头、有实测支撑**的约束, +而不是一句口头约定。 + +⚠️ **给后来者**:在 `src/platform/` 下新建模块并让 `mcpp.platform.process` +(或任何被广泛 import 的底层模块)import 它时,**先只导出内置类型**。 +BMI 坏掉时报错会指向一个和你的改动毫无关系的模块,极难归因 —— 我花了七轮二分。 + +--- + +## 7.3 实施最终形态(与设计的差异) + +| 设计写的 | 实际做的 | 为什么 | +|---|---|---| +| 保留 `isModuleInterface`/`isImplementation` | **删掉** | 它们是写而不读的死字段(5 写 0 读),3 份互不一致的推导 | +| 方言侧「按需补 `-x`」 | **无条件补** | 旗标幂等已实测;查表会过期且错了静默 | +| Windows deadline 单开 issue | **本 PR 实现** | 用户要求跨平台;平台代码落到 `src/platform/windows/` | +| —— | **新增 `src/platform/{unix,windows,linux,macos}/`** | 用户要求;`process.cppm` 的 25 处 `#if` 收敛成一处 `if constexpr` 分派 | +| —— | **新增可观察性** | `mcpp self doctor` 报告生效的扩展名表、超时值及其来源、deadline 是否真的强制 | + +### 可观察性(实测输出) + +``` + Checking build policy + ok module interfaces: .cppm .ixx .ccm .cxxm (3 from [build] module_extensions) + ok build.mcpp run bound: 600s (from built-in default) + ok process deadlines: enforced (POSIX SIGKILL / Windows job object) +``` + +外加:`module_extensions` 里零命中的条目会告警(死配置 ≠ 打字错误,不报就分不清)。 + +### 实测通过的判据 + +| 判据 | 结果 | +|---|---| +| A-1′ 图形态零差分 | ✅ 未配置时 `build.ninja` 的 diff 只有 2 条 rule 命令 + 2 个 `unit_lang`,**零 `build` 边变化**,指纹目录名逐字符相同 | +| 四种扩展名 build→link→run | ✅ **GCC 与 Clang 两条腿都过**(e2e 217) | +| 对象名无碰撞 | ✅ `a.m.o` / `b.ccm.o` / `c.cxxm.o` / `d.ixx.o` | +| 快路径扫描 `.ixx` | ✅ 加 `import` 后强制全量 prepare(e2e 218) | +| 指纹包含 `module_extensions` | ✅ `65e7cc0a` → `497632df` | +| manifest 超时键 | ✅ 2s 生效、env=1 覆盖它、负数硬错误(e2e 186) | +| **CI 全绿** | ✅ **19/19,0 失败**(linux/windows/macOS 的 unit+e2e、cross、mingw、hermetic、bare-Windows、xlings 集成) | +| 本机全量 e2e | 198 过 / 12 败;**12 条全部用已发布 2026.8.10.3 跑同样红** ⇒ 环境性(本机共享 gcc payload specs 被历次安装污染),唯一真问题是 218 自身的脆弱断言,已修 | +| 超时报错指名 manifest | ✅ 打印该包 `mcpp.toml` 的绝对路径 | + +--- + +## 7.4 深度自审(CI 后)发现并修掉的两条 + +### ① 未捕获的有界运行丢了流式输出 —— 真回归 + +`run_exec_deadline` 是 `mcpp test` **非 JSON 模式**跑测试二进制的路径,原本 +**继承调用方 stdio**。我第一版把它改成「捕获后在结束时回放」,后果两条: + +1. 长测试的输出全部憋到退出才出现 —— 恰好抵消 `mcpp test` 可观察性那一整轮工作 + (「只有子进程输出、mcpp 一行没有」正是缓冲问题的指纹); +2. 子进程 stdout 变成管道而非终端 ⇒ gtest 之类**静默关掉彩色输出**。 + +修法:两侧启动器共用一条契约 —— **`sink == nullptr` 表示不捕获**,子进程直接继承 +调用方 stdio,但仍然有界。POSIX 不建管道不设 dup2;Windows 不设 +`STARTF_USESTDHANDLES`,也不加 `CREATE_NO_WINDOW`(未捕获的运行本来就是给人看的)。 + +### ② 依赖缓存键的 E 轴漏了 `module_extensions` + +指纹管的是 `target///`,**全局依赖缓存是另一套键**。一个声明了 +`module_extensions` 的依赖产出不同的 `.o`/BMI,但它的缓存键此前不含这个字段。 + +**今天不可达**:默认 glob 会跟着变 ⇒ `sourceGlobs` 已经动了;而索引包描述符按版本 +冻结,`package.version` 在 D 轴。⇒ 但这正是本方案要消灭的形状(同一决策漏一处), +一行补上比留着等它以后变成一次「错误的缓存命中」便宜。 + +### 顺带确认:`-x c++` 不需要 bump 缓存 epoch + +老 mcpp 写入的缓存条目(命令行里没有 `-x c++`)会被新 mcpp 读到。因为该旗标在 +已识别后缀上**幂等**(§3.8.1 实测),产物逐字节相同 ⇒ 沿用是安全的,不必让全网 +缓存作废。 + +### 一处**刻意的**行为变化 + +`is_compilable_output`(build.mcpp 生成物能否编译)原本是**唯一含 `.ixx` 的清单**。 +改为按 kind 判定后,`.ixx` 生成物在未声明 `module_extensions` 时不再被自动纳入 +`sources`。这是**修正而非回归**:旧路径接受它进 sources,而后面每一个阶段都会 +错误处理它 —— 没有任何包能靠那条路径正常工作。 + +--- + +## 7.5 我自己写的测试,又踩了一次同一个坑 + +`218` 的第 2 部分用 `find target -name build.ninja | head -1` 推指纹目录。 +**单跑绿,进全量套件红。** + +原因:第 2 部分改了 `module_extensions` 之后 `target/` 下有**两个**输出目录, +`head -1` 取到的是 `find` 恰好先走到的那个。 + +这与记忆里那条「指纹目录随版本变,`ls | head -1` 会自查到旧二进制」是**同一形状** +—— 而且我在这次实施里已经踩过一次(取错 mcpp 二进制,误以为自己改了指纹), +然后**在一个专门用来抓这类问题的测试里又踩了第二次**。 + +修法:不去推目录,直接问 `mcpp build --print-fingerprint` 要那个值,断言值本身。 + +> **判据**:一个断言如果依赖「目录里恰好只有一个东西」,它就不是断言,是运气。 +> 能问到值就别去列目录。 + +--- + +## 8.1 本次不做,但已排期 + +| 项 | 状态 | 说明 | +|---|---|---| +| **基于 xlings 生态的图形开发可用性验收** | **推迟**(2026-08-11 决定) | 等 xlings 最新版本发布后单独做。本 PR 的验收范围到「xlings 生态装→建→跑」为止,不含 GL context 获取那一段。图形栈本身的三层故障见 `2026-08-10-graphics-stack-usability-design.md` | + +--- + +## 9. 开放问题 + +1. **`[lib]` 约定(#19)** 是否要跟着 `module_extensions` 走? + 倾向**不**:它是**写**路径(`mcpp new` 生成 `src/.cppm`), + 把生成物的拼法交给配置只会让脚手架产出不可预测的文件名。 +2. **`module_extensions` 要不要在 `mcpp doctor` 里回显?** + 倾向要 —— 一个「为什么我的 `.ixx` 没被编译」的问题, + 目前唯一的自查手段是读 `inferredNotes`。 +3. **索引侧的 mcpp 版本下限**(§5)当前是什么机制、是否可降级 —— + 这是 PR-2 发布前的**阻塞项**,不是实施项。 diff --git a/.agents/docs/2026-08-12-bench-suite-architecture-and-plan.md b/.agents/docs/2026-08-12-bench-suite-architecture-and-plan.md new file mode 100644 index 00000000..4b73dc48 --- /dev/null +++ b/.agents/docs/2026-08-12-bench-suite-architecture-and-plan.md @@ -0,0 +1,212 @@ +# `bench/` 构建引擎基准套件 —— 架构与实施计划 + +> 2026-08-12 +> 前置分析:[2026-08-12-modular-build-performance-deep-analysis.md](./2026-08-12-modular-build-performance-deep-analysis.md) +> 目标:把一次性的对比脚本,变成一套**可复用、跨平台、可扩展**的构建引擎基准。 + +--- + +## 0. 为什么要重做一遍 + +上一轮分析用的一次性脚本(bash + hyperfine)能回答"mcpp 和 xmake 谁快",但它有四个结构性缺陷,直接决定了它不能长期用下去: + +| 缺陷 | 后果 | +|---|---| +| 只支持 2 个引擎,加第 3 个要改 `run.sh` 主体 | 每加一个对比对象都动核心逻辑 | +| bash + hyperfine | **Windows 上跑不了**;而 mcpp 是三平台产品 | +| 被测对象只有 mcpp 自己 | 无法回答"模块化 vs 头文件"这个真正的问题 | +| 结果是 TSV,字段随手加 | 跨机器/跨时间的数据无法可靠合并 | + +新套件按四个角度设计:**优雅(加引擎=加一个文件)、架构稳定(协议与实现解耦)、兼容(旧数据可读)、跨平台(不依赖 shell)**。 + +--- + +## 1. 顶层结构 + +``` +bench/ ← 顶层目录,与 src/ tests/ docs/ 平级 + README.md 基准规范(可复用的那份文档) + mcpp.toml 基准工具本身就是一个 mcpp 工程 + src/ + main.cpp + protocol.cppm ★ 协议:结果 schema / 版本 / 序列化 + spec.cppm 矩阵与场景定义(数据,不是代码) + runner.cppm 计时循环:预热、重复、中位数 + registry.cppm 引擎注册表 + engines/ + engine.cppm 适配器契约 + mcpp.cppm cmake.cppm xmake.cppm meson.cppm bazel.cppm + fixture/ + generate.cppm 同一工程 → 头文件版 / 模块版 + emit_buildfiles.cppm 为每个引擎生成构建描述 + analysis/ + ninjalog.cppm graph.cppm report.cppm 构建剖析(--analyze) + platform.cppm 门面(主模块,export import 各分区) + platform/ + posix.cppm 分区:整文件宏控,非 POSIX 上不导出任何符号 + windows.cppm 分区:同上 + results/ 结果 + NOTES.md +``` + +**为什么基准工具本身用 mcpp 写**:它要在 Linux/macOS/Windows 上跑同一套逻辑。bash 在 Windows 上不可用,hyperfine 需要额外安装,而 mcpp 是本仓库必然存在的东西。**用 mcpp 构建 mcpp 的基准工具,顺带也是一次 dogfooding。** + +--- + +## 2. 协议模块(`bench.protocol`)—— 架构稳定性的锚点 + +这是整套设计里唯一"必须先定、之后不能随便改"的东西。 + +```cpp +export module bench.protocol; + +// 结果 schema 的版本。字段增删必须动它,读取侧据此决定兼容策略。 +export inline constexpr int kProtocolVersion = 1; + +export struct HostInfo { // 结果只有配上宿主才有意义 + std::string os, arch, cpu_model; + int logical_cores{}, physical_cores{}; + bool heterogeneous{}; // 13900K 的 8P+16E 不能当 24 个同构核读 + std::uint64_t ram_bytes{}; +}; + +export struct CellKey { // 一个测量单元的完整坐标 + std::string engine, compiler, profile, scenario, fixture, variant; +}; + +export struct Sample { double wall_s{}; int exit_code{}; }; + +export struct CellResult { + CellKey key; + std::vector samples; + double median_s{}, min_s{}, max_s{}; + std::string status; // ok | failed | skipped | unavailable + std::string note; // 失败或跳过的原因,必填 +}; +``` + +**三条不变量**,写死在协议里: + +1. **失败不得伪装成数据。** `status` 与 `median_s` 是两个字段;上一轮 `run.sh` 把失败写成 `0.000s`,就是因为没有这一层。 +2. **跳过必须带原因。** "bazel 不在这台机器上"和"bazel 跑失败了"是完全不同的结论。 +3. **宿主信息与结果同生共死。** 单独一个数字没有意义。 + +序列化为 JSON,字段名即上面的名字,顶层带 `protocol_version`。 + +--- + +## 3. 引擎适配器契约 + +```cpp +export struct Engine { + virtual ~Engine() = default; + virtual std::string_view name() const = 0; + // 这台机器上有没有?没有就 unavailable,不是 failed。 + virtual Availability probe() const = 0; + // 是否支持这个 fixture 变体(headers / modules) + virtual bool supports(Variant) const = 0; + virtual Result configure(const Job&) const = 0; + virtual Result build(const Job&) const = 0; + virtual Result clean(const Job&) const = 0; +}; +``` + +**加一个引擎 = 新增一个 `engines/.cppm` + 在 `registry.cppm` 注册一行。** 不动 runner、不动协议、不动 CI。 + +`supports(Variant, compiler)` 是必要的,而且**编译器是这个问题的一部分** —— 实测:bazel 9.2 + rules_cc 0.2.22 +配 clang 能构建 C++20 模块,配 gcc 则死在它自己的扫描器里(`aggregate-ddi: Invalid JSON string`, +它解析不了 GCC 的 P1689 输出);meson 1.10.2 两个编译器都不行(`module 'fx.a' not found`)。 +所以"bazel 支不支持模块"没有脱离具体运行的答案。不支持时报 `unavailable` **并附上得出该结论的那次测量**, +而不是硬跑出一个误导性的数字。 + +--- + +## 4. Fixture:同一工程的两种形态 + +**生成而非手写。** 手写两份"等价"的代码,几乎必然在某处不等价,而那正是被测量的东西。 + +生成器参数:单元数 `N`、依赖深度 `D`、每单元代码量 `L`。产出: + +``` +fixtures/synth-x/ + headers/ include/unit_k.hpp + src/unit_k.cpp (传统头文件 + 分离实现) + modules/ src/unit_k.cppm (模块接口单元) + modules-impl/ src/unit_k.cppm + src/unit_k_impl.cpp (接口 + 实现单元 ★) +``` + +第三种变体直接对应上一轮分析的 **F4 / §6.3**:把实现移出接口单元。有了它,"改一行函数体"的代价差异就是**测出来的**,不是推断的。 + +同时提供 **`--project ` 模式**:直接就地测量一个已存在的工程(mcpp 自身即基础用例),因为真实工程的依赖形状不是合成器能编出来的。该模式下 variant 轴坍缩为 `native`,并且 `edit-body` 会在测量前后**逐字节保存并恢复**被改的源文件 —— 包括构建失败的路径,那正是遗留改动最容易被忽略的时候。 + +--- + +## 5. 场景矩阵 + +| 维度 | 取值 | +|---|---| +| engine | `mcpp=`(可给多个,自动按版本标注)、cmake、xmake、meson、bazel | +| variant | headers, modules, modules-impl | +| profile | release, debug | +| scenario | cold, noop, touch-hub, edit-body, touch-leaf | +| compiler | gcc, clang, msvc(平台可用者) | + +**"优化前后"用两个真实二进制表达,不用模拟。** `--engines mcpp=<旧>,mcpp=<新>` 会注册两个引擎,各自向自己的二进制询问版本并据此标注(`mcpp@2026.8.11.3` / `mcpp@2026.8.12.1`)。 + +早期设计里有一个 `mcpp-opt` 引擎,靠在构建前后设 `SOURCE_DATE_EPOCH` 来**模拟**优化。已删除:**在 harness 里模拟一个改动,测的是 harness 对该改动的理解**,而且一旦真实实现与之分叉,它会静默地不再跟踪。优化属于 mcpp,基准测的是二进制。 + +矩阵是笛卡尔积但**不是全跑**:`spec.cppm` 用显式的 include/exclude 规则裁剪,CI 默认跑一个小集合,`workflow_dispatch` 可放开。 + +--- + +## 6. 平台拆分 + +采用 **xlings `src/platform/*.cppm` 的既定约定**:模块分区 + 整文件宏控。 + +| 关注点 | 位置 | +|---|---| +| 进程启动 + 墙钟计时 + 退出码 | `platform/posix.cppm`、`platform/windows.cppm` | +| CPU 型号 / 核数 / 异构判定 | 同上 | +| 环境变量读写 | 同上(`setenv` vs `SetEnvironmentVariableA`) | +| 组装与可移植部分(std::filesystem) | 主模块 `platform.cppm` | + +每个分区把**整个 body** 包在一个宏里,非目标平台**不导出任何符号**;两侧导出同名函数,于是任一构建中每个名字只有一份定义,**编译期自动选中**——不需要 stub,也不需要 `if constexpr` 派发。主模块 `export import :posix; :windows;` 后用 `export using` 提升。 + +结果:`#if defined(_WIN32)` 只出现在这两个分区里,runner / engines / protocol / fixture 全部零平台条件。 + +--- + +## 7. CI + +新增 `.github/workflows/bench.yml`: + +- `on: workflow_dispatch`(**只手动触发** —— 基准是重活,不该挂在每个 PR 上) +- 输入:`engines`、`scenarios`、`variants`、`fixture_size`、`runs` +- 矩阵:`ubuntu-24.04` × `macos-14` × `windows-2022`,各自的默认工具链 +- 产出:上传 `results/*.json` 为 artifact +- **不设阈值断言**:基准用于观察趋势,不用于 gate。把噪声变成红叉只会让人忽略它。 + +--- + +## 8. 实施阶段 + +| 阶段 | 内容 | 完成判据 | +|---|---|---| +| **A** | `bench/` 骨架:protocol + platform + runner + registry + mcpp 引擎 | 三平台能跑 `bench --engine mcpp --scenario cold --fixture self` 并产出合法 JSON | +| **B** | fixture 生成器(headers / modules / modules-impl) | 三个变体编译产物行为一致(同一断言集通过) | +| **C** | cmake / xmake / meson / bazel 适配器 | 缺失工具报 `unavailable` 且带原因,不是崩溃 | +| **D** | 构建剖析并入 `--analyze` + 结果合并 | 关键路径与 Python 实现交叉验证一致 | +| **E** | `bench.yml` CI | 手动触发在三平台跑通并上传 artifact | +| **F** | 文档 / 测试 / 版本 / PR / 验证 / 合入 / 发布 | 见目标清单 | + +**顺序是有依赖的**:A 定协议,之后所有阶段都写向它;B 之前 C 无处可跑;D 依赖 A 的结果格式。 + +--- + +## 9. 明确不做 + +- **不把基准挂进 PR CI**。噪声会淹没信号。 +- **不设性能回归阈值**。宿主差异(异构 CPU、云厂商邻居噪声)远大于多数真实回归。 +- **不重新实现计时统计学**。中位数 + min/max 足够;不做置信区间,因为样本量本来就小。 +- **不追求引擎功能对等**。引擎跑不了某个变体就报 unavailable —— 强行凑一个数字比没有数字更糟。 + 但"跑不了"必须是**测出来的**,不是假设的:最初这里写死了 `bazel supports(modules) = false`, + 而实际上加上 `module_interfaces` + `--experimental_cpp_modules --features=cpp_modules` 之后, + bazel 配 clang 是能构建并运行模块程序的。写死的能力判断会把一整列真实数据变成空白。 diff --git a/.agents/docs/2026-08-12-cold-build-optimization-plan.md b/.agents/docs/2026-08-12-cold-build-optimization-plan.md new file mode 100644 index 00000000..db6dd8ca --- /dev/null +++ b/.agents/docs/2026-08-12-cold-build-optimization-plan.md @@ -0,0 +1,334 @@ +# mcpp 冷构建深度优化方案 + +> 2026-08-12 +> 前置:[模块化构建性能深度分析](./2026-08-12-modular-build-performance-deep-analysis.md) · [bench 套件](./2026-08-12-bench-suite-architecture-and-plan.md) +> 范围:**冷构建**(`mcpp clean && mcpp build`)。增量侧的级联抑制已在 2026.8.12.1 落地。 + +--- + +## 0. 现状 + +`bench --project . --engines mcpp=<旧>,mcpp=<新> --scenarios cold`,mcpp 构建 mcpp 自身: + +| 版本 | 冷构建中位数 | +|---|---| +| 2026.8.11.3 | 78.70s | +| 2026.8.12.1(含 `bmi-equal`) | 78.57s | + +**持平,而且必然持平。** `bmi-equal` 修的是「重编后 BMI 未变则不级联」;冷构建里没有「上一份 BMI」,这条路径压根不适用。冷构建要快,必须解决另一组约束。 + +--- + +## 1. 三个互相独立的约束 + +### C1 —— 关键路径 = 100% 墙钟 + +``` +edges : 423 +makespan : 76.54 s +work (sum dur) : 303.36 s +avg parallelism: 3.96 x (of 32 hw threads) +critical path : 76.48 s = 100% of makespan +``` + +后 55% 的时间里,32 个硬件线程上只有 **1 个**编译进程在跑。**这不是调度器不够聪明,是图本身就是一条链。** + +### C2 —— 关键路径上 77% 的时间在生产无人等待的 `.o` + +BMI 在编译进度 **22.8%** 处就被**原子 `rename`** 就位(`strace` 证实:此后 982 个系统调用没有一个再碰它),而 ninja 的依赖模型只认「边结束」。于是每个导入者都要多等一段纯 codegen。 + +### C3 —— mcpp 从不传 `-j`,ninja 用默认的 `nproc + 2` + +这在**本机**是 34,在 62 GB 内存上没问题。但实测单个模块编译的峰值常驻内存: + +| 模块 | 峰值 RSS | +|---|---| +| `build/prepare.cppm`(最重) | **1,057 MB** | +| `build/plan.cppm`(中位偏上) | **561 MB** | + +⇒ 一台 64 核 / 32 GB 的机器会跑 66 路并发 × ~0.5–1 GB = **换页甚至 OOM**。`nproc + 2` 在核多内存少的机器上是**主动有害**的默认值。 + +> C3 与 C1/C2 正交:在图仍是一条链时,调低 `-j` 不会更慢(反正用不满),调高也不会更快。所以 C3 首先是**安全属性**,只有在 C1/C2 解决之后才变成性能属性。 + +--- + +## 2. 优化 A —— BMI 落盘即释放下游(最大项) + +### 2.1 收益已实测,不是模拟 + +机械改写 `build.ninja`、拆边、同一编译器进程,`bench/proto-bmi-release/`: + +| 方案 | 墙钟 | 产物 | +|---|---|---| +| baseline(边完成即释放) | **77.42s** | 19,347,008 B | +| **split(BMI 落盘即释放)** | **36.56s** | 19,347,008 B,可运行 | + +**2.12×,零额外 CPU**(每个模块仍然只有一个 `g++ -c`)。 + +### 2.2 图的形状 + +```ninja +rule cxx_module_bmi # 编译器起跑,BMI 落盘即返回 + command = $mcpp compile-module --phase=spawn --slot $slot --bmi $out --obj $obj_out -- $cxx ... + restat = 1 +rule cxx_module_obj # 等同一个编译器收尾,传播退出码 + command = $mcpp compile-module --phase=wait --slot $slot --obj $out + restat = 1 + +build $bmi : cxx_module_bmi $src | $ddi_dd + dyndep = $ddi_dd +build $obj : cxx_module_obj $bmi +``` + +下游**不需要改动**:dyndep 本来就让导入者依赖 `gcm.cache/X.gcm`,它们只是提前约 4 倍就绪。link 依赖 `obj/*.m.o`,仍然等全部对象。 + +**唯一不显然的改动**:dyndep 把依赖挂在扫描阶段记录的 `-fdeps-target` 上,也就是 `obj/X.m.o`。若不动它,**导入依赖会去门禁那条只负责等待的边,而真正做编译的边在没有任何 BMI 的情况下起跑**。所以扫描边的 `-fdeps-target` 要改指向 BMI。 + +⚠️ 改这个要小心:`cxx_scan` 规则把 `$compile_target` **用了两次** —— 一次 `-fdeps-target=`,一次 `-o`。改共享变量会把预处理输出对准 BMI 路径、有截断风险。必须拆成两个变量,只改 `-fdeps-target`。(原型里就是这么做的。) + +### 2.3 两个会让方案「看起来没用」的实现陷阱 + +**陷阱 1:分离出去的编译器继承了构建系统的 stdout/stderr 管道。** +ninja 判定一条边结束的依据是**管道 EOF,而不是直接子进程退出**。第一阶段即使提前 `exit 0`,只要后台编译器还持有那个 fd,ninja 就认为边还在跑。原型第一次运行就栽在这里:BMI 边中位数 2018 ms(= 完整编译时长),看起来像「这个想法没用」,实际是**测量被伪装成了 baseline**。 +⇒ 后台进程的 stdout/stderr 必须重定向到文件,由第二阶段回放(否则编译器警告与错误静默消失)。 + +**陷阱 2(已被实测缩小):`-j` 与编译器上限的关系。** + +我最初把原型第一次的 78.99s 归因于两件事:管道继承 **和** 「`-j` 必须远大于上限」。后来把 `-j` 单独扫了一遍,结论是**第二条基本不成立**: + +| ninja `-j` | 编译器上限 | 墙钟 | +|---|---|---| +| 32 | 32 | **37.84s** ← 最快 | +| 64 | 32 | 38.23s | +| 128 | 32 | 38.39s | +| 192 | 32 | 39.06s | + +**`-j` 越大反而略慢**(多出来的休眠边只是调度开销)。也就是说那次 78.99s **几乎全部是陷阱 1**,我把一个原因写成了两个。 + +正确的规则很简单:**`-j` 取编译器上限即可**;它不需要更大,也不应该更大。 + +### 2.4 进程生命周期:分离,但**不脱离进程组** + +这是设计里最容易做错的一处。 + +需求只有一条:**别占住 ninja 的管道**。它**不**要求 `setsid()`。 + +保持在同一进程组的直接好处:ninja 收到 Ctrl-C 时会把 SIGINT 发给整个进程组,**分离出去的编译器照样收到**。若为了「干净」而 `setsid()`,反而要自己实现中断清理,并且会留下孤儿编译器继续吃满 CPU。 + +- **POSIX**:`fork` → 子进程把 stdio 重定向到日志 → `exec` 编译器;**不调用 `setsid`**。父进程(spawn 阶段)轮询 BMI 后退出,编译器被 init 收养但仍在原进程组。 +- **Windows**:`CreateProcess` **不带** `DETACHED_PROCESS`,句柄重定向到文件,继承控制台 ⇒ Ctrl-C 正常。后续可加 Job Object 做强保证。 + +### 2.5 失败语义 + +编译器可能在 BMI 落盘**之后**才失败(codegen 阶段的 ICE —— xlings 迁移前的 xmake.lua 就因 GCC 15 在 `-O1/-O2` + modules 上 `tree-ssa-dce` ICE 而强制 `-Og`)。此时: + +- 导入者已经拿着一份**合法的** BMI 开始编译 —— 前端成功过,BMI 有效 +- `--phase=wait` 拿到非零退出码,该边失败,构建整体失败 + +⇒ **失败仍然被报告,只是更晚**,且下游做的是无害的额外工作。诊断顺序可能颠倒(下游错误先于上游失败出现),这一点要写进发布说明。 + +### 2.6 并发上限用什么实现 + +跨平台、无外部依赖:**原子 `mkdir` 令牌目录**。`mkdir` 在 POSIX 与 Windows 上都是原子的「要么成功要么 EEXIST」。令牌在编译器**退出时**释放(由 spawn 阶段的后台段释放),不是在 `wait` 边被调度时 —— 否则上限会被 ninja 的调度延迟放大。 + +不会死锁:持有令牌的进程从不等待另一个令牌。 + +--- + +## 3. 优化 B —— 硬件感知的并发选择(可选项) + +### 3.1 为什么 `nproc + 2` 是错的 + +两个原因,都不是「保守一点更好」这种口味问题: + +1. **内存**:实测每编译 0.5–1 GB(§1 C3)。`nproc+2` 在 64 核/32 GB 上必然换页。 +2. **异构**:i9-13900K 是 8 P-core + 16 E-core。`nproc` 报 32,但 E-core 编译吞吐约为 P-core 的 40%,SMT 兄弟核约 25%。**把 32 当成 32 个同构核,会把有效并行度估高一倍以上。** + +### 3.2 `auto` 的取值 + +``` +jobs_auto = clamp( min( cpu_budget, mem_budget ), 1, 64 ) + +cpu_budget = 异构 ? physical_cores // E-core 不按整核计 + : logical_cores +mem_budget = max( 1, (available_ram - reserve) / per_job_estimate ) + reserve = 2 GiB + per_job_estimate = 768 MiB // 实测中位 561 MB / 峰值 1057 MB +``` + +- 用 **available**(不是 total)内存:构建通常不是机器上唯一的东西 +- `per_job_estimate` 是**可配置常量**,不是猜测:它来自本仓库的实测,并且在文档里注明了来源,便于其他工程按自己的规模调整 +- 上限 64:再高时 ninja 自身的调度开销与文件系统争用开始显现 + +### 3.3 配置面 + +```toml +[build] +jobs = "auto" # 或一个整数;缺省 = 当前行为(不传 -j,由 ninja 决定) +``` + +``` +mcpp build --jobs N|auto +``` + +**默认不变。** 这是刻意的:改变默认并发会改变所有人的构建时长与内存占用,属于行为变更,应当先作为可选项验证一段时间。文档里把 `auto` 标为推荐值。 + +### 3.4 与优化 A 的关系 + +A 落地后仍然只需要**一个**数。§2.3 的实测表明 `-j` 取编译器上限就是最优,更大反而略慢,所以: + +``` +compiler_cap = jobs_auto +ninja_jobs = compiler_cap // 不放大 +``` + +(我一度以为这里需要 `cap * 6`,那是把陷阱 1 的症状误记到了陷阱 2 上;扫描数据见 §2.3。) + +--- + +## 4. 优化 C —— 关键路径感知的调度顺序 + +ninja 在多条就绪边之间的选择顺序是任意的。当图很窄时无所谓(现状后半段并发度 1.0),但 **A 落地后图会变宽**,此时先跑关键路径上的边就有价值。 + +mcpp 已经在扫描阶段拿到了完整模块图,算一次最长路径几乎免费。ninja 没有优先级 API,但可以通过**边的声明顺序**施加弱影响。 + +收益不确定,成本极低,排在 A 之后作为微调。 + +--- + +## 5. 优化 D —— 对象级缓存 + +codegen 占全部工作量的 **77%**。`bmi-equal` 让 BMI 稳定之后,`.o` 也可按内容哈希缓存。mcpp 已有 `~/.mcpp/build-cache/v1/` 用于依赖包,扩展到根包即可。 + +**只对重复的冷构建有效**(切分支来回、revert、CI 缓存恢复),对首次冷构建无效。 + +⚠️ 历史教训:本仓库出现过「命中也 100% 重编」的假缓存,骗了三个月。验收判据必须是**命中时确实跳过了编译**,不是日志里出现 `Cached`。 + +--- + +## 6. 明确不做 + +| 方向 | 为什么不做 | +|---|---| +| 降低优化档位 | 实测 `-O0` 相对 `-O2` 只快 **1.75×**,而产物运行时性能全丢 | +| 缩小 BMI / 降扇入 | 实测 `import std`(31.5 MB BMI)只多 **4.8 ms** —— GCC 导入本来就是惰性的 | +| 优化 scan / dyndep 阶段 | 合计 **0.8%** 的工作量 | +| 分布式编译(distcc/icecc) | 关键路径 100% ⇒ **在 A 之前是负收益**(只增加网络延迟) | +| `-fmodule-only` 两阶段 | 实测它**照样跑完整个 codegen 再丢弃**(15.93s vs 完整 15.95s) | + +--- + +## 7. 实施顺序与验收判据 + +| # | 动作 | 预期 | 验收判据 | +|---|---|---|---| +| **A1** | `mcpp compile-module --phase=spawn/wait` + 拆边 + 扫描 `-fdeps-target` 重定向 | 78.6s → **~37s** | 产物字节一致;**BMI 边中位数远小于 OBJ 边**(否则第一阶段没有提前退出,测的是 baseline);构建后无残留编译器进程;Ctrl-C 不留孤儿 | +| **A2** | Windows 侧(Job Object) | 同上 | Windows e2e 绿 | +| **B** ✅ | `--jobs N\|auto` + `[build] jobs`(**2026.8.12.1 已实施**) | 安全属性;A 之后转为性能属性 | 本机 `auto` → `-j24`(异构 ⇒ 物理核 24,内存预算更大);默认行为不变;8 个单测覆盖公式的每条分支 | +| **C** | 关键路径优先的边序 | 微调 | A 之后重测,无提升则回退 | +| **D** | 对象缓存 | 重复冷构建 | **命中时确实跳过编译**,不是日志说了算 | + +--- + +## 8. 与其他构建系统的对照(同一台机器、同一编译器) + +见 `bench/results/`。要点:xmake 在**同样的图形状**下也是延迟瓶颈(它同样走 GCC 单阶段),所以 A 不是「追平 xmake」,而是**两者都还没做的事**。 + +--- + +# 附录:2026-08-13 复测 —— 优化 A 的依据被重新确立,并纠正一处错误推理 + +上文写 A(BMI 提前释放)时,依据是一次原型测量。这次在 **mcpp 自身 80s 冷构建**上 +重新逐条量过,结论是 **A 成立,而且比原来写的更硬**;但中间我先得出过一个**相反且错误** +的结论,过程值得记下来。 + +## A.1 现状:100% 延迟受限 + +`bench --analyze` 于 mcpp 的 release 构建目录: + +``` +edges : 426 +makespan : 79.79 s +work (sum dur) : 314.08 s +avg parallelism: 3.94 x (of 32 hw threads) +critical path : 79.73 s = 100% of makespan +``` + +**关键路径就是墙钟本身。** 32 个硬件线程上平均并行度只有 3.94 —— 加核、加机器、 +分布式编译全部无效。关键链 26 跳,几乎全是 `cxx_module`,其中 +`mcpp.build.prepare` 单个模块 **16.1s**,占整个构建的 20%。 + +## A.2 ⚠️ 错误推理:用 `-fmodule-only` 判定「codegen 占多少」 + +第一反应是量「只产 BMI」要多久: + +``` +prepare BMI-only 16.18s full 16.27s → 99% +cli BMI-only 5.50s full 5.59s → 98% +plan BMI-only 5.36s full 5.43s → 99% +``` + +据此我一度判定 **A 不成立**:BMI 几乎就是全部成本,代码生成只有 1%,提前释放没有空间。 + +**这是错的。** GCC 的 `-fmodule-only` 并不跳过后端,它跑完整条流水线、只是不写目标文件。 +用它测「BMI 什么时候好」等于什么都没测。 + +## A.3 正确判据:BMI 文件何时**写完**,以及下游能否用 + +三步,缺一不可: + +1. **何时出现** —— 轮询 `.gcm`:`prepare` 的 BMI 在 **2.31s / 16.19s = 14%** 处出现。 + 但「出现」不等于「写完」(GCC 早创建、可能持续写)。 +2. **何时写完** —— 轮询到大小连续 150ms 不变。快照 785488 字节,与编译结束后的成品 + **逐字节相同**。 +3. **是否可用** —— 把早期快照放回 `gcm.cache/`,编译一个真实下游导入者 + (`mcpp.build.execute`):**exit 0**。 + +采样关键链上最重的 8 个模块: + +| 模块 | full | BMI 写完 | 占比 | +|---|---|---|---| +| mcpp.build.prepare | 16.20s | 2.50s | **15%** | +| mcpp.cli | 5.67s | 2.22s | 39% | +| mcpp.build.plan | 5.47s | 1.07s | 20% | +| mcpp.build.compile_commands | 4.74s | 1.03s | 22% | +| mcpp.build.execute | 4.52s | 1.19s | 26% | +| mcpp.libs.toml | 2.28s | 0.53s | 23% | +| mcpp.modgraph.scanner | 3.23s | 0.66s | 20% | +| mcpp.build.ninja | 3.65s | 1.00s | 27% | + +**中位约 22%。下游在等的 78% 是它根本不需要的代码生成。** + +## A.4 头寸 + +关键链 24 个模块节点合计 ~74.7s。若在 BMI 写完即解锁: +`74.7 × 0.22 ≈ 16.4s` + `obj/main.o` 4.78s + link 0.18s ≈ **21s**。 +此后构建转为吞吐受限,下限是 `work / 线程数 = 314 / 32 ≈ 9.8s`,按 60–70% 并行效率 +落在 15–20s。**综合预期 80s → 25–35s(2.3–3.2×)。** + +## A.5 实施形状(未实施) + +ninja 认为一条边完成 = 进程退出,所以必须让「BMI 好了」成为一个可观测事件: + +* **信号**:GCC 的 `-fmodule-mapper`(P1184)在 BMI 落盘时发 `MODULE-COMPILED`。 + 这是设计好的机制,不需要轮询文件大小(轮询只适合做上面这种一次性测量)。 +* **边的形状**:`cxx_module` 改为跑一个 mcpp 助手,它代管 mapper 协议,收到 + `MODULE-COMPILED` 后**把余下的 codegen 甩到后台并退出 0**。 +* **收口**:`cxx_link` 前置一条 `await-objects` 边,等所有后台 codegen 结束。 + 链接本来就在最后,目标文件是并行完成的,所以这条边通常不阻塞。 + +⚠️ 三个已知坑: + +1. **甩到后台的子进程会继承 ninja 的管道** —— 上一次原型就栽在这里:BMI 边的耗时 + 被记成整条编译的耗时,数字变成 78.99s,看起来像「这个想法不成立」。 + 子进程的 stdio 必须重定向到文件。 +2. **失败会迟到** —— 后台 codegen 失败时,`cxx_module` 边已经报成功了。 + `await-objects` 必须收集并复现每个失败,否则会变成链接期的一堆未定义符号。 +3. **作业槽会超订** —— ninja 以为边结束了,后台进程仍在吃 CPU。这在当前 + 3.94× 的并行度下是**想要**的,但在 `--jobs` 很大时需要重新标定。 + +## A.6 顺带:两条不需要改引擎的路 + +* **`mcpp.build.prepare` 一个文件 16.2s,占 20%。** 拆开它直接缩短关键链, + 且不引入任何调度复杂度。 +* **换编译器。** clang 在同类工程上整体快约 2.4×(此前测量),而关键链的形状不变。 diff --git a/.agents/docs/2026-08-12-modular-build-performance-deep-analysis.md b/.agents/docs/2026-08-12-modular-build-performance-deep-analysis.md new file mode 100644 index 00000000..2e8a9164 --- /dev/null +++ b/.agents/docs/2026-08-12-modular-build-performance-deep-analysis.md @@ -0,0 +1,628 @@ +# 模块化 C++ 构建性能深度分析与优化方案 + +> 2026-08-12 — mcpp 2026.8.11.3 自举构建 / xmake v3.0.7 对照 +> 实测宿主:Intel i9-13900K(8 P-core + 16 E-core,32 线程)、62 GB RAM、Linux 6.8 +> 编译器:GCC 16.1.0(mcpp hermetic payload)、Clang 22.1.8 +> 被测工程:mcpp 自身 —— **137 个 `.cppm` 模块接口单元 + 1 个 `main.cpp`,56.6k 行** + +--- + +## 0. 一句话结论 + +mcpp 的自举构建**不是吞吐瓶颈,是延迟瓶颈**:关键路径 = 100% 墙钟时间,后 55% 的时间里 32 个硬件线程上只有 **1 个**编译进程在跑。而这条关键路径上 **77% 的时间在生成没有任何下游需要的 `.o`** —— 下游真正需要的 BMI 平均在编译进度 **22.8%** 处就已经原子落盘。 + +由此得到的最高价值优化不是"编得更快",而是**"更早释放下游"**。这条已经用真实原型验证过,不只是模拟:同一编译器、同样的编译器并发上限、产物一致,**冷构建 77.42s → 36.56s(2.12×),零额外 CPU 工作量**。 + +第二个发现更廉价也更刺眼:仓库里 2026-05-12 就设计并实现了"接口不变则不级联重编"的 BMI restat 机制,但它**从未生效过**——因为 GCC 把 wall-clock 时间戳写进了 BMI 文件内容本身。一个 `SOURCE_DATE_EPOCH` 让 touch 场景从 **73.0s 变成 0.22s**。 + +--- + +## 1. 分析方法与策略 + +### 1.1 策略:先证伪"编译器很慢",再定位"等待很久" + +面对"构建慢",默认假设通常是"编译器慢 / 代码太多"。这个假设**在本例中是错的**,而且错得很具体。分析按以下顺序推进,每一步都要求可证伪: + +| 步骤 | 问题 | 判据 | 结果 | +|---|---|---|---| +| 1 | 工作量 vs 墙钟 | `sum(edge duration)` / `makespan` | 309s / 79s = **3.91×**,32 线程只用上 12% | +| 2 | 是并行度不足还是关键路径长? | 计算真实关键路径 | 关键路径 = **79.01s = 100% 墙钟** → 纯延迟瓶颈 | +| 3 | 关键路径上的时间花在哪? | `-ftime-report` + `-fmodule-only` 对照 | 86% 在 `opt and generate` | +| 4 | 下游真的需要等 codegen 吗? | 轮询 BMI 落盘时刻 + `strace` | **不需要**,BMI 在 22.8% 处原子就位 | +| 5 | 能否让下游早走? | GCC 模块映射器协议实测 | 可以,`MODULE-COMPILED` 就是该信号 | +| 6 | 增量为何也这么慢? | 字节比对连续两次编译的 BMI | GCC 嵌了时间戳 → 级联抑制永久失效 | + +### 1.2 五个把结论带偏的测量陷阱(每一个都真的改变过结论) + +这些不是花絮,是复现本报告时必须避开的坑: + +1. **多输出边在 `.ninja_log` 里每个输出各写一行**,起止时间相同。按行求和会把编译耗时从 302s 读成 604s。必须按 `(start, end, command_hash)` 去重。 + +2. **模块的真实依赖边不在 `build.ninja` 里**,而在构建期生成的 dyndep 文件 `obj/*.ddi.dd` 中。只读 `build.ninja` 算出的关键路径是 **22s**,折入 dyndep 后是 **79s**——差 3.6 倍,足以得出完全相反的结论("并行调度有问题" vs "关键路径就是全部")。 + +3. **dyndep 把依赖挂在 `obj/X.m.o` 上,而导入者依赖的是同一条边的另一个输出 `gcm.cache/X.gcm`。** 不把同一条边的多个输出合并成单个图节点,最长路径走两跳就断了。 + +4. **ninja 是追加写 `.ninja_log` 的,且每次调用时钟从 0 重启。** 多次构建混在一起会算出"关键路径 > makespan"这种不可能的读数(xlings 那份日志第一次跑出 136%)。必须只取最后一次调用。 + +5. **最长路径必须按拓扑序松弛,不能用栈式 DFS。** DFS 里"跳过已在栈上的节点"这个防环写法,会把兄弟分支压入但尚未算完的依赖也当成 0,导致路径提前终止。我的 C++ 版最初就是这样,报出 **33.9s / 10 节点**,而真值是 **76.5s / 26 节点** —— 把"100% 延迟瓶颈"读成了"44%",结论直接反转成"加核有用"。 + +> 第 5 条是**靠交叉验证抓到的**:同一份日志,`bench --analyze`(C++)与独立的 Python 分析器在 makespan、工作量、逐规则耗时、并发曲线上**全部吻合**,唯独关键路径差 2.3 倍。旁证是并发曲线——末尾 40 秒的 1.0× 串行尾巴不可能与 34 秒的关键路径共存。 +> +> **凡是计算关键路径的东西,都要用第二个实现交叉验证。** 这五条已固化进 `bench/src/analysis/` 与 `bench/README.md`。 + +### 1.3 一个被推翻的假设(保留在此以免后人重走) + +**假设**:GCC 的 `-fmodule-only`("Only emit Compiled Module Interface")能跳过 codegen,从而低成本地拿到 BMI,做成两阶段编译。 + +**实测**:`-fmodule-only` 确实**不产出 `.o`**,但 `-ftime-report` 显示它**照样完整执行 `phase opt and generate`(13.65s / 86%)然后把结果丢弃**。总耗时 15.93s vs 完整编译 15.95s。 + +``` +完整编译 : 15.95s → .o + .gcm +-fmodule-only : 15.93s → 只有 .gcm(codegen 白做) +-fsyntax-only : 2.04s → 什么都不产出(证明前端只要 2s) +``` + +**结论**:GCC 在 16.1 上**没有**廉价产出 BMI 的开关。这是 QoI 缺陷,值得向上游报告。Clang 有(`--precompile`)。 + +### 1.4 工具链 + +| 工具 | 用途 | 关键用法 | +|---|---|---| +| `.ninja_log` + 自研分析器 | 每条边的起止毫秒 → 工作量/关键路径/并发曲线 | `bench --analyze ` | +| `hyperfine` 1.18 | 统计严谨的墙钟计时(中位数、prepare/cleanup 钩子) | 所有矩阵单元 | +| `strace -f -tt -e trace=openat,write,close,rename` | 单次编译内 BMI 文件的生命周期 | 证明 BMI 是**原子 rename** 就位 | +| `g++ -ftime-report` | cc1plus 内部分阶段耗时 | 定位 86% 在 codegen | +| `-fmodule-mapper=\|` | 实测 P1184 模块映射器协议 | 证明 `MODULE-COMPILED` 信号存在 | +| 逐字节 `cmp -l` | BMI 可复现性 | 定位到 4 字节时间戳 | +| 离散事件调度模拟器 | 用实测 t_bmi/t_total + 真实依赖图预测收益 | 贪心表调度,P 可扫 | +| **图改写原型** | 把模拟结论变成实测:机械拆边后跑真实构建 | `bench/proto-bmi-release/` | +| `perf` / `bpftrace` | 备用 —— 本轮**没有用上**:瓶颈在调度与 I/O 时序,不在 CPU 采样能看到的地方 | — | + +> 关于 `perf`:提前开了 `perf_event_paranoid=-1`,但整个分析没有用到采样剖析。定位靠的是**构建图的时间结构**(`.ninja_log`)和**单进程内的文件生命周期**(`strace -tt`)。这本身是一条方法论结论:构建性能问题通常不是"哪段代码热",而是"谁在等谁"。 + +--- + +## 2. 基线数据 + +### 2.1 mcpp 自举构建(GCC 16.1,`-O2`,`-j32`) + +`bench --analyze` 输出(可复现): + +``` +edges : 423 (137 cxx_module + 138 cxx_scan + 138 cxx_dyndep + 1 cxx_object + 1 link + 8 stage) +makespan : 76.54 s +work (sum dur) : 303.36 s +avg parallelism: 3.96 x (of 32 hw threads) +critical path : 76.48 s = 100% of makespan +verdict : LATENCY-bound. More cores will not help. +``` + +| rule | count | total_s | avg_ms | max_ms | %work | +|---|---|---|---|---|---| +| `cxx_module` | 137 | 296.29 | 2162.7 | 15892 | **97.7%** | +| `cxx_object` | 1 | 4.53 | 4527.0 | 4527 | 1.5% | +| `cxx_scan` | 138 | 1.75 | 12.7 | 105 | 0.6% | +| `cxx_dyndep` | 138 | 0.60 | 4.4 | 9 | 0.2% | +| `cxx_link` | 1 | 0.16 | 163.0 | 163 | 0.1% | +| `stage_file` | 8 | 0.02 | 2.8 | 12 | 0.0% | + +> 早前一次剖析读数为 makespan 79.07s / work 309.0s / CP 79.01s,同一结论;差异是运行间噪声。hyperfine 3 次中位数为 **77.55s**。 + +**扫描阶段只占 0.8%。** 每个 TU 三次进程调用(scan → dyndep → compile)的开销常被当成嫌疑犯,实测不是。这条要写进结论,以免有人去优化一个 1.8 秒的阶段。 + +### 2.2 并发度塌陷 + +``` + t= 0.0s 17.4x |################################# + t= 4.0s 9.7x |################## + t= 7.9s 5.2x |########## + t= 11.9s 6.2x |############ + t= 15.8s 4.9x |######### + t= 19.8s 8.7x |################ + t= 23.7s 7.3x |############## + t= 27.7s 2.4x |#### + t= 31.6s 2.0x |#### + t= 35.6s 1.0x |## ← 此后 44 秒(墙钟的 55%)全程单线程 + ... + t= 75.1s 1.0x |## +``` + +关键路径 24 层深: +`shell → linux → platform → manifest.types → manifest.toml → manifest → runtime_selection → runtime_binding → elf_runtime → loader_contract → plan → flags → compile_commands → ninja_backend → prepare(16.1s) → execute → configure → cmd_build → cli → main.o → link` + +> §2.1 的 79.07s 是被剖析的那一次完整重建;§4 表格里的 **77.55s** 是 hyperfine 3 次的中位数。两者一致,前者用于结构分析,后者用于对比。 + +### 2.3 BMI 落盘时刻 vs 编译总时长(全部 137 个模块,`-O2`) + +> 方法:逐个模块单独编译,轮询其 `.gcm` 出现的时刻。**这些是隔离测量**,没有 32 路并发下的内存带宽争用,因此合计 258.9s 低于真实构建的 309.0s(约 16%)。这个偏差对 §6.2 的模型 A 和 B **同向作用**,所以那里的**加速比(2.98×)比绝对秒数更可信**。 + +| | 秒 | +|---|---| +| BMI 产出耗时合计 | **59.1** | +| 完整编译耗时合计 | **258.9** | +| **下游白等的 codegen** | **199.8(77.2%)** | +| BMI 平均就绪进度 | **22.8%** | + +最热的几个: + +| 模块 | t_bmi | t_total | BMI 占比 | +|---|---|---|---| +| `build/prepare.cppm` | 2.29 | 15.99 | 14.3% | +| `cli.cppm` | 1.98 | 5.50 | 35.9% | +| `build/plan.cppm` | 0.92 | 5.44 | 17.0% | +| `platform/runtime_binding.cppm` | 0.98 | 5.40 | 18.1% | +| `doctor.cppm` | 1.05 | 5.07 | 20.8% | +| `manifest/toml.cppm` | 0.59 | 4.67 | 12.7% | + +### 2.4 BMI 是原子就位的(可安全提前消费) + +`strace` 抓到的 `build/plan.cppm` 编译过程: + +``` +10:25:01.058 编译开始 +10:25:01.855 openat("gcm.cache/mcpp.build.plan.gcm~", O_RDWR|O_CREAT|O_TRUNC) +10:25:01.950 close(fd) +10:25:01.950 rename("...gcm~", "...gcm") ← BMI 原子就位 +10:25:06.527 进程退出 ← 又跑了 4.58 秒纯 codegen +``` + +**写临时文件 + `rename()`** 意味着 BMI 要么不存在、要么完整,不存在撕裂读。这让"看到 BMI 就放行下游"在**构造上**是安全的,不需要额外加锁或校验。 + +--- + +## 3. 根因 + +### F1 — 构建是延迟瓶颈,加核完全无效 + +离散事件模拟(真实依赖图 + 实测每模块耗时): + +``` +P=8 72.0s → 并行度不是约束 +P=16 72.0s +P=24 72.0s +P=32 72.0s +P=64 72.0s ← 加到 64 线程,一秒都不会快 +``` + +### F2 — 关键路径上 77% 的时间在生产无人等待的 `.o` + +见 §2.3 / §2.4。GCC 单阶段模型下,BMI 与 `.o` 由同一个进程产出;ninja 的依赖模型只认"边结束",于是导入者被迫等到 codegen 收尾。 + +### F3 — GCC 把时间戳写进 BMI,级联抑制机制从未生效 + +`build.ninja` 的 `cxx_module` 规则实现了 2026-05-12 文档设计的 copy-if-different: + +```sh +cp -p $bmi_out $bmi_out.bak && && \ + if cmp -s "$bmi_out" "$bmi_out.bak"; then mv "$bmi_out.bak" "$bmi_out"; else rm -f "$bmi_out.bak"; fi +``` + +同一条命令连编两次,BMI 有 **4 字节**不同: + +``` +buildtime: 2026/08/12 02:25:01 UTC localtime: 2026/08/12 02:25:01 UTC +buildtime: 2026/08/12 02:25:33 UTC localtime: 2026/08/12 02:25:33 UTC + ^^ ^^ +``` + +2026-05-12 的文档写道:「GCC 每次都会重新生成 BMI 文件(即使内容相同**时间戳也变**),所以必须在构建系统层面做 copy_if_different」。**该判断只覆盖了文件 mtime,漏掉了时间戳被写进文件内容**,因此 `cmp -s` 同样必然失败,`restat` 永远认为 BMI 变了。 + +实测后果(touch 一个被 46 个模块导入、内容完全未变的 `platform.cppm`): + +| | 墙钟 | 重跑边数 | +|---|---|---| +| 现状 | **73.0s** | 180 | +| `SOURCE_DATE_EPOCH=<固定值>` | **0.22s** | 5 | + +**332×**,且正确性不变:真实接口变更仍然完整级联(180 边),回退亦然。 + +### F4 — 改函数体照样全量级联(架构问题,不是 bug) + +在 `src/ui.cppm`(27 个导入者)的**非 inline 函数体内加一行注释**,接口完全未动: + +| | 墙钟 | 重跑边数 | +|---|---|---| +| body-only 编辑 | **47.6s** | 66 | + +GCC 的 BMI 携带函数体(为了跨模块内联),所以任何编辑都会改变 BMI 字节。`SOURCE_DATE_EPOCH` 修不了这一类。 + +**真正的根因是工程结构**:mcpp 有 **137 个模块接口单元,却只有 1 个实现 TU(`main.cpp`)**——全部实现代码都写在接口单元里。因此每一次日常编辑的代价都是 O(导入者数),而不是 O(1)。 + +### F5 — 扫描/dyndep 阶段不是瓶颈 + +`cxx_scan` 1.83s + `cxx_dyndep` 0.48s = 全部工作量的 **0.8%**。每 TU 三次进程调用的设计**不需要优化**。 + +### F6 — `prepare.cppm` 单点占关键路径 20% + +16.1s,扇入 **61 个 BMI**(含 31MB 的 `std.gcm` 与 17MB 的 `mcpp.libs.json.gcm`)。 + +--- + +## 4. mcpp vs xmake 实测对比 + +同一份源码(137 `.cppm` + `main.cpp`)、**同一个 `g++` 二进制**(`xim-x-gcc/16.1.0`,由 `xmake.lua` 从 `mcpp.toml` 的 `[toolchain] default` 读取并钉死)、同样 `-std=c++23 -fmodules -O2`、同样 `-j32`。hyperfine 中位数,每格 3 次。 + +两侧各产出 **141 个 BMI** —— 模块集合一致,xmake 的 culling 没有偷偷少编东西。 + +| 场景 | mcpp | xmake | 判读 | +|---|---|---|---| +| **冷构建**(release `-O2`) | **77.55s** | 88.94s | mcpp 快 **1.15×** | +| **冷构建**(debug `-O0 -g`) | **44.23s** | 46.30s | mcpp 快 1.05× | +| **no-op** | 0.430s | **0.381s** | xmake 略快 | +| **touch hub 模块**(46 导入者,内容未变) | **73.79s** | 81.70s | **两者都退化到接近全量重建** | +| **改函数体**(27 导入者,接口未动) | **47.75s** | 52.19s | 两者都完整级联 | +| **touch `main.cpp`** | 5.40s | **5.28s** | 持平 | + +> **`-O0` 相对 `-O2` 只快 1.75×**(77.55 → 44.23)。对一个 codegen 占 77% 工作量的构建,这个比例偏低,再次印证前端与关键路径结构才是主导——**降优化档不是出路**(§6.5)。 +> +> 另可注意:release 档 mcpp 领先 14.7%,debug 档只领先 4.7%。优化档位越高,两个引擎的差距越明显。 + +### 4.1 冷构建差距的归因(两个已声明的不对称,都已量化) + +| 不对称 | 实测值 | 结论 | +|---|---|---| +| mcpp 从全局缓存 stage `std.gcm`,xmake 自己编译 | 编译 `std` 只要 **2.04s** | 只解释约 2s,**不是主因** | +| mcpp 有全局依赖构建缓存 | `mcpp build --cache=off` 冷构建 = **78.46s**(vs 77.55s) | 缓存只值 **0.9s**,**不是主因** | + +⇒ **11.4s 的差距扣除上述约 2s 后仍有约 9s(~10%),是真实的引擎差异**,不是缓存优势。 + +### 4.2 最重要的判读:两个引擎在增量场景下**一起失败** + +`touch-hub` 一栏是全表最关键的信息:一个内容**完全没变**的文件,mcpp 花 73.79s、xmake 花 81.70s,而冷构建分别是 77.55s / 88.94s —— **增量 ≈ 全量**。 + +两个独立实现的构建引擎表现几乎一致,说明这**不是某一家的实现质量问题**,而是 **C++ 命名模块 + GCC 的结构性问题**(§3 的 F3/F4)。这也意味着: + +> 在 GCC 上,任何构建系统都无法靠"更聪明的调度"解决增量问题——必须解决 BMI 的确定性(F3)与 BMI 携带函数体(F4)。 + +### 4.3 结论在第二个独立项目上复现:xlings + +只测一个项目得出的"结构性结论"不可信。**xlings** 是理想的对照:独立作者、独立代码库、同量级规模,且已从 xmake 迁移到 mcpp(用户给出的 `xmake.lua` 是迁移前的 ca25ab7)。 + +| | mcpp | xlings | +|---|---|---| +| 模块接口单元 / LOC | 137 / 56 555 | 110 / 46 253 | +| 冷构建 makespan | 79.07s | 51.74s | +| 总工作量 | 309.0s | 163.6s | +| **平均并行度**(32 线程) | **3.91×** | **3.16×** | +| **关键路径占墙钟** | **100%** | **100%** | +| 编译占总工作量 | 97.6% | 95.5% | +| 扫描 + dyndep 占比 | 0.8% | 1.7% | +| 关键链深度 | 24 | 24 | + +**两个项目的病理完全一致。** 这不是某个代码库的偶然结构,而是"C++23 命名模块 + GCC 单阶段 + 边完成即释放"这一组合的固有结果。 + +--- + +--- + +## 5. 被证伪的优化方向(先说不要做什么) + +投入之前先砍掉三条看起来合理、实测无效的路线。每条都有具体判据。 + +### ✗ 5.1 "缩小 BMI / 降低扇入"——BMI 体积几乎不要钱 + +直觉:`std.gcm` 31.5MB 被 134/138 个 TU 导入,`mcpp.libs.json.gcm` 17.1MB 被 17 个导入,反序列化必然很贵。 + +实测(空模块 vs 逐个加 import): + +| TU 内容 | 编译耗时 | +|---|---| +| 空模块(无 import) | 12.1 ms | +| `+ import std`(31.5 MB BMI) | 16.9 ms(**+4.8 ms**) | +| `+ import mcpp.libs.json`(17.1 MB) | 19.2 ms(**+2.3 ms**) | + +**GCC 的模块导入本来就是惰性的**(mmap + 按需具现)。BMI 体积基本不影响导入成本;真正花钱的是这个 TU **自己**的代码。 + +佐证:`corr(LOC, t_total) = 0.825`,平均 **4.6 ms/行**。编译时间由代码量驱动,不是由扇入驱动。 + +> 推论:`-fmodule-lazy` 大概率也没有收益(默认已惰性)。 + +### ✗ 5.2 "优化 scan / dyndep 阶段"——它只占 0.8% + +每个 TU 三次进程调用(scan → dyndep → compile)看着浪费,实测 `cxx_scan` 1.83s + `cxx_dyndep` 0.48s = 全部工作量的 **0.8%**。不要动。 + +### ✗ 5.3 "上分布式编译(distcc/icecc)"——关键路径 100%,分布式无处可分 + +关键路径 = 100% 墙钟意味着**任何时刻可并行的工作都已经并行完了**。模拟显示 P=64 与 P=16 完全同速。分布式编译在 F2 解决之前是纯粹的负收益(加了网络延迟)。 + +**顺序很重要:必须先做 §6.2,分布式才有意义。** + +--- + +## 6. 优化方案 + +按 **收益 / 成本** 排序。每条都给出判据与验证方式。 + +### 6.1 ✅【已实施 · 2026.8.12.1】让级联抑制真正生效(仅 GCC) + +**问题**:F3。**仅适用于 GCC** —— Clang 的 `.pcm` 实测字节稳定(§7.2),那边的级联抑制本来就在工作。 + +**已按方案 A 实施**(2026.8.12.1):把"BMI 是否相等"的判据从裸 `cmp` 换成**时间戳无关比较**。新增 `mcpp bmi-equal `(内部子命令),跳过 BMI 内的 `buildtime:` / `localtime:` 字段。`cxx_module` 规则里把 + +```sh +cmp -s "$bmi_out" "$bmi_out.bak" +``` +换成 +```sh +$mcpp bmi-equal "$bmi_out" "$bmi_out.bak" +``` + +**方案 B(附赠可复现构建)**——注入 `SOURCE_DATE_EPOCH`。取值**不能是当前时间**(那等于没改),候选:git commit 时间 / manifest version 派生的常量。加 `[build] reproducible = true` 开关。 + +**副作用**:方案 B 会改变 `__DATE__` / `__TIME__` 的值。用户代码可能依赖,因此不宜作为默认。**方案 A 无此问题,应作为默认;方案 B 作为可选项。** + +**实测收益(落地后,由 `bench --project` 测 mcpp 构建自身)**: + +| 场景 | 2026.8.11.3 | 2026.8.12.1 | +|---|---|---| +| `noop` | 0.27s | 0.19s | +| **`touch-hub`**(46 导入者,内容未变) | **73.99s** | **0.45s** | + +正确性由单测从**两侧**钉死(`tests/unit/test_bmi_equivalent.cpp`):真实接口变更仍完整级联。 + +> **⚠️ 目前仅 POSIX。** `ninja_backend` 的 Windows 分支写着 *"skip BMI restat optimization (requires POSIX shell)"* —— 整套 backup/compare/restore 是用 shell 的 `if` / `cp` / `cmp` 拼的,cmd.exe 上没有对应写法,所以 **Windows 一直没有任何级联抑制**。 +> +> 这个限制现在可以解除了:`bmi-equal` 已经是 mcpp 的子命令,把剩下的 backup/restore 也收进一个 `mcpp bmi-guard --bmi -- ` 里,整个序列就变成**一个进程、零 shell**,两个平台共用同一条规则。这是本条优化的直接后续,不需要新的设计。 + +**验证方式**:`bench/run.sh --scenario touch-hub`,并**必须同时验证**接口变更场景仍然级联——只测 touch 分不清"级联被正确抑制"和"级联坏了"。 + +### 6.2 【L1·最大收益】BMI 落盘即释放下游 + +**问题**:F1 + F2。这是全部方案里收益最高的一条。 + +**核心事实**(已实测): +- BMI 在编译进度 22.8% 处**原子 rename** 就位(§2.4),之后 77% 的时间下游在空等 +- GCC 的模块映射器协议**主动发送 `MODULE-COMPILED `**(实测报文见 §7),这正是"CMI 就绪"信号 +- 同一份 CPU 工作量,**一个编译进程都不多** + +**模拟收益**(真实依赖图 + 实测每模块 t_bmi/t_total): + +| 模型 | makespan | 加速 | +|---|---|---| +| A 边完成即释放(现状) | 72.0s | 1.00× | +| **B BMI 落盘即释放** | **24.1s** | **2.98×** | +| C codegen 完全离开关键路径(理论上界) | 15.4s | 4.66× | + +且核数重新变得有意义:现状 P=16 与 P=64 同为 72s;方案 B 下 P=8→42.7s、P=32→24.1s。 + +#### ✅ 已用真实原型实测验证(不是只有模拟) + +把 mcpp 生成的 `build.ninja` 机械改写成"每模块两条边、共用一个编译进程",在同一个构建目录、同一编译器、**同样的编译器并发上限(≤32)**下 A/B: + +| 方案 | 墙钟 | 产物 | +|---|---|---| +| baseline(边完成即释放) | **77.42s** | 19,347,008 B | +| **split(BMI 落盘即释放)** | **36.56s** | 19,347,008 B,`--version` 正常 | + +**实测 2.12×**,零额外 CPU 工作量(每个模块仍然只有一个 `g++ -c`)。自检:BMI 边中位数 883ms vs OBJ 边中位数 3041ms —— 确认第一阶段真的提前退出了。 + +实测 2.12× 低于模拟 2.98×,差距来自原型的 bash 轮询(5ms)与 `mkdir` 信号量开销,以及模拟使用的是隔离编译耗时(§2.3 注)。生产实现(用映射器协议或原生 job control)应更接近模拟值。 + +#### ⚠️ 原型暴露的两个实现陷阱(任何真实实现都会踩) + +**陷阱 1:分离出去的编译器继承了构建系统的 stdout/stderr 管道。** +ninja 判定一条边结束的依据是**管道 EOF,而不是直接子进程退出**。第一阶段即使提前 `exit 0`,只要后台编译器还持有那个 fd,ninja 就认为边还在跑。第一次原型运行就栽在这里:BMI 边的中位数是 2018ms(= 完整编译时长),看起来像"这个想法没用",实际是**测量被伪装成了 baseline**。 +修法:后台进程的 stdout/stderr 重定向到文件,由第二阶段回放(否则编译器警告与错误会静默消失)。 + +**陷阱 2:ninja 的 `-j` 必须远大于编译器并发上限。** +编译器一旦分离就不再占用 ninja 槽位,并发改由信号量约束。若 `-j` 与信号量上限相同,槽位会被"卡在信号量上"和"等 codegen"的休眠边占满,就绪前沿饿死,调度退化成 baseline。原型第一次正是 `-j32` + 上限 32 ⇒ 78.99s(比 baseline 还慢)。 +修法:`-j` 取编译器上限的数倍(原型用 6×),CPU 并行度仍由信号量精确控制。 + +**三条实现路径:** + +| | 机制 | 成本 | 风险 | +|---|---|---|---| +| **(A) 拆边 + 监督进程** | 每个模块拆成 `cxx_module_bmi`(BMI 落盘即退出)+ `cxx_module_obj`(等 codegen 收尾并传播退出码),共用一个编译进程 | 改 `ninja_backend` + 一个新 helper 子命令 | ninja 槽位记账;中断时需清理后台进程;Windows 无 fork(用 Job Object) | +| **(B) Clang 原生两阶段** ⭐ | `--precompile` → `.pcm`,再 `-c x.pcm` → `.o`。**Clang 本来就支持**,mcpp 目前只在 `std` 模块上用了它,项目模块走的是单阶段 `-fmodule-output=` | 只改构建图,**无需监督进程/信号量/映射器** | 已实测:总 CPU 只多 **7%**,关键路径份额降到 **25%**(§7.3),**风险接近零** | +| **(C) mcpp 作为模块映射器服务** | `-fmodule-mapper=`,mcpp 阻塞应答 `MODULE-IMPORT` 直到 BMI 就绪,生产者发 `MODULE-COMPILED` 时立即放行 | 最大改动 | **死锁**:并发槽被"等 import"的编译器占满而生产者排不进来 ⇒ 必须按拓扑序做准入控制 | + +**建议路线**:**(B) 先落地**(Clang 上零风险拿到收益,macOS/Windows 默认即 llvm)→ **(A) 覆盖 GCC** → (C) 作为下一代架构。 + +**给 GCC 上游的反馈**:`-fmodule-only` 文档写的是 "Only emit Compiled Module Interface",实际仍完整执行 codegen 再丢弃(§1.3 实测)。若上游修复,方案 A 可退化成两条普通 ninja 边,复杂度大降,并直接逼近模型 C 的 4.66×。 + +### 6.3 【L2·架构】把实现移出模块接口单元 + +**问题**:F4。body-only 编辑仍然 47.8s。 + +**根因是工程结构**:mcpp 有 **137 个模块接口单元、1 个实现 TU**。GCC 的 BMI 携带函数体(为跨模块内联),所以接口单元里的**任何**编辑都改变 BMI 字节 ⇒ 代价 O(导入者),而不是 O(1)。 + +**方案**:非 inline、非模板的函数体迁往模块实现单元(`module mcpp.ui;`,不带 `export`)。实现单元**不产生 BMI**,编辑它只重编 1 个 TU。 + +**优先靶点**(按 LOC × 扇入): + +| 模块 | LOC | 扇入 | 现状单次编辑代价 | +|---|---|---|---| +| `build/prepare.cppm` | **6476**(占全项目 11%) | — | 15.99s 自身 + 关键路径 20% | +| `ui.cppm` | 723 | 27 | 47.8s(实测) | +| `platform/platform.cppm` | — | 46 | 73.0s(实测,内容未变时) | +| `manifest/manifest.cppm` | — | 30 | — | + +**成本**:重构工作量大,可增量推进(先动上表 4 个)。需要先确认 mcpp 的 scanner / glob 对实现单元的支持体验。 + +**注意**:`prepare.cppm` 6476 行本身就是独立问题——它是关键路径末端最重的单点(占 20%),即使不迁实现,拆分它也直接缩短关键路径。 + +### 6.4 【L1】对象级缓存 + +codegen 占全部工作量的 **77%**。BMI 稳定后(§6.1),`.o` 也可按内容哈希缓存。mcpp 已有 `~/.mcpp/build-cache/v1/` 用于依赖包,扩展到根包即可。 + +**收益场景**:切分支来回、revert、CI 缓存恢复。**不改善**首次冷构建。 + +### 6.5 【不推荐】降低优化档位 + +**实测否定了这条**:`-O0` 相对 `-O2` 只快 **1.75×**(77.55s → 44.23s)。对一个 codegen 占 77% 工作量的构建,这个比例说明降档拿不到成比例的收益——前端与关键路径结构才是主导。而代价是产物运行时性能全丢。 + +⇒ **不值得**。相比之下 §6.2(2.12× 实测)与 §7.3(Clang 两阶段)既不牺牲产物质量,收益也更大。 + +**生态先例仅作参考**:xlings 迁移前的 `xmake.lua` 因 GCC 15 在 `-O1/-O2` + C++23 modules 上 ICE(`tree-ssa-dce`)而在 Linux 上强制 `-Og`。那是**规避编译器崩溃**,不是性能选择。 + +--- + +## 7. 跨平台与不同编译器 + +### 7.1 实测:同一份代码,Clang 比 GCC 快 2.42× + +`mcpp build`,同一工程、同一 `-O2`、同样 137+1 个编译单元、同样的边数,只换工具链: + +| | GCC 16.1.0 | Clang 22.1.8 | | +|---|---|---|---| +| **冷构建墙钟** | 77.55s | **32.08s** | **2.42×** | +| 总工作量(所有边耗时之和) | 309.0s | **123.0s** | 2.51× | +| 平均并行度 | 3.91× | **3.89×** | 一样低 | +| **关键路径占墙钟** | **100%** | **100%** | 一样是延迟瓶颈 | +| 最重单点 `prepare.m.o` | 16.1s | 7.1s | 2.27× | +| 产物大小 | 19.3 MB | 5.7 MB | (libc++ + 默认 strip 差异) | + +**这两组数字要一起读:** + +- Clang 让**每个单元**便宜 2.5 倍 —— 这是纯粹的编译器前端/后端效率差距,**零工程成本**; +- 但 Clang 的构建**结构性病理与 GCC 完全一样**:并行度 3.89×、关键路径 100%。换编译器**不解决** F1/F2。 + +⇒ **§6.2 的 BMI 提前释放与"换 Clang"是正交的、可以相乘的**,不是二选一。粗略叠加后 mcpp 自举冷构建有望进入 **15s 量级**(当前 77.55s)。 + +### 7.2 编译器能力矩阵(全部实测,不是查文档) + +| 能力 | GCC 16.1.0 | Clang 22.1.8 | +|---|---|---| +| 廉价"只产 BMI" | ✗ `-fmodule-only` **仍完整跑 codegen 再丢弃**(15.93s vs 15.95s) | ✓ `--precompile` **1.80s vs 单阶段 7.18s(3.99×)** | +| BMI 在编译进度多早落盘 | 22.8% | 25.3% —— **同样的问题** | +| BMI 字节可复现 ⇒ F3 | ✗ 嵌 `buildtime`/`localtime`,需 §6.1 | ✓ **字节稳定,无需任何处理** | +| 精简 BMI 能否免掉 body 编辑级联 ⇒ F4 | ✗ | ✗ **`-fmodules-reduced-bmi` 实测无效** | +| 模块映射器协议(P1184) | ✓ 实测可用,`MODULE-COMPILED` 即就绪信号 | ✗(用 `-fmodule-file=`) | +| 惰性导入 | ✓ 默认即惰性(§5.1) | ✓ | + +### 7.3 关键实测:Clang 的两阶段编译几乎是白送的 + +对同一个 `build/prepare.cppm`: + +| | 耗时 | 落在关键路径上的部分 | +|---|---|---| +| 单阶段 `-fmodule-output`(mcpp 现状) | 7.18s | **7.18s** | +| 两阶段 · phase 1 `--precompile` | **1.80s** | **1.80s** | +| 两阶段 · phase 2 `.pcm → .o` | 5.90s | 0(可完全并行) | +| 两阶段合计 | 7.71s | — | + +**总 CPU 只多 7%,关键路径上的份额降到 25%。** 而且实现上只需要**把一条 ninja 边拆成两条**——不需要监督进程、不需要信号量、不需要映射器服务,§6.2 那两个实现陷阱一个都不会遇到。 + +⇒ **这是整份报告里性价比最高的一条:Clang 上改构建图即可,风险接近零。** + +### 7.4 关于 F4 的更正 + +早期设计文档(2026-05-12 §3.4)寄望于 Clang 的 `-fmodules-reduced-bmi` 来消除"改实现也级联"。**实测不成立**: + +| 编辑方式 | GCC BMI 变化 | Clang BMI 变化 | Clang + reduced-bmi | +|---|---|---|---| +| 函数体内加一行注释(移动行号) | 变 | 变 21 974 B | 变 21 974 B | +| **不改变行数**的函数体内编辑 | 变 **14 B** | 变 14 354 B | 变 14 354 B | + +两个编译器都会变 ⇒ 都会级联。**F4 只能靠 §6.3 的工程结构调整解决**(把实现移出接口单元),没有编译器开关可用。 + +### 7.5 平台结论 + +- **F3(BMI 不确定性)是 GCC 独有的** —— Clang 上 mcpp 现有的级联抑制本来就在工作。这意味着 §6.1 是 GCC 专项修复。 +- **F1/F2(延迟瓶颈)两个编译器都有**,且 mcpp 目前在 Clang 上也走单阶段(`-fmodule-output=`),白白放弃了 `--precompile`。 +- **换编译器与改调度是正交的**:Clang 已经快 2.42×,叠加两阶段后关键路径还能再降约 4×。 +- **Windows** 无 `fork`,§6.2(A) 的监督进程需 Job Object;但 Windows 默认已是 `llvm@20.1.7`,走 §7.3 的两阶段即可绕开。 +- **macOS** 默认 `llvm@22.1.8`,同样直接受益。 +- **musl / 交叉目标** 不影响本分析任何结论(瓶颈在前端与调度,不在 libc)。 + +**跨平台注意**: +- **Windows** 无 `fork`,§6.2(A) 的监督进程需用 Job Object 保证中断时不残留;MSVC 两阶段天然 +- **macOS** 默认 llvm ⇒ §6.2(B) 收益立即可得 +- **musl / 交叉目标** 不影响本分析的任何结论(瓶颈在前端与调度,不在 libc) + +--- + +## 8. 建议落地顺序 + +按"收益 ÷ 风险"排,每条都给出**验证判据**——判据不满足就不算做完。 + +| # | 动作 | 适用 | 实测/预估收益 | 风险 | 验证判据 | +|---|---|---|---|---|---| +| **1** | §7.3 **Clang 走原生两阶段**(`--precompile` + `-c x.pcm`) | Clang(macOS/Windows 默认,Linux 可选) | 关键路径份额 7.18s→1.80s(**3.99×**),总 CPU 仅 +7% | **极低**:只改构建图 | 冷构建墙钟下降;`.pcm` 与单阶段产物等价;`mcpp test` 全绿 | +| **2** | §6.1 **BMI 比较忽略时间戳**(`mcpp bmi-equal`) | 仅 GCC | touch 场景 **73.0s → 0.22s** | 低 | **必须双侧钉**:内容未变→不级联;接口变更→**仍完整级联**(只测前者分不清"修好了"和"坏了") | +| **3** | §6.2(A) **GCC 侧 BMI 落盘即释放** | GCC | **实测 2.12×**(77.42→36.56s) | 中:进程生命周期管理 | 产物一致 + 构建后无残留编译器进程 + 中断可清理;⚠️ 必检 BMI 边耗时是否**真的**远小于 OBJ 边 | +| **4** | §6.3 **实现移出接口单元**(先动 `prepare.cppm` 6476 行) | 全平台 | body 编辑 47.8s → 预计个位数秒 | 中:重构量大,可增量 | 改一个实现单元后重编 TU 数 = 1 | +| **5** | §6.4 **对象级缓存** | 全平台 | 切分支/revert 场景 | 低 | 命中时**确实跳过编译**(⚠️ 历史上出现过"命中也 100% 重编"的假缓存) | +| **6** | §6.2(C) **mcpp 作为模块映射器服务** | GCC | 逼近模型 C(4.66×) | 高:需拓扑序准入控制防死锁 | 大规模工程下无死锁、无饥饿 | + +**明确不做**:§5.1 缩小 BMI/降扇入、§5.2 优化扫描阶段、§5.3 分布式编译(在 #3 之前无效)、§6.5 降优化档。 + +**关于默认工具链**:Linux 默认 `gcc@16.1.0` 比 `llvm@22.1.8` 慢 **2.42×**(§7.1)。这是个值得单独评估的决策——但它与上表正交,不是替代关系。 + +--- + +## 附录 A:复现方式 + +> 本报告成文时用的是一次性 bash + hyperfine 脚本;它已被 `bench/` 取代 —— 一套用 mcpp 写的跨平台基准套件,见 [bench 架构与实施计划](./2026-08-12-bench-suite-architecture-and-plan.md)。下列命令是当前的复现方式。 + +```bash +# 基准(同一个二进制,跨三平台) +cd bench && mcpp build +./target/*/*/bin/bench --list # 本机装了哪些引擎 +./target/*/*/bin/bench --engines mcpp,mcpp-opt,cmake,xmake \ + --variants headers,modules,modules-impl \ + --scenarios cold,noop,touch-hub,edit-body + +# 构建剖析器(同一个二进制的 --analyze 模式) +./target/*/*/bin/bench --analyze ../target/x86_64-linux-gnu/ + +# BMI 提前释放原型的 A/B +bench/proto-bmi-release/run_proto.sh +``` + +测量契约见 `bench/README.md`;结果按运行分目录,索引见 `bench/results/README.md`, +本文这批数据的出处在 `bench/results/hyperfine-20260812/NOTES.md`。 + +## 附录 B:关键原始数据 + +### B1 每模块 BMI 落盘时刻(-O2,137 个单元合计) + +``` +sum t_bmi = 59.1 s sum t_total = 258.9 s BMI 占比 22.8% 白等 199.8 s +``` + +### B2 模块图形态 + +``` +单元 138 依赖边 736 平均扇出 5.3 总 LOC 56 555 平均 4.6 ms/行 +corr(LOC, t_total) = 0.825 + +扇出 top3 prepare 60 · doctor 27 · ninja_backend 24 +扇入 top4 std 134 · mcpp.platform 46 · mcpp.manifest 30 · mcpp.ui 27 +纯聚合模块 platform.cppm(9 条 export import,0 行实码)· pm.cppm · manifest.cppm +``` + +### B3 模块导入的边际成本(证伪"BMI 太大"这条路线) + +``` +空模块 12.1 ms → + import std(31.5 MB)16.9 ms → + import json(17.1 MB)19.2 ms +``` + +### B4 GCC 的 BMI 非确定性 + +``` +连续两次相同编译,BMI 差 4 字节: + buildtime: 2026/08/12 02:25:01 UTC localtime: 2026/08/12 02:25:01 UTC + buildtime: 2026/08/12 02:25:33 UTC localtime: 2026/08/12 02:25:33 UTC +设 SOURCE_DATE_EPOCH 后:字节完全一致,且 localtime 字段消失 +``` + +### B5 BMI 的原子提交(strace,`build/plan.cppm`) + +``` +10:25:01.058 编译开始 +10:25:01.855 openat("gcm.cache/mcpp.build.plan.gcm~", O_RDWR|O_CREAT|O_TRUNC) +10:25:01.950 close → rename(...gcm~, ...gcm) BMI 原子就位 +10:25:06.527 进程退出 之后 982 个系统调用,无一再碰它 +``` + +### B6 GCC 模块映射器协议实测报文 + +``` +--> HELLO 1 GCC '' ; <-- HELLO 1 mapper-probe gcm.cache ; +--> MODULE-REPO <-- PATHNAME gcm.cache +--> MODULE-EXPORT probe.a <-- PATHNAME probe.a.gcm +--> MODULE-COMPILED probe.a <-- OK ← 这就是「CMI 已就绪」信号 +``` + +⚠️ 协议用**行尾 ` ;`** 表示批处理续行,应答必须镜像该标记,否则 GCC 会把第 N 个应答配到第 N+1 个请求上。 diff --git a/.agents/docs/2026-08-13-build-optimization-status.md b/.agents/docs/2026-08-13-build-optimization-status.md new file mode 100644 index 00000000..3fe6d7bb --- /dev/null +++ b/.agents/docs/2026-08-13-build-optimization-status.md @@ -0,0 +1,725 @@ +# 构建性能优化:综合报告(2026-08-13) + +本文报告 L1–L4 四条杠杆的**当前状态**、每条的**依据**、以及一次**被回退的实施**。 +架构与方案在 `2026-08-13-build-performance-architecture.md`;这里只讲做到了哪里。 + +--- + +## 0. 一句话结论 + +**目标达成。** 同一份 pinned 源码(@8219584)、同一台机器、`eef8a4c` 上复测: + +| | schedule=off | schedule=on | 比值 | +|---|---|---|---| +| **gcc 16.1.0** | 80.58s | **35.13s** | **2.29×** | +| **llvm 22.1.8** | 32.40s | **18.04s** | **1.80×** | + +**L1 与 L2 叠加**:gcc/off 80.58s → clang/on **18.04s = 4.47×**,远在 50s 线内。 +两者正交 —— L1 换编译器(压常数),L2 改图的形状,所以相乘而不是相加。 + +拆分调度下 noop **重建 0 个产物**(`.o`/`.gcm`/`.pcm` 一个都没动)。 + +### 而且是通用的,不是把 mcpp 这一个工程调快 + +| 工程 | 规模 | schedule=off | schedule=on | 比值 | +|---|---|---|---|---| +| **mcpp** | 138 模块 / 57k 行 | 79.9s | **34.80s** | **2.30×** | +| **xlings** | 110 模块 / 46k 行 | 112.92s | **33.41s** | **3.38×** | + +xlings 是**独立作者、独立代码库**的对照(openxlings/xlings @ b1563fe), +效果比开发它的那个工程**更大**。两个工程都 noop 无重建 +(mcpp 0.21s;xlings 的 10.77s 全部是依赖解析开销,`.ninja_log` 增量 **0 条边**)。 + +⚠️ xlings 那一栏有一处不对称:off 那次编译了 `mcpplibs.xpkg`(5 个单元), +on 那次命中了缓存。5 个单元相对 80s 的差值可以忽略,但记在这里而不是抹掉。 + +⚠️ **修正一次错误归因。** 本文先前写「schedule 基础层导致段错误,已整批回退」。 +重新施加后逐条复现:**基础层 rc=0**。那两次崩溃用的二进制**都包含当时未提交的图拆分 +发射** —— 崩的是那部分。基础层已恢复,`auto` 为 off、`on` 才启用拆分形状。 + +--- + +## 0b. L2 的覆盖面 —— gcc 与 clang 都已落地 + +两个编译器各支持**其中一种**机制,不可互换,`policy` 决策一次: + +| 编译器 | 形状 | 为什么只能是它 | +|---|---|---| +| gcc | `detach-codegen` | 无廉价的 BMI-only 模式(`-fmodule-only` 要花掉整编译的 99%),但 gcc 用 `rename()` 发布 BMI,所以"文件出现"是可靠信号 | +| clang | `two-phase` | 反过来:clang 用 `O_TRUNC` 就地写 BMI(读者会看到半个文件),但它有真正廉价的 BMI-only 调用 | + +### clang 这条的两个坑,都不是"接边"的问题 + +**坑 1:`--precompile` 发出来的不是同一种 BMI。** + + -fmodule-output= … -c 7.35s BMI 9,102,984 B (reduced) + --precompile 1.81s BMI 18,402,920 B (FULL) + --precompile -Xclang -emit-reduced-module-interface + 1.67s BMI 9,102,968 B (reduced) + (clang 22.1.8,src/build/prepare.cppm) + +`--precompile` 单独用又快又对**看起来**成立,实际上它发的是 *full* BMI —— +因为它的产物本来是要喂回去做 codegen 的。把 full BMI 发布给下游不是等价替换: +小模块上体积涨约 16 倍(`mcpp.platform` 19,424 → 313,452 B),而且在 mcpp 自己的 +模块图上直接让 clang 22.1.8 编错一个下游 TU: + + error: call to implicitly-deleted default constructor of + 'formatter, wchar_t>' + +—— 一个**窄**格式串,报错点在 `std` 里面,离真因三个文件远。同一个 TU 对着 reduced BMI +编译通过。所以 reduced 不是优化,是契约:`bmiOnlyFlags` 必须逐字节复现它。 + +⚠️ 这个坑的判据是**体积**,不是"编过了"。先做出来的版本能跑完 fixture、 +noop 干净、增量传播正确,**在 137 个模块的真实工程上才炸**。 + +**坑 2:object 边只能重编源码,不能读 BMI。** + +reduced BMI 不能拿去 codegen,于是 object 边是 `-c <源码>`(不带 `-fmodule-output`, +BMI 归 A 边所有,两条边不能写同一个文件)。代价是**前端跑两遍**;收益是下游只等 A 边。 + +两条边彼此**独立**(object 边不等 BMI 边),所以 codegen 可以整体落在图的后面。 + +### dyndep:一个源码两条边,两条都要记录 + +P1689 只知道 object(`primary-output` 取自被扫描命令的 `-o`),BMI 边没有记录时 +ninja 不是警告而是**整图拒绝**: + + ninja: build stopped: 'pcm.cache/mcpp.version_req.pcm' not mentioned in + its dyndep file 'obj/version_req.cppm.ddi.dd' + +—— 报的是边,不是缺失的记录,指向的是无辜的一侧。 + +解法是 `mcpp dyndep --split-module`:给 BMI **和** primaryOutput 各写一条记录。 +不是"把目标改成 BMI" —— 两条边都要解析同一批 import,都需要同一批隐式输入。 +**不要去改扫描的 `-o`**:GCC 那边共用 `-o` 与 `-fdeps-target` 已经造成过 +"扫描去写还不存在的 `gcm.cache/`" —— **在 mcpp 自己的仓库上不暴露** +(那个目录早被上一次构建建好),换个全新工程立刻失败。 + +### 实测(pinned 源码 @ 8219584,clang 22.1.8) + +| 并发 | schedule=off | schedule=on | 比值 | +|---|---|---|---| +| `-j4` | 56.34s | **37.60s** | 1.50× | +| `-j8` | 34.00s | **25.55s** | 1.33× | +| `-j32` | 32.03s | **17.95s** | **1.78×** | + +前端跑两遍要多花 CPU,但**在试过的每个并发档位上都是净赢**,所以没有加核数门槛。 + +**正确性判据是产物而不是退出码**:两条臂的 **130 个目标文件逐字节相同**。 +(BMI 有 101 个不同 —— 两臂在不同的指纹目录下,BMI 里烙了输出目录的绝对路径; +这正是"对照放两个目录会让路径冒充差异"那条,所以 BMI 差异在这里不构成证据。) + +--- + +## 1. 四条杠杆的状态 + +| | 杠杆 | 状态 | 依据 | +|---|---|---|---| +| **L1** | 按次选择工具链 `--toolchain` | **已实施** | 实测 81.8 → **32.6s**(2.51×) | +| **L2** | 下游在 BMI 可用时即开始 | **已实施**(`bmi_schedule = "on"`,gcc + clang) | gcc 79.9 → **34.8s**(2.30×);clang 32.0 → **17.95s**(1.78×) | +| **L3** | 定义移出接口单元 | **不做** —— 已量出它治的是 L2 同一个病 | 实测:对 mcpp **−6.2%**,对 cmake +92.3% | +| **L4** | 拆 `build.prepare` | **已实施**(架构收益;性能上为零) | 实测:**0**,原因见下 | + +### L1:做成了「按次选择」,没有换默认 + +`mcpp build --toolchain llvm@22.1.8` —— 实测 **81.83s → 32.61s(2.51×)**,已达标。 + +**换默认**才是那个不能做的动作:它让所有已发布包的指纹失效(全生态一次性重编), +三平台 llvm 版本还不统一(Windows 20.1.7 vs Linux/macOS 22.1.8), +且牵涉 `-static-libstdc++` 与 libc++/libstdc++ 的 ABI 选择 —— 需要协调。 +**按次选择不需要任何人配合,收益却是同一个 2.51×。** + +⚠️ 它**不改变形状**:clang 下 makespan 32.20s / 关键路径 32.15s = 仍然 **100%**, +并行度 3.90×,与 gcc 完全一致。clang 只是每模块便宜 2.5 倍。**L2 因此仍然必要。** + +### L3:明确不进这个 PR + +L3 指的是**改 mcpp 自己的 138 个模块**——把定义从接口单元移到实现单元。 +它不是构建引擎的能力,而是**被构建工程的写法**。 + +**决定:待合入的 PR 不动 mcpp 源码的实现风格。** 理由: +本轮的目标是优化 **mcpp 的构建性能**(引擎能力),而"改被测工程的结构来提速" +是另一件事——它对所有用 mcpp 的工程都适用,却要求每个工程改写自己的代码。 +把两者混进同一个 PR,会让一次引擎改动挟带一次跨全库的风格变更。 + +**可以做的**:拉一个临时分支/PR,只为**量出具体收益**(推算是链 74.6s → ~10.4s), +测完即弃,不合入。收益数字回填到本文。 + +### ⚠️ 更正:L3 是单项收益最大的杠杆,不是"绕行方案" + +下面那段用**未标定的旧 fixture**得出"L3 只值 +8%",**那个数字不可信** —— +那份 fixture 每个 TU 有 74% 是编译器启动、`weight` 旋钮推不动成本(见 bench/README §1a)。 +用标定后的 fixture(`--preset standard`)重测的 2×2: + +| | `modules`(定义在接口) | `modules-impl`(定义移到实现单元) | +|---|---|---| +| **schedule=off** | 17.76s | **5.30s** | +| **schedule=on** | 12.45s | **5.00s** | + +* **L3 单独:3.35×** · **L2 单独:1.43×** · **L2+L3:3.55×** + +**它们叠加,但只叠一点点**,因为**两者治的是同一份浪费、只是从两头下手**: +L3 把 codegen 从接口单元搬走,L2 是不等那份 codegen。做了任何一个, +另一个就没多少可买。而**单项收益 L3 远大于 L2**。 + +⚠️ 注意 L2 在这里只有 1.43×,而在 mcpp 真实源码上是 2.30× —— +fixture 单元的 codegen/parse 比例与真实模块不同,**不要跨工作负载搬运比值**。 + +**所以"极致性能"的答案是:引擎侧 L2 + 工程侧 L3,而 L3 是更大的那一半。** +L3 仍然不进这个 PR(它改的是被构建工程的写法),但它的定位从 +"给没有 L2 的引擎准备的绕行方案"更正为**最有效的单项优化**, +文档提示应当据此改写。 + +### 旧的分析(基于未标定 fixture,保留作为对照) + +不用拉分支:bench 的 `modules-impl` 变体测的**正是** L3(定义写在接口单元 vs 移到实现 +单元),数据已经在 `bench/results/five-way-20260812/` 里。同一 fixture、同一编译器: + +| 场景 | mcpp:modules → modules-impl | cmake:modules → modules-impl | +|---|---|---| +| cold (gcc) | 3.53 → 3.25s **(+7.8%)** | 13.05 → 12.80s (+2.0%) | +| cold (clang) | 2.50 → 2.19s **(+12.3%)** | 4.00 → 3.96s (+0.9%) | +| **edit-body (gcc)** | 0.29 → 0.31s **(−6.2%)** | **10.29 → 0.79s (+92.3%)** | +| edit-body (clang) | 0.46 → 0.31s (+32.5%) | 2.62 → 0.42s (+84.0%) | + +**看 `edit-body` 那两行。** 把定义移出接口单元,给 cmake 带来 **92%** 的提升, +给 mcpp 带来 **−6%**(即没有)。原因是同一个:改函数体时接口没变, +cmake 按 BMI 的 mtime 级联,mcpp 比 BMI 的内容。**L3 是给没有 L2 的引擎准备的绕行方案。** +引擎做了这件事之后,工程再去重构,在这条轴上什么都买不到。 + +真正留下来的是 **cold 上 +8%~12%** —— 接口单元变薄,关键链就变短。这是真的, +但它是"顺手的好设计",不是一条值得为性能去改 138 个模块的理由。 + +**所以 L3 的文档提示是:先要引擎的 L2,再谈重构。** 顺序反了会做很多白工。 + +### L4:已实施,而且实测收益为零 —— 这一点比数字本身重要 + +抽出 `mcpp.build.prepare_inputs`(341 行,cfg() 谓词 + 指纹规范化), +`prepare.cppm` 6521 → 6186 行,两个函数 re-export 所以调用方零改动。 + +**构建时间没有变化**(off 79.23s / on 34.54s)。原因在拆分前就分析出来了, +实测只是确认:**抽出来的东西成了 prepare 的依赖,链只会变长不会变短** —— +`… → prepare_inputs → prepare → …` 仍然串行,prepare 少掉的成本正好由新模块付掉。 + +要缩短关键路径,抽出的部分必须是 prepare 的**兄弟**(被 prepare 的**导入者**直接用)。 +已查清:configure 只用 `BuildContext`,execute 用 `BuildContext` + `prepare_build`, +其余只用 `prepare_build`;而链上是 prepare → execute → configure, +execute 离不开 `prepare_build`,所以抽类型也没人能离开这条链。 +真正有效的是拆 `prepare_build` 本身。 + +而且 **L2 落地后这件事的收益又小了一截**:一个接口现在只阻塞导入者约 22% 的编译, +不是全部。所以这次拆分按**架构**理由留下(6500 行的模块本就该拆),不按性能理由。 + +### 这条界线 + +⚠️ **这是本轮最重要的一条界线。** 「优化 mcpp 的构建性能」指的是**通用构建性能**, +不是把 mcpp 这一个工程调快。**通过改被测目标来变快,不能算数** —— +它对别人的工程一点用都没有,而且会让基准失去意义。 + +L2 是通用的:引擎侧的 2.30× / 3.38× 在**两个互不相关的工程**上都成立, +不要求任何人改写自己的代码。L3/L4 只对被改写的那个工程生效。 + +**所以 L3 和 L4 都不进这个 PR**,它们降级为**文档里的提示**: +想更快的工程可以这么做,收益在临时分支上量、量完即弃,数字回填到本文。 + +两者仍然不是同一类动作,区别记在下面: + +* **L4 是架构改动** —— `build.prepare` 6521 行、16.4s、占关键链 22%,是唯一的真离群点。 + 形状比"把大文件拆小"苛刻得多: + + ⚠️ **把一部分抽成 prepare 的依赖,是让链变长而不是变短。** + `… → 新模块 → prepare → …` 仍然串行,只是多了一跳;prepare 少掉的那点成本 + 被新模块自己的成本抵掉。抽出来的东西必须是 prepare 的**兄弟** —— + 被 prepare 的**导入者**直接使用,才能与 prepare 并行编译。 + + 实际调查(谁 import prepare、用了什么): + + configure.cppm 只用 BuildContext(一个类型) + execute.cppm 用 BuildContext + prepare_build + doctor / pack / cli.cmd_build 只用 prepare_build + + 所以把 `BuildContext` 抽成叶子模块能让 `configure` 不再依赖 prepare —— + **但链上是 prepare → execute → configure**,而 `execute` 需要 `prepare_build`, + configure 仍在 execute 之后。**净收益为零。** + + 真正能缩短关键路径的是**拆 `prepare_build` 本身**,让 `execute` 只依赖它的一部分。 + 那是对一个 6521 行模块的深度重构,不是一次抽取,需要单独立项 —— + 而且按上面那条界线,它属于**工程侧建议**,不属于本轮的引擎优化。 +* **L3 是实现风格改动** —— 把定义从接口单元移到实现单元,要动 138 个模块。 + 它对代码的组织方式提出要求,而收益只对改写了的那个工程生效。 + **不做**;要量收益就在临时分支上测,测完即弃。 + +⚠️ **两者都不得触碰 bench 钉住的那份基准 mcpp 源码**(`bench/projects/mcpp/` 指向的 +被测树的快照)。那份源码是**测量基准**:改了它,前后两次测量就不再是同一个工作负载, +所有比值失效。 + +**L2 与它们的区别**:引擎侧的 2.30× 对**每一个** mcpp 工程都生效, +不要求任何人改写自己的代码;L3/L4 只对被改写的那个工程生效。 + +### L2 做到哪里 + +**已实施并验证通过**的部分: + +* `src/build/schedule/policy.cppm` —— 纯函数决策表(每个编译器一种机制), + 7 条单测两侧钉死;`requested_switch` 是唯一读开关的地方。 +* `src/build/schedule/detach_codegen.cppm` —— gcc 的运行期。 + **实测:阶段一在 2.30s / 16.15s = 14% 返回,目标文件正确,两阶段 rc=0。** +* 可观测性:`# mcpp:graph=normal;schedule=detach-codegen` 写进图头, + `--verbose` 打印决策理由。 +* 失效靠**指纹**而不是守卫:换调度就换构建目录,旧形状的图结构上不可达。 + +**已全部接上。**(下段保留当时的记录,因为两个失效点值得留证) +历史记录 —— 曾经未接上的:图的拆分发射。它需要按 §2.3/§2.4 把 BMI 边的 `depfile` 接到 +P1689 扫描的产出上;第一版发射(未提交)会让 `mcpp build` **段错误(rc=139)**, +而**同一棵树上不含它的二进制 rc=0** —— 这一点是逐条复现出来的, +先前把整批基础层当成元凶是错误归因。 + +`auto` 现在是 **off**:调度改错是**静默**的(漏掉一条头文件依赖不会报错, +只会不再重编),不该凭一台机器的结果成为默认。 + +--- + +## 2. 已经钉死、下次不必重走的四件事 + +这些是本轮最有价值的产出,全部有实测支撑: + +1. **GCC 原子发布 BMI,clang 不是。** + strace:GCC 写 `.gcm~` 再 `rename()`;clang 以 `O_TRUNC` **直写最终路径**。 + ⇒ 「看文件出现」对 GCC 成立、对 clang **不成立**(会读到写了一半的 `.pcm`)。 + +2. **两种机制互补,不是二选一。** + clang 有便宜的 BMI-only 调用(实测 `src/build/prepare.cppm`:**1.67s vs 7.35s**); + GCC 没有(`-fmodule-only` 要 **99%** 的时间 —— 它不跳过后端,只是不写目标文件), + 但 GCC 原子发布 BMI 而 clang 不。**装反是静默的。** + + ⚠️ 早先这一条写的是「`--precompile` 0.78s / `-c` 自 pcm 0.70s,总 CPU 只多 9.6%」。 + **那条路走不通**:`--precompile` 发的是 *full* BMI(见 §0b 坑 1)。真实数字是 + 1.67s + 7.31s ≈ **多 22% CPU**,object 边重编源码而不是读 BMI。 + +3. **depfile 在 BMI 之后写出。** + 实测:depfile 16.39s,BMI 2.36s,整条编译 16.55s。 + ⇒ 拆分后的 BMI 边**不能**用编译器自己的 depfile(边结束时它还不存在); + 挂到对象边则头文件变更时 `bmi-await` 立刻返回、**什么都不重编**。 + 两种朴素挂法都会**静默丢掉头文件跟踪**。 + +4. **P1689 扫描的 depfile 是可用的替代来源。** + 实测对照:它相对编译的 depfile **只缺 `.gcm`**(那是 dyndep 在管的), + **头文件全覆盖**;且 `cxx_scan` **没有**声明 `depfile`,ninja 不会消费掉它。 + ⇒ 这条路是通的,只是还没接上。 + +--- + +## 3. 顺带修掉的:`mcpp test` 热跑 189.7s → 2.15s(88×) + +不属于构建引擎,但属于同一个问题的同一种病 ——「每次都重做一件上一次已经做完的事」。 + +用户报「每次运行单元测试都很慢,是不是没有并行测试功能」。**先量,分解就把前提否掉了**: + + finished in 189.74s (build 187.70s + run 1.78s) + +83 个测试的**运行阶段只有 1.78s**。「没有并行执行」属实,但它不是慢的原因。 + +给 `NinjaBackend::build` 加了分段计时(`-v` → `build/stage:`,留在代码里),一次热跑: + + loader-tags total=158701ms calls=85 <-- 98% + ninja total= 575ms calls=85 + compile-commands total= 443ms calls=85 + runtime-validate total= 160ms calls=85 + emit-ninja total= 88ms calls=85 + +没有这个分解只能猜 —— 我先后猜过 emit_ninja / compile_commands / hermetic,全错 +(三者合计 < 15ms)。 + +三处修复: + +| # | 改动 | 收益 | +|---|---|---| +| 1 | rule E(`check_and_record_loader_tags`)只解析 stat 变了的产物;没变的从 `resolution.json` 把判定**读回来** | 189.7s → 5.3s | +| 2 | `-k 0` 批量构建成功 ⇒ 跳过每个测试的复驱动(~39ms × 83) | 5.3s → 3.9s | +| 3 | 并行跑测试(>1 个时捕获输出、整块打印;=1 个时保持前台流式) | 3.9s → **2.15s** | + +⚠️ **1 的回归测试形状**:便宜的写法(跳过没变的就完事)会让记录缩水成「这次重链了 +什么」,于是「记录为空」和「全部合规」长得一模一样 —— 正是 rule E 存在的理由。 +**纯 noop 抓不到它**(noop 时什么都不重写,坏记录也完好);要**只碰一个目标的源码** +(一个重链、一个不重链)。e2e 214 按这个形状写,并按错误实现验过先红。 + +⚠️ **3 为什么不是无脑并行**:多个测试直接流式输出会逐行交错,失败的断言变得无法归属 —— +而归属正是那个循环存在的理由。所以 >1 时捕获、结束时整块打印;=1 时保持流式,因为 +那是调试场景,挂住的时候尤其需要实时输出。 + +--- + +## 4. 本 PR 当前包含什么 + +引擎侧: + +* **L1** `--toolchain SPEC` / `MCPP_TOOLCHAIN`:按次选工具链,不动 manifest、不动指纹。 +* **L2** `bmi_schedule = "on"` / `MCPP_BMI_SCHEDULE`:gcc `detach-codegen` + clang `two-phase`, + 决策集中在 `src/build/schedule/policy.cppm`,运行期在 `src/build/schedule/`。 + 默认 `auto` = off。 +* **L4** 抽出 `src/build/prepare_inputs.cppm`(架构收益,性能为零 —— 见 §1)。 +* `--jobs N|auto`。 +* **BMI 等价性判断改用 `mcpp bmi-equal`**,替代永远不可能成功的 `cmp -s` + (GCC 把时间戳写进 BMI 内容)。真实工程实测: + `touch-hub` **84.53s → 0.44s**(对 cmake **192×**,对上一版 mcpp **174×**)。 + ⚠️ `edit-body` 无提升(18.29s vs 18.30s)且**这是对的** —— + 改函数体确实改变 BMI,级联是必需的。这一行是区分 + 「避免不必要的工作」与「避免工作」的对照。 +* `mcpp test` 的三处提速(§3)。 + +其余是 bench 套件、规范与数据(见 `bench/README.md`、`bench/results/`)。 + +## 5. CI 与合入 + +* `curl: (52) Empty reply from server` 那一类红是引导下载失败,12 秒内即挂、与代码 + 无关。判据:失败 job 的日志里没有任何测试名,只有 curl 的退出码。已由 + `.github/tools/fetch_release.sh`(`--retry-all-errors` + 归档校验)根治。 +* ⚠️ **更正:此前这里写的是「反复出现的红**全部**是下载失败」,那是错的。** + bench 那条线上真正的问题是**没有红** —— 见 §7。把所有说不清的红都归给网络, + 正是让那件事多存活了几周的原因。 +* **未合入**,按要求。 + +## 7. bench 套件审计:一整条绿色的 CI 什么都没有测 + +用户报「bench 卡住而且没有进度」。查下去发现卡住只是最表层的症状。 + +**`bench (macos/clang/fixture)` 报成功,实际是 6 ok / 48 failed / 18 unavailable**; +唯一过的 6 个格子全是 cmake 的 `headers` 变体。三个 xlings job 报成功,**一个测量 +都没有**。这个状态持续了数周。 + +### 六个互相独立的真因 + +| # | 缺陷 | 为什么没被发现 | +|---|---|---| +| 1 | 每个引擎拿到的编译器不同 —— CI 用 `command -v g++` = runner 的 gcc 13.3.0,而 mcpp 用自己 registry 的 16.1.0 | cmake 配不出 modules、xmake 把 gcc 编崩,都被记成对引擎的「真实发现」 | +| 2 | 构建工具版本随 runner 漂移(镜像自带 cmake 3.31.6,没有 4.0 的 `import std` 键) | 同上 | +| 3 | 被测工程运行时从默认分支 clone —— `--hub src/xlings.cppm` 早就不存在 | 每个格子报 `skipped`,harness 退出 0 | +| 4 | `--hub`/`--body` 按 harness 的 cwd 解析,不是按工程目录 | 只有「测你正站着的树」时才对,即 mcpp 测自己 | +| 5 | harness **永远返回 0** | 「测到了东西」这件事从来没有被断言过 | +| 6 | xmake 的 `--buildir` 相对 `-P` 解析,`clean()` 删的是另一个目录 | `cold 0.60s` 状态 `ok`、带样本 —— **每一个 xmake 真实工程 cold 数字都是假的** | + +### 一条贯穿的形状 + +**每一个都是「失败看起来像成功」,而不是「失败没被处理」。** 套件的协议不变量 1 +写着「失败不得看起来像测量」,但它只覆盖了单个 cell 的 `status` 字段 —— 没覆盖 +退出码、没覆盖「量到的是不是真的那件事」。 + +现在补上的断言,按发现顺序: + +* `failed` 或「一个 ok 都没有」⇒ 非零退出;已知缺口写进 `allow_failed` 且必须带 + `KNOWN GAP` 说明(守卫检查)。 +* `cold` 必须大于同引擎同 variant 的 `2 × noop` —— 否则它没重建。**不是性能阈值**, + 是内部一致性。 +* `hub`/`body` 必须在钉住的树里真实存在(靠子模块才可检查)。 +* 工具版本必须是精确版本;`reference_mcpp` 必须等于 `.xlings.json` 的 bootstrap pin。 +* 扰动**形态**写进 note —— `edit-comment` 在有函数体的单元里插注释(行号全移、BMI + 真的变了、级联是对的)和在没有函数体的单元末尾追加(什么都没动)是两个不同的 + 问题,而套件用一个名字同时回答了它们。 + +### 可观测性(用户最初报的那件事) + +* 进度实时打到 stderr 并逐行 flush; +* 每条 configure/build 有超时(默认 1800s),超时 kill 并报 `TIMED OUT after Ns`; +* 失败时直接打出子进程日志尾部; +* 子进程日志改为**追加**、由 runner 每个 cell 清空一次 —— 此前计时构建那一行 + `build ok, spent 0.111s` 会把前面 configure 的输出整个擦掉,这正是 xmake 那条 + 0.60s 一开始无法诊断的原因。 + +### 这批数字里最该记住的一条 + +`edit-comment` 在 mcpp 自己的工程上是 **199×**,在 xlings 上是 **1.00×**。 +不是优化时灵时不灵 —— 是 mcpp 的 hub 恰好没有函数体。**mcpp 测自己永远看不到 +这件事**,这就是独立控制目标存在的全部理由。 + +## 6. 下一步(按顺序) + +1. **`auto` 是否翻成 on**:这是发布决策不是技术缺口 —— 指纹里带了 schedule, + 翻默认会让所有已发布包全量重建一次。等三平台 CI 见过 `on` 之后再单独立项。 +2. **msvc**:`/ifcOnly` 的代价与 `.ifc` 是否原子发布都还没测。猜错是静默的 + (半个 BMI 不是诊断,是编错),所以保持 `None`。 +3. **L3 作为书写约定**:优先施加于链上那 19 个模块(见 §1),不回改存量。 +4. **换默认工具链**单独立项(生态决策,见 §L1)。 + + +## 8. ⚠️ L2 有一个未修好的正确性缺陷 —— 现阶段不应推荐开启 + +`bmi_schedule = "on"` 在**增量重建**上会让导入者撞到不存在的 BMI: + + failed: gcm.cache/fx.unit_1.gcm + fx.unit_0: error: failed to read compiled module: No such file or directory + fx.unit_0: note: imports must be built before being imported + +**复现**(生成的 fixture,modules variant,四个场景稳定失败): + + bench --engines 'mcpp[schedule=on]=' --variants modules \ + --scenarios touch-hub,touch-leaf,edit-body,edit-comment \ + --preset standard --runs 2 --compiler payload:gcc + +### 已经确定的 + +* **不是编译器之间的竞态**:`-j1` 一样复现。 +* **窗口是设计带来的、而且很大**:phase 1 在 spawn 编译器**之前**就把旧 BMI + rename 进 `.bak`,直到编译器发布新的为止,这个模块在磁盘上**没有 BMI**。 + 实测一次增量重建中该文件消失约 **208ms**(2ms 采样 × 104 次命中)。 +* **失败模态是安全的那一种**:该路径下 BMI 是**缺失**而不是**陈旧**,所以永远 + 是响亮的失败,不会产出一个「成功但错误」的构建。这一点是量出来的,不是希望。 +* 真实工程(mcpp 自己、xlings 两种风格)上没有复现 —— 只有 fixture 的紧密 + unit_0→unit_1 链会撞上。**这就是合成 fixture 的价值**,我此前把它当成 + 「不如真实工程可信的那一半」,是错的。 + +### 已经修掉但**不是**本缺陷成因的 + +`compile_release_at_bmi` 的 `read_rc` 分支返回成功却从不 `settle_bmi`(见 +`8c9f239`)。它确实是个真缺陷 —— 上一份 BMI 一直停在 `.bak`,而且那个单元的 +**等价性检查从未运行**,也就是说级联抑制对最便宜的那些单元是静默关闭的。修完 +之后 `.bak` 残留归零,**但四个场景照样失败**。 + +### 还没搞清楚的 + +在 `-j1`、且 dyndep 明确写着 `unit_1.gcm: dyndep | unit_0.gcm` 的情况下,导入者 +为什么仍然会在那 208ms 的窗口里被 ninja 调度。下一步应当是 `ninja -d explain` +配合边级时间线,而不是继续静态推理 —— 这一条我已经猜错过一次。 + +### 因此 + +* **`auto` 绝不能翻成 on**,直到这条修好; +* 所有已发布的 `bmi_schedule` 数字都是在缺陷存在时取的,README 里已标注不可引用; +* 修法方向:要么让 BMI 在整个重建期间保持可读(先编译到临时路径、成功后再原子 + 替换,而不是先把旧的挪走),要么让导入者的边真正等到 BMI **重新发布**之后。 + 前者更像是对的 —— 「先移走再重建」本身就在制造一个不存在的中间态。 + + +## 8b. 第四次尝试之后:把 L2 从 CI 里撤出,并说明现状 + +**已修好的两件事(独立成立,与下面那条无关):** + +* `compile_release_at_bmi` 的 `read_rc` 分支返回成功却不 `settle_bmi` —— + 上一份 BMI 停在 `.bak`,而且那个单元的**等价性检查从未运行**,级联抑制对最便宜 + 的单元静默关闭。 +* 编译失败时不再把单元留在「完全没有 BMI」的状态。 + +**「导入者读不到 BMI」已经修好。** 原设计在 spawn 编译器**之前**就把旧 BMI +`rename` 走,于是模块在磁盘上有约 208ms 没有 BMI(实测)。改成**复制**一份到 +`.bak`、原件留在原地,并用「文件身份(size+mtime)发生变化」而不是「文件存在」 +来判断发布。`failed to read compiled module` 不再出现。 + +⚠️ 中间踩的两个坑,都值得记住: +* `std::filesystem::file_size(p, ec)` 失败时返回 `(uintmax_t)-1`。我在检查 `ec` + **之前**就把它写进结构体,于是「文件不存在」与默认构造的哨兵不相等 —— phase 1 + 第一次轮询就认为「变了」,在编译器产出任何东西之前返回,所有 object 边报 + `no compiler was started … phase 1 did not run`。 +* `copy_file` 给副本盖的是**拷贝时刻**的 mtime。而 `settle_bmi` 恢复这份副本正是 + 为了让 mtime **不前进**、让 ninja 的 restat 掐断级联。不显式把原 mtime 带过去, + 恢复反而把 mtime 推前 —— `touch-hub` 变成 12.61s(冷构建 12.37s), + **六个格子全报 `ok`**。状态列抓不到这个,只有数字能。 + +**~~仍然没修好的~~ 已于 2026-08-14 修好。** 上一版这里写的方向是对的,并且就是最终 +的修法:BMI 的 restat 抑制不能连带抑制**这个单元自己的 object 边**。 + +`ninja_backend.cppm` 里 object 边原本是 + + build : cxx_module_obj # 唯一输入是 BMI + +现在是 + + build : cxx_module_obj | + +源码作为输入给了它一个 restat 清不掉的「脏」的理由;BMI 降为隐式输入,顺序不变 +(`bmi-await` 不能跑在 phase 1 之前)。 + +**⚠️ 但真实症状比这里记的严重得多,而且我记错了它的形态。** 上一版把它记成 +「挂在链接上」,于是它读起来像 fixture 特有的边角情况,整整一周没人再看。实际的 +一般形态是**静默产出错误的二进制**: + + export int leaf_value() { return 1; } // 改成 42,重建 + + Finished dev in 0.02s <- 报成功,8 条边只跑了 3 条 + ./repro -> 1 <- 源码写的是 42 + +`undefined reference` 只是「符号原本就不存在」时的特例。**记录一个缺陷时,记最一般 +的形态,不要记你第一次撞见的那个** —— 后者会让所有人低估它。 + +**⚠️ 它还污染了已发布的数字,这一点当时完全没人察觉。** 被跳过的 object 边与链接是 +构建欠下的工作,所以那一列量的比一次构建少。`bmi_schedule=on` 的 +`touch-hub 0.22s` / `edit-comment 0.18s` 在修复后是 **0.44s / 0.44s**,比默认档还略 +慢 —— 因为那两行本来就没有级联可省。`cold`(35.43→36.36)与 +`edit-body`(30.17→30.48)不受影响,头条结论成立。 + +**⚠️ 我为这件事写的守卫失败了,记在这里比记成功更有用。** 思路是:轮询引擎写入的 +那棵树,引擎退出后还有文件落盘就判定「构建只是返回了、并没有结束」。三次尝试: +(1) 看错了目录 —— `job.build_dir` 是给 cmake/xmake 的 `-o`,mcpp 写的是 +`/target`;(2) 窗口取 300ms,而要检测的**尾巴本身就是一次编译**,实测 +1.24s 才落盘;(3) 改成轮询到稳定、上限 3s、按引擎声明的产物目录 —— 诊断确认它 +**确实在跑、确实拿到了基线**,但对仍带缺陷的二进制在本套件的 touch-hub 流程里 +不触发。于是撤掉:**一条无法被证明能抓到目标的守卫,和没有守卫无法区分。** + +顺带订正我自己的一句话:我先前把「已发布数字量的是没跑完的构建」当成结论写进了 +README。手工重建确实能观察到 `mcpp build` 0.56s 返回、`cc1plus` 仍在跑、object +1.24s 后落盘;但在 harness 自己的流程里复现不出来。把一次手工观察当成对已发布 +数字的解释,是过度归因 —— 能站住的只有「object 边和链接被 restat 清掉了」。 + +**因此 CI 里暂时不跑 `+schedule=on` 这条臂。** 修好之后可以放回去;门槛仍是 §8 的 +复现全绿。 + +**这条 bug 我连错五次**(误诊 settle_bmi、哨兵不匹配、mtime 没带过去、object 边、 +以及把症状记成了链接错误)。下一个人应该从「object 边与 BMI 边的 restat 语义不同」 +开始。 + + +## 9. bench 跑起来之后暴露的两个**与 bench 无关**的既有缺陷 + +这两个都不是这次改动引入的 —— 是矩阵此前根本没在测,所以从来没人看见。 + +### 9a. macOS 上 mcpp 编不了依赖的 `build.mcpp` 助手 + +`bench (macos/clang/xlings-2026.8.13.1)`: + + error: dependency 'xpkg': build.mcpp failed to compile (exit 1): + dyld[21445]: Symbol not found: __ZdaPv + clang++: error: unable to execute command: Abort trap: 6 + +`__ZdaPv` 是 `operator delete[](void*)`。助手链接过了,运行期找不到 libc++。 +与 [[build-mcpp-helper-self-containment]] 同一类问题(glibc 靠 rpath、musl 与 PE +才需 `-static`),但 macOS 这条此前没有覆盖。 + +**影响面比 bench 大**:任何在 macOS 上依赖带 `build.mcpp` 的包的工程都会踩到。 + +### 9a-2. 同一个机制,更大的面:macOS 上**每个**子进程都被污染 + +不只是 `build.mcpp` 助手。bench 的 macOS 格子里 cmake / bazel / 参照 mcpp +**三个引擎一起**挂在同一处: + + dyld: Symbol not found: __ZdaPv + Referenced from: …/XcodeDefault.xctoolchain/usr/bin/ld ← Apple 自己的链接器 + Expected in: …/registry/…/lib/libc++.1.0.dylib + +**Apple 的 `ld` 自己就链 libc++**,而 registry 的那份 libc++ 进了动态加载器的搜索 +路径,于是 `ld` 还没开始链接就 abort。判据:**被测的 mcpp 那条臂在同一次运行里 +全绿** —— 三个引擎同样地挂、一个不挂,说明问题在环境而不在任何一个引擎。 + +⚠️ **把 macOS 上的 payload libc++ flag 全部去掉之后,它照样发生。** 所以污染不是 +经由链接 flag 进来的,而是经由 `DYLD_*`(很可能是 mcpp/xlings 为了让自己的载荷 +二进制跑起来而设的),然后被所有子进程继承。 + +macOS 的 bench 格子因此进 `excluded`,原因写在 matrix.json 里。**没有继续猜** —— +本机是 Linux,复现不了,而这条已经让我烧掉好几轮 CI。 + +### 9a-3. 根因与修法(2026-08-16,已修) + +上面两条把现象记全了,也记了"没有继续猜"。issue #437 把最后一步补上了 —— +**污染是真的,但可以绕开:不要用那个会被污染的链接器。** + +根因链(对着当前源码核过): + +1. `build_program.cppm:154` 把 host helper 的 `cfgBypass` 设成 `LinuxOnly`; +2. 于是 macOS 上 `bypassCfg == false`,`hostflags.cppm` 命中 + `else if (dm.hasCfg) return out;` —— **返回空 link token**,没有 `-fuse-ld=lld`; +3. clang++(payload)于是用默认链接器 = Xcode 的 `/usr/bin/ld`; +4. 那个 `ld` **自己就是链 libc++ 的 Mach-O**,而它跑在 payload 工具链设好的 + `DYLD_*` 里,dyld 把它的 libc++ 解析到 payload 那份 —— 缺 `__ZdaPv`, + **ld 还没开始链接就 abort**。 + +**为什么主构建不挂**:`flags.cppm` 的 macOS 分支**刻意**用 `-fuse-ld=lld`, +注释原文就写着 *"Xcode 15.4's ld aborting at launch on macos-14 CI when its +libc++ resolution was diverted"*。也就是说 —— **同一个坑,主构建早就绕过去了, +host helper 这条路没跟上。** + +> 判据上这一条最说明问题:**mcpp 用 llvm 能把整个工程构建出来,却构建不了 +> 自己的 `build.mcpp`。** 同一个工具链、同一个环境,两条链接路径给出不同结果, +> 那不是"macOS 环境问题",是这两条路径没有对齐。 + +**修法(已实施)**:`hostflags.cppm::host_link_tokens` 的 trust-cfg 分支在 macOS 上 +追加 `-fuse-ld=lld`。cfg 选的是 runtime,从来没选过链接器;lld 随做编译的那套 +工具链一起发布,不可能被解析到它没链过的 libc++ 上。 + +**没有配单元测试**:判断在 `if constexpr (is_macos)` 里,在 Linux 上整段编译掉, +测试不可能失败。判据是 macOS CI 的 e2e(`89/92/110/111/125/143/144/145/181/186/194` +都带 `build.mcpp`)与 issue 报告者的复现。 + +### 9b. mbedtls 的 registry 源码包缺 `framework/` 子模块 + + mbedtls-3.6.1/CMakeLists.txt:304 + framework/CMakeLists.txt not found. + Run `git submodule update --init` from the source tree. + +挡住的是 xlings cmake arm 的最后四分之一(ftxui / libarchive / lua 三个已经接好、 +能用)。注意 **mcpp 自己构建 mbedtls 不会踩到**,说明 mcpp 走的根本不是 mbedtls +的 CMake 路径。 + +解法有三条,都需要决策而不是我单方面选:vendor 那个不大的 `framework` 目录、 +改走 mbedtls 的 Makefile、或者让 registry 把完整树打进包里。 + +## 10. CI 的核心 job 全红 —— 根因是 clang 22 的模块误编译,不是环境 + +**判据(同一份分支代码,只换编译器):** + +| 编译器 | `mcpp test` | +|---|---| +| clang 22.1.8(仓库默认) | `unit/test_elf_runtime` **SIGSEGV**,82 passed / 1 failed | +| gcc 16.1.0 | `unit/test_elf_runtime ... ok`,**83 passed / 0 failed** | + +backtrace 落在 libc++ 的 `__assign_with_sentinel`,调用方是 +`mcpp::platform::elf::inspect_elf_runtime@mcpp.platform.elf_runtime` —— +**那个文件本分支一行没改**(`git log origin/main..HEAD -- src/platform/elf_runtime.cppm` +为空),而 main 用同一个 clang 是 80 passed / 0 failed(在独立 worktree 里实测, +不是看 CI)。也就是说:别处的新增改变了这个函数的代码生成。这与仓库里已记录的 +`clang-modules-unused-fn-miscompile` 是同一类现象。 + +崩的那个用例叫 `RejectsUnsupportedOrTruncatedElfWithoutGuessing` —— 它只对一个 +9 字节文本和一个 6 字节文件调 `inspect_elf_runtime`,而头部检查 +(`bytes.size() < 0x40`)本该直接挡住。 + +### 定位它花了多少弯路,以及为什么 + +**转折点是给 CI 加了一个 `continue-on-error` 的 verbose 探针**,它把崩溃钉在 +`stage("runtime-validate")` **打印之前** —— 即 `validate_changed_artifacts` 内部。 +顺着这条线才在本地找到那个稳定复现的单测。 + +在此之前逐条否掉的七个假设(每条都有实验,不是推理): + +1. `--jobs auto` 导致 OOM —— `mcpp.toml` 根本没设 `jobs`,默认不走该路径 +2. xmake 3.1.0 —— 切过去后 fixture 本地 1 ok / 0 failed +3. mcpp-index 变动 —— 最后一次提交 08-12,不在窗口内 +4. xim 索引载荷 —— 近期无 llvm/glibc 变动 +5. 容器镜像 —— 在同一个 `debian:stable-slim` 里精确复现 CI 那一步:`hello 195`,rc=0 +6. 全新空 registry(强制重下全部载荷)—— rc=0 +7. 「这是本分支的代码缺陷」—— 被同一批日志否掉:`toolchain: gcc` 里崩的是**已发布的 + 2026.8.11.3**,hermetic 里崩的是本分支构建的,两个版本都崩 + +⚠️ **本来可以早几个小时定位。** 我在很早就记录过「本地 `mcpp test` 有 1 个失败: +`unit/test_elf_runtime`」,当时把它当孤立小问题;它和 CI 的 `exit 139` 是**同一个 +信号、同一个子系统**。一个和 CI 症状同信号的本地失败,永远值得先连起来看。 + +⚠️ 还有一个我一度当成证据的错误推理:「文档提交(经核实只有两个 markdown、38 行) +让 9 个核心 job 全红 ⇒ 必是环境问题」,以及后来的「重跑能复现 ⇒ 确定性 ⇒ 不是环境」。 +后一步是错的:**确定性失败同样可以来自一个已经变了的外部状态**。真正的解释是第三种: +缺陷一直在,变的是**构建状态是否让那条代码路径被走到**(缓存命中 vs 冷构建)。 + +### 触发点:一行混进提交的工具链变更(我自己造成的) + +`git bisect`(判据=该单测是否 SIGSEGV)指向 `f51e6ab`,而它对 `mcpp.toml` 的改动是: + + [toolchain] + -default = "gcc@16.1.0" + +default = "llvm@22.1.8" + +**这一行与那个提交的主题(xmake 臂、libarchive 覆盖包)毫无关系,提交说明里一个字 +都没提** —— 是 `git add -A` 扫进去的。它把 mcpp 自身的默认工具链从 gcc 换成了 +clang,于是每一次 CI 构建都撞上 clang 22 的模块误编译。 + +这也解释了那个「纯文档提交让 9 个核心 job 全红」的怪事:**工具链早在它的上一个 +提交就被换掉了**,文档提交只是第一个跑完整套核心 job 的提交。我当时的推理 +「文档提交不可能弄坏构建 ⇒ 必是环境问题」前半句是对的,但我从没想到去查**前一个 +提交里混进了什么**。 + +改回 `gcc@16.1.0` 后:`83 passed; 0 failed`。 + +⚠️ **教训:`git add -A` 会把无关改动带进一个主题明确的提交。** 这次带进去的是 +一行工具链切换,代价是几小时的排查,而排查方向一直被"提交说明说它只改了 bench" +误导。提交前看 `--stat` 里有没有主题之外的文件,是最便宜的防线。 +CI 里那个诊断探针(`ci-linux-e2e.yml` 的 "diagnose: verbose trace")已在根因确认后 +删除 —— 它的使命就是把崩溃钉在 `stage("runtime-validate")` 打印之前,做到了。 + +**修复确认**:改回 gcc 后,先前全红的六个核心 job(build+unit tests / toolchain: gcc / +hermetic e2e / integration / toolchain: musl+llvm / cross-build aarch64)在 CI 上 +**全部转绿**。 diff --git a/.agents/docs/2026-08-13-build-performance-architecture.md b/.agents/docs/2026-08-13-build-performance-architecture.md new file mode 100644 index 00000000..a92ae23c --- /dev/null +++ b/.agents/docs/2026-08-13-build-performance-architecture.md @@ -0,0 +1,274 @@ +# mcpp 构建性能:架构层面的分析与方案(2026-08-13) + +目标:`mcpp clean && mcpp build --release` 从 **79.9s** 降到 **50s 以内**。 + +本文只用实测数字。每个结论后面都注明它是**测出来的**还是**推算的**,推算的给出推算方式。 + +--- + +## 0. 基线 + +| | | +|---|---| +| 工程 | mcpp 自身,138 个模块接口单元 + `src/main.cpp`,57k 行,全部 `import std;` | +| 主机 | 13th Gen Intel i9-13900K,32 逻辑 / 24 物理(异构),64 GiB | +| 工具链 | `gcc@16.1.0`(mcpp 自带载荷),release = `-std=c++23 -fmodules -O2` | +| 冷构建 | **79.92s** | + +对照(同机、同源码,构建描述在 `bench/projects/mcpp/`): + +| 引擎 | 冷构建 | +|---|---| +| mcpp | 80.0s | +| cmake 4.0.2 + ninja | 94.5s | +| xmake v3.0.7 | 94.6s | + +**四个引擎都在 15% 以内。** 这说明瓶颈不在调度实现,而在所有引擎共有的那个东西 —— 图的形状。 + +--- + +## 1. 结构性事实:100% 关键路径 + +`bench --analyze`: + +``` +edges : 426 +makespan : 79.79 s +work (sum dur) : 314.08 s +avg parallelism: 3.94 x (of 32 hw threads) +critical path : 79.73 s = 100% of makespan +``` + +**关键路径等于墙钟本身。** 32 个硬件线程上平均只跑到 3.94 路并行。 + +直接推论,每一条都实测印证过: + +* **加核无效。** `-j4/8/16/32` = 101.3 / 81.0 / 80.0 / 79.9s。从 8 到 32 提升 1.4%。 +* **分布式编译无效。** 关键路径是串行依赖,不是资源不足。 +* **换引擎无效。** cmake/xmake 走同一条链,差异 ≤ 18%。 + +**换了编译器,形状也不变。** clang 下重测: + +``` +makespan : 32.20 s ← gcc 79.79 s +work (sum dur) : 125.49 s ← gcc 314.08 s +avg parallelism: 3.90 x ← gcc 3.94 x +critical path : 32.15 s = 100% of makespan +``` + +clang 不是调度得更好,而是**每个模块便宜 2.5 倍**;100% 关键路径、3.9 路并行这两个结构特征一模一样。 +所以「换 clang」是把常数压小,不是把问题解决 —— 工程再长大一倍,它同样会顶到墙。 + +--- + +## 2. 成本解剖:一次模块编译的钱花在哪 + +`-ftime-report`,`src/build/prepare.cppm`(16.2s,链上最贵的一跳): + +| 阶段 | 秒 | 占比 | +|---|---|---| +| **opt and generate** | 14.08 | **86%** | +| ├ callgraph functions expansion | 11.00 | 67% | +| └ callgraph ipa passes | 2.77 | 17% | +| phase parsing | 1.32 | 8% | +| template instantiation | 0.95 | 6% | +| module import | 0.51 | 3% | + +**一次模块接口编译的 86% 是代码生成 —— 而它的导入者一个字节都用不到。** + +这一点有独立的三重验证(见 §5.2 的方法):BMI 在编译的 **15%–39%(中位 ~22%)** 处 +就已经原子落盘、与最终产物**逐字节相同**、且**真实下游导入者能用它编译成功**。 + +⚠️ 一个错误推理值得记下来:我先用 `-fmodule-only` 量「只产 BMI 要多久」,得到 99%, +据此判定「codegen 只占 1%,没有优化空间」。**这是错的** —— GCC 的 `-fmodule-only` +不跳过后端,只是不写目标文件。这个 flag 不能用来回答这个问题。 + +--- + +## 3. 图的形状:19 跳的导入链 + +按源码 `import` 关系建图,用实测编译耗时加权求最长路径: + +``` +最长导入链 = 19 个模块,链上编译耗时合计 74.6s(全图 306.0s) + + 1.02s mcpp.platform.env + 2.84s mcpp.platform.process + 0.02s mcpp.platform ← 纯 re-export 门面 + 1.98s mcpp.manifest.types + 5.21s mcpp.manifest.toml + 0.02s mcpp.manifest ← 纯 re-export 门面 + 1.82s mcpp.platform.xlings.runtime_selection + 5.65s mcpp.platform.runtime_binding + 4.03s mcpp.platform.elf_runtime + 2.06s mcpp.build.loader_contract + 6.08s mcpp.build.plan + 2.82s mcpp.build.flags + 4.76s mcpp.build.compile_commands + 3.69s mcpp.build.ninja + 16.41s mcpp.build.prepare ← 22% of the chain + 4.70s mcpp.build.execute + 2.21s mcpp.build.configure + 3.49s mcpp.cli.cmd_build + 5.77s mcpp.cli +``` +(尾部还有 `obj/main.o` 4.78s + link 0.18s,不在模块链内但在关键路径上。) + +两个观察: + +1. **这条链是真实的分层**:platform → manifest → build → cli。它不是偶然的耦合, + 压平它等于破坏架构。**所以「重构掉这条链」不是一个可行方案。** +2. **成本是摊开的,不是集中的。** 142 次模块编译:中位 1.99s, + top5 占 13%、top10 占 21%、top20 占 35%;44 个 <1s,66 个 1–3s,30 个 3–6s,2 个 >6s。 + 只有 `build.prepare`(16.4s)是真离群。 + **推论:「把最胖的模块拆了」不是通用解**,它只在那一跳上有效。 + +--- + +## 4. 四条杠杆 + +| | 杠杆 | 冷构建 | 依据 | 改动面 | +|---|---|---|---|---| +| **L1** | 默认工具链换 clang | 79.9 → **32.2s**(2.48×) | **实测** | 一行 manifest | +| **L2** | 引擎:下游在 BMI 可用时即开始 | gcc 80.5 → **39.2s**(2.05×) | **实测**(原型 A/B) | 引擎中等 | +| **L3** | 源码:定义移出接口单元 | 链 74.6 → ~10.4s | 推算(§2 的 86%) | 138 个模块 | +| **L4** | 源码:拆 `build.prepare` | 链 −8~11s | 推算 | 一个模块 | + +L1 与 L2 **可叠加**(一个压常数、一个改形状),叠加后推算 ~15s。 + +### L1 —— 换 clang(实测 2.48×) + +零引擎改动,单独就达标。但它**不改变形状**(§1),而且有三个必须先回答的问题: + +* mcpp 在三平台上的 llvm 载荷是否都可用、版本是否统一(目前 `windows = "llvm@20.1.7"`, + 与 Linux/macOS 的 22.1.8 不同)。 +* 换默认工具链会让**所有已发布包的指纹失效**,全生态一次性重编。 +* ABI:`-static-libstdc++` 与 libc++/libstdc++ 的选择;共享库不得内嵌 C++ 运行时 + (已知问题,见 `origin-precedence-and-shared-lib-cxx-runtime`)。 + +**这是一个生态决策,不是性能决策。** 它应当单独立项。 + +### L2 —— 让下游在 BMI 可用时就开始,而不是等编译器退出 + +**这是唯一一条既改变形状、又不需要改源码结构的路。** + +⚠️ **这一节的形状改过一次。** 最初写成「POSIX 用 fork 甩开 codegen,Windows 不支持」, +被指出「cmake / mcpp / xmake / bazel 不都是跨平台的吗」。追下去发现两件事, +它们把方案从「一个技巧勉强套两个编译器」改成了**按编译器族选机制**: + +| 编译器 | BMI 可用时刻 | 机制 | 可移植性 | +|---|---|---|---| +| GCC 16.1 | 编译的 **~22%** | 原子 rename + 甩开 codegen | 需要一个比本进程活得久的监督进程 | +| Clang 22.1 | **57%** | **原生两阶段**,两条普通 ninja 边 | 完全可移植,零进程把戏 | + +**Clang 实测**:`--precompile` 0.78s、`-c` 自 `.pcm` 0.70s,两阶段总 CPU 比单阶段(1.36s) +多 **9.6%**,而下游解锁点从 100% 提前到 **57%**。 + +**而且 clang 不能用 GCC 那套**:strace 证实它以 `O_TRUNC` **直接写最终路径**,没有 rename —— +「看文件出现」对它不成立,读者会读到写了一半的 `.pcm`。反过来,GCC 没有便宜的两阶段 +(`-fmodule-only` 要 99% 的时间,见 §2 的错误推理)。**两条路互为补集,不是二选一。** + +策略落在已有的 `BmiTraits`(`src/toolchain/model.cppm`)里,和 `moduleOutputPrefix`、 +`bmiSearchPrefix` 并列 —— 那正是「同一个决策只推导一次」的位置: + +``` + clang → TwoPhase cxx_precompile: x.cppm -> x.pcm + cxx_object_pcm: x.pcm -> x.o + gcc → DetachCodegen cxx_module_bmi: 编译器启动 → BMI 原子落盘 → 本边退出 0 + cxx_module_obj: 等该进程结束 → 回放输出 → 传播退出码 + msvc → None(待调研:/ifcOnly 是否便宜、.ifc 是否原子发布) +``` + +两种形状下,**下游 import 只依赖 BMI 边,link 依赖 object 边**。 + +「比本进程活得久的进程」用**派生一个 `mcpp` 监督子进程**实现,不用 `fork()` —— +派生进程在 Windows 上同样成立,所以 DetachCodegen 也不是 POSIX 专属; +它的**前提**(BMI 原子发布)才是编译器专属的。 + +原型 A/B(同构建目录、同编译器、同 flags、同编译器并发上限、同源码集, +**唯一差异是图的形状**): + +``` +baseline rc=0 wall=80.51s ninja -j32 compilers<=32 binary=19362456 +split rc=0 wall=39.23s ninja -j192 compilers<=32 binary=19362456 +BMI 边: n=142 中位 940ms OBJ 边: n=143 中位 3091ms +产物可运行;残留游离编译器进程 0 +``` + +⚠️ **四个已知坑,全部踩过:** + +1. **后台子进程会继承 ninja 的管道。** ninja 认为一条边结束是**管道 EOF**,不是直接子进程退出。 + 继承管道会让提前退出**完全不可见**,每条 BMI 边被记成整条编译的耗时 —— 第一次原型 + 就是这样得出「这个想法不成立」的。子进程的 stdio 必须重定向到文件,由 obj 边回放。 +2. **`ninja -j` 必须远大于编译器并发上限。** 编译器一旦脱离,就不再占 ninja 的槽; + 若 `-j` 等于上限,槽会被「正在睡觉的边」占满、就绪前沿饿死,调度退化成 baseline。 + 两条边的并发必须用**独立的信号量**(原子 `mkdir` 令牌)来限,而不是靠 `-j`。 +3. **失败会迟到。** BMI 落盘之后 codegen 才失败时,模块边已经报成功了。 + obj 边必须收集并**复现**每一个失败,否则会变成链接期一堆看不懂的未定义符号。 +4. **图必须自己声明形态。** `build.ninja` 是共享可变状态,快路径会重放它。 + 拆分与否必须写进 `# mcpp:graph=` 那一行,否则换了开关之后快路径会重放旧形状的图 + (这正是 #387/#407 的形状)。 + +**还有一个附带收益**:现在的 BMI 等价性判断是写在 ninja 命令里的一段 POSIX shell, +**Windows 上整段跳过**。把它移进 `mcpp` 子命令后,Windows 也能享受级联抑制。 + +### L3 —— 把定义移出接口单元(推算,链 74.6 → ~10.4s) + +§2 说一次接口编译 86% 是 codegen。这些 codegen 之所以发生在**接口单元**里, +是因为定义写在接口里。移到实现单元后: + +* 接口单元只剩 parse + 实例化 ≈ 现成本的 14%,**它们才是链上的节点**; +* codegen 搬到实现单元 —— 它们是 DAG 的**叶子**(没人 import),完全可并行; +* 总 work 不变,关键路径推算 74.6 × 0.14 ≈ 10.4s + `main.o` 4.78s ≈ **15s**, + 之后转为吞吐受限(314s / 24 线程 ≈ 13s)。 + +这与 bench 的 `modules-impl` 变体测的是同一件事。**但它要动 138 个模块**, +是一次跨越整个代码库的重构,不能一次做完,也不该为了性能而牺牲可读性 —— +它应当作为**新代码的书写约定**逐步生效,并优先用在**链上那 19 个模块**。 + +### L4 —— 拆 `build.prepare`(推算,链 −8~11s) + +16.41s,占链的 22%,是唯一的真离群点。拆成互不依赖的兄弟模块可直接缩短关键路径。 +**⚠️ 拆成链式的两个模块等于什么都没做** —— 必须是兄弟。 + +--- + +## 5. 建议顺序 + +1. **先做 L2。** 唯一改变形状、且不需要改源码结构的路;实测 2.05×,单独就把 80s 打到 39s, + 达成 <50s 目标。附带把级联抑制带到 Windows。 +2. **L4 紧随其后。** 一个模块的改动,收益可直接测量,且与 L2 叠加。 +3. **L1 单独立项。** 是生态决策(指纹失效、三平台载荷、ABI),不该混进性能 PR。 +4. **L3 作为约定长期生效**,优先施加于链上的 19 个模块。 + +## 6. 明确不做 + +* **加核 / 更大的 `-j` / 分布式编译。** 已实测:`-j8 → -j32` 只快 1.4%。 +* **压平模块分层。** §3:那条链是真实的架构分层,不是偶然耦合。 +* **为了性能降低优化档。** 改的是产物,不是构建。 +* **缓存自己的 BMI 跨构建复用。** 冷构建的定义就是没有缓存;这条对 §0 的目标无效。 + +--- + +## 7. 方法学附注 + +### 7.1 判据必须是「下游能不能用」,不是「文件在不在」 + +`.gcm` **出现**在编译的 14% 处,但「出现」不等于「写完」。三步缺一不可: + +1. 轮询到大小连续 150ms 不变 → 快照; +2. `cmp` 快照与编译结束后的成品 → **逐字节相同**; +3. 把快照放回 `gcm.cache/`,编译一个**真实下游导入者** → **exit 0**。 + +只做第 1 步会把「文件被创建」当成「BMI 可用」。 + +### 7.2 对照组 + +「改函数体后 BMI 差 2 字节」看起来像铁证。跑一次**同一份源码编译两次**的对照: +差的是**同样两个偏移**,上下文是 `buildtime:` / `localtime:` 的秒位。 +没有这个对照就会得出相反结论。 + +### 7.3 关键路径必须按拓扑序松弛 + +用栈式 DFS 求最长路径(带防环)会把 76.5s / 26 节点读成 33.9s / 10 节点, +把「100% 关键路径」读成「44%」,结论完全反过来。必须用 Kahn 拓扑序松弛。 diff --git a/.agents/docs/2026-08-14-bench-local-first-design.md b/.agents/docs/2026-08-14-bench-local-first-design.md new file mode 100644 index 00000000..40924ac4 --- /dev/null +++ b/.agents/docs/2026-08-14-bench-local-first-design.md @@ -0,0 +1,221 @@ +# bench 改为「本地真跑、CI 不跑」:方案与 Linux 实测计划(2026-08-14) + +**状态:待 review,尚未实施。** + +--- + +## 0. 一句话 + +把 bench 从 CI 里整个删掉,改成**一条本地可重复的命令**产出数据;当前只承诺 +**Linux**,其他平台在文档里明确标 `(未测试)`;标准数据集固定为 **3 轮**。 + +--- + +## 1. 为什么删 CI bench —— 不是因为它慢,是因为它测不到东西 + +删除的理由必须是事实,不是偏好。当前 `bench/matrix.json` 的账: + +| | | +|---|---| +| 格子数 | 10 | +| 外部引擎臂总数 | 32 | +| **被 `allow_failed` 豁免的** | **12(37%)** | +| 其中 xmake | 在测 4 / 豁免 6 —— **豁免多于在测** | +| 其中 cmake | 在测 5 / 豁免 5 | + +也就是说:一个自称「跨引擎对比」的矩阵,有三分之一的对比臂从未产出过数字,而 +job 是绿的。这不是"还没修完",这是**这个东西在 CI 上跑的形态本身就不成立**: + +1. **共享 runner 上测的是 runner,不是引擎。** 两核机器、邻居噪声、镜像随时更新。 + 同一份代码在 runner 上 cold 是 243s,在开发机上是 79s —— 三倍差距不来自 mcpp。 +2. **外部工具的缺口不是 mcpp 的缺陷,却由 mcpp 的 CI 承担。** xmake 编不了 + libc++ 的 `import std`、bazel 的 MSVC 依赖扫描器坏掉、rules_cc 不支持 + Windows PIC —— 每一个都要在 mcpp 的矩阵里挂一条豁免,而豁免越多,绿色越没意义。 +3. **一次矩阵约两小时,而它回答的问题("性能有没有退")在 PR 粒度上并不需要每次回答。** + 真正需要它的时刻是**发版前**和**做完一次优化后**,那两个时刻都有人在场。 + +**保留 CI 里的哪一部分**:`tests/e2e/230_bench_harness.sh`(套件自身能构建、能跑出一份 +合法报告)继续留在 e2e 里。删掉的是**跑真实工作负载的那个矩阵**,不是套件的自测。 + +--- + +## 2. 删掉什么、留下什么 + +**删除** +- `.github/workflows/bench.yml` 整个文件 + +**保留,但改变角色** +- `bench/matrix.json` —— 从「CI 跑哪些格子」变成「**标准数据集的定义**」。 + 这一点很关键:清单仍然只有一份,只是读它的人从 workflow 变成了本地脚本。 +- `bench/src/**` —— harness 本身不动。 +- `bench/results/**` —— 数据仍然入库。 + +**新增** +- `bench/run-standard.sh`(名字待定)—— 读 `matrix.json`,跑**当前平台**的格子, + 3 轮,输出到 `bench/results/--/`。一条命令,不带参数也能跑。 + +**必须同步改的守卫(否则删完 e2e 直接红)** +- `tests/e2e/233_bench_matrix.sh` 现在有 5 处读 `.github/workflows/bench.yml`: + 第 48/53 行(存在性)、427(workflow 读 matrix.json)、438(`--runs` 与 + dispatch 默认值)、475/480(runner 镜像不得内联)。这些检查的**对象**要从 + workflow 换成 `bench/run-standard.sh`,判据不变:**清单只有一份、脚本读它而不重复它**。 +- `tests/e2e/232_workflow_syntax.sh` 会少一个文件,无需改。 + +--- + +## 3. 标准数据集 —— 只承诺能证明的 + +### 3.1 平台 + +| 平台 | 承诺 | README 写法 | +|---|---|---| +| **Linux x86_64** | 本次真实跑出 | 完整数据 | +| macOS | 不跑 | `(未测试)` | +| Windows | 不跑 | `(未测试)` | + +`(未测试)` 不是 `(不支持)`。差别要在文档里写明:**没有数据 ≠ 不能用**,而 +"曾经有过一版数据、但那版数据是在有缺陷的 harness 上取的"更要写明 —— 见 §5。 + +### 3.2 格子(Linux) + +从现有 10 格里取 Linux 的 6 格,并**去掉所有需要豁免的外部臂**: + +| 工作负载 | 编译器 | 引擎 | 说明 | +|---|---|---|---| +| `fixture` | gcc | mcpp, cmake, xmake | 三引擎,唯一能控制 variant 轴的地方 | +| `fixture` | clang | mcpp, cmake, xmake, bazel | 四引擎 | +| `mcpp-2026.8.11.3` | gcc | mcpp, xmake | cmake 有未定案缺口 → 不列 | +| `mcpp-2026.8.11.3` | clang | mcpp, cmake | xmake 有已复现缺口 → 不列 | +| `xlings-2026.8.11.2` | gcc | mcpp | 两个外部臂都有缺口 | +| `xlings-2026.8.13.1` | gcc | mcpp | 同上,与上一行成对(两种代码风格) | + +**原则:标准集里不出现豁免。** 一条臂要么在测,要么不在这张表里 —— 它的缺口写进 +§5 的「已知缺口」清单,附证据。这样"标准数据"里的每一个数字都是真的跑出来的, +不需要读者去分辨哪些是豁免掉的。 + +### 3.3 轮次 + +**3 轮**(`--runs 3`),取中位数,同时记录 min/max。 + +理由:n=1 没有离散度,而现有 README 每张表都标 `n=1` 并附带"不要比较个位数字"的 +警告 —— 那个警告本身就说明 n=1 不够。3 轮 × 6 格 × 5 场景在开发机上约 40–60 分钟, +是一个人可以在一次会话里跑完的量。 + +### 3.4 场景 + +沿用现有六个:`cold` / `noop` / `touch-hub` / `touch-leaf` / `edit-body` / +`edit-comment`。真实工程略去 `touch-leaf`(现有 note 已说明:真实的树里没有 +"没人 import 且足够稳定可以点名"的单元)。 + +--- + +## 4. 本地实测计划(Linux) + +### 4.1 前置条件(脚本要检查并明确报错,而不是继续跑) + +1. 工具链载荷齐备:`xim-x-gcc/16.1.0`、`xim-x-llvm/22.1.8`、`xim-x-binutils`、 + `xim-x-glibc` +2. 外部工具按 `matrix.json` 的 `tools` 钉住:cmake 4.0.2 / xmake 3.1.0 / bazel 9.2.0 +3. 子模块已 checkout(三个 pinned 工作负载) +4. 依赖包已解包(`mcpplibs.cmdline` 等)—— 现在 cmake 臂会 FATAL_ERROR,好过静默少编 + +### 4.2 步骤 + +```bash +# 1. 构建被测的 mcpp 与 harness +mcpp build --release +cd bench && mcpp build --release && cd .. + +# 2. 拉取钉住的工作负载 +git submodule update --init + +# 3. 跑标准集(一条命令,内部按 matrix.json 展开 Linux 的格子) +bash bench/run-standard.sh + +# → bench/results/2026-08-14-linux-x86_64/*.json + report.md +``` + +### 4.3 记录什么 + +每份报告已经带 `host`(CPU 型号、核数、内存、是否异构)。再补两件: + +- **被测 mcpp 的版本与 commit** —— 现在只记版本号,同一版本可以有不同 commit +- **跑完的时间戳与总耗时** —— 用于判断两次数据是否可比 + +### 4.4 判据(跑完必须核对,不能只看退出码) + +1. 退出码 0 +2. `failed` 为 0、**`waived` 为 0**(标准集里不该有豁免) +3. 每个 `cold` 都通过既有的两条不变量(vs 自身 noop ≥2×、vs 同侪 ≤20×) +4. 三轮的 min/max 跨度不超过中位数的 ±20% —— 超了就是机器有噪声,数据不发布 + +--- + +## 5. README 要改成什么样 + +现在的 `bench/README.md` 是**一份实验记录**:900 行,大量"这里曾经错在哪"。 +那些内容有价值,但它不是一个人第一次想跑 bench 时该读到的东西。 + +**改成三层:** + +``` +bench/README.md +├─ 一、怎么跑(前 40 行,可以照抄的命令) +├─ 二、标准数据(Linux 表格 + 其他平台标 (未测试)) +└─ 三、方法与已知缺口(现有内容,往后放) +``` + +**「怎么跑」必须是可重复的**:给出完整命令序列、预期耗时、以及"跑完怎么判断这份 +数据能不能用"(§4.4 的四条)。现在的 README 里这些信息散在 §6 和 §10。 + +**已知缺口单独成节**,每条写:现象、归属(我们的 / 上游的)、复现方式、试过什么。 +当前应当列入: + +| 缺口 | 归属 | 证据 | +|---|---|---| +| xmake + libc++ 编不了真实工程的 `import std` | 上游 | 本机复现,`--sdk` 与两种工具链都试过 | +| cmake 在 CI runner 上 `__CMAKE::CXX23` 不可用 | 未定 | 外部可查项全部与能跑通的机器一致 | +| cmake 读 xlings 依赖包时 `manifest has no sources` | **我们的** | 本机 rc=0,runner 上某个 `compat-x-*` 缺 `sources`;**具体包名待补** | +| bazel MSVC 依赖扫描器 | 上游 rules_cc | Windows 专有 | + +**中文版同步**,并保留"以英文版为准"的声明。 + +--- + +## 6. 现有已发布数据怎么处理 + +`bench/results/` 里已有 30 个 JSON。**不删**,但要在 README 里分层: + +- **标准数据**:本次 Linux 实测那一份(3 轮),README 的表格只引用它 +- **历史数据**:其余的,标注日期与当时的 harness 状态 + +⚠️ 特别地:`bmi_schedule=on` 那一列的旧数据是在 §8b 缺陷修复**之前**取的, +`touch-hub`/`edit-comment` 两格量的工作比构建欠下的少(已在 README §8b 记录)。 +本次重测应当把这一列一并重取,让标准表里不再混有修复前的数字。 + +--- + +## 7. 风险与取舍 + +| 取舍 | 得 | 失 | +|---|---|---| +| CI 不再跑 bench | 省两小时/次;不再用绿色掩盖 12 条豁免 | **性能回归不再自动发现** | +| 只承诺 Linux | 数字都是真的 | 跨平台差异无数据 | +| 标准集不含豁免臂 | 表里每个数字都真跑过 | 表变小(32 臂 → 约 14 臂) | + +**最大的失**是第一条。缓解:把「发版前跑一次标准集」写进发布流程文档 +(`release-publish-pipeline` 那条);优化类 PR 在描述里附本地数据。 + +**不做的事**:不搞"CI 里跑一个缩水版 bench"。缩水版会重新引入"绿色代表什么" +的模糊地带 —— 这正是要删掉它的原因。 + +--- + +## 8. 待 review 的决策点 + +1. `bench/run-standard.sh` 这个名字与位置,是否放在 `bench/` 下 +2. 标准集的格子清单(§3.2)是否合适 —— 尤其是否要保留 `xlings` 的两格 + (它们只有 mcpp 一条臂,是"两种代码风格对比"而非"引擎对比") +3. 3 轮是否够;要不要对 `cold` 单独加轮次 +4. `bench/matrix.json` 是否继续承载"标准集定义",还是另起一个更小的文件 +5. §6 的历史数据:保留还是清理到只剩标准集 diff --git a/.agents/docs/2026-08-15-bench-resumable-design.md b/.agents/docs/2026-08-15-bench-resumable-design.md new file mode 100644 index 00000000..b67c06f0 --- /dev/null +++ b/.agents/docs/2026-08-15-bench-resumable-design.md @@ -0,0 +1,173 @@ +# bench 可断续:每个测量点独立落盘,进度可算可显示(2026-08-15) + +**状态:方案,待实施。** + +--- + +## 0. 现状(读代码确认,不是推测) + +| | | +|---|---| +| 落盘时机 | **只在一次 `bench` 调用结束时**,`std::ofstream out(opts->out, trunc)` 一把写完 | +| 断点续跑 | **完全没有** —— 源码里没有 resume / checkpoint / journal 任何一处 | +| 内层循环 | `engine × variant × scenario`,轮次在 `Runner::measure` 内部 | +| 进度显示 | 有实时行(`[123.4s] engine/tc/…/scenario run 2/3`),但**没有总量**,所以看不出"还剩多少" | +| `run-standard.sh` | 一格一次 `bench` 调用;格与格之间也没有续跑 | + +**后果**:任何中断 —— 我杀进程、机器重启、一个 cell 超时、我改错一个文件 —— +都让**整次调用**的工作归零。这两天我已经因此丢掉三整轮数据,每轮数小时: + +1. 裸名 `mcpp` 测成了旧版 → 全轮作废 +2. 字段错位导致 cmake 拿错目录 → 全轮作废 +3. 装 cmake 4.4.2 中途换掉 PATH → 全轮作废 + +**这不是运维失误的代价,是设计缺陷的代价。** 一个跑七小时、且没有任何中间态的 +批处理,注定会被它自己的运行环境打断。 + +--- + +## 1. 可保存的最小单元 + +正如你指出的,单元是: + +``` +os · toolchain · project · variant · scenario · engine · run_index +``` + +一个单元的产物只有一个数:那一轮的墙钟秒数(以及退出码)。这七个字段是它的 +**完整坐标**,而当前的报告已经带了前六个 —— 缺的只是 `run_index` 和"逐条落盘"。 + +**总量是可算的**,开跑前就能算出: + +``` +N = Σ(每个 cell) engines × variants × scenarios × runs +``` + +本机 Linux 标准集:约 **1000 个单元**(fixture 两格各 54/90 个测量点 × 3 轮, +五个真实工程格各 15 个 × 3 轮)。 + +--- + +## 2. ⚠️ 难点不在"存盘",在"这条记录属于哪一次运行" + +断点续跑最危险的失败模式不是丢数据,是**把两次运行的数据拼在一起而没人发现**。 + +这不是假设 —— **今天就发生了一次**:上一轮被 `kill` 的进程没有立刻死,在我 +`rm -rf` 之后才写出报告,于是 `clang-fixture.json`(90 格,20:21 那轮)和 +`gcc-fixture.json`(72 格,20:32 这轮)同名并列在一个目录里。唯一能分辨的线索是 +JSON 里的 `started_at`。如果我直接拿这个目录生成表格,**两轮数据会被无声地拼进 +一张表**。 + +加了 resume 之后,这个风险从"偶然"变成"系统性":resume 的本质就是"把旧记录 +当成本次的结果"。所以: + +> **没有身份校验的 resume,就是一台自动拼接不同运行的机器。** + +### 运行身份(run key) + +每条记录必须带一个 key,resume 只接受 key 完全相同的记录: + +| 成分 | 为什么 | +|---|---| +| 被测 mcpp 的 **版本 + commit** | 同一分支上每个 commit 版本号相同 —— 这正是任务 #16 | +| 参照 mcpp 的版本 | 老版本列换了就不是同一张表 | +| cmake / xmake / bazel 版本 | 今天 cmake 4.0.2→4.4.2 中途切换过 | +| 编译器载荷版本 | gcc 16.1.0 / llvm 22.1.8 | +| 每个工作负载的 **submodule commit** | 被测的树不能漂 | +| harness 自身的 commit | 计时逻辑改了,数就不可比 | +| host 指纹(CPU 型号 + 核数 + 内存) | 换机器就不是同一次测量 | + +key 不匹配时:**拒绝续跑,不静默重测**。打印哪一项变了 —— +"cmake 4.0.2 → 4.4.2,已有 412 条记录作废" 比"重新开始"有用得多。 + +--- + +## 3. 设计 + +### 3.1 journal(append-only) + +`/journal.jsonl`,每测完一个单元立刻 append 一行并 `flush`: + +```json +{"key":"","os":"linux","toolchain":"gcc","project":"mcpp-2026.8.11.3", + "variant":"native","scenario":"cold","engine":"mcpp@2026.8.13.1","run":2, + "wall_s":80.65,"exit":0,"at":"2026-08-15T04:31:02Z"} +``` + +* **append-only**,不改写:一次 kill 最多丢掉正在测的那一个单元 +* **JSONL**:半行(进程被杀在写一半)可以被解析器跳过,而半个 JSON 对象会让 + 整个文件无法读取 +* 最终报告仍然生成,由 journal 归约而来 —— 报告变成**派生物**,journal 是真源 + +### 3.2 resume + +启动时:读 journal → 丢弃 key 不匹配的行 → 得到"已完成单元集合" → 计划里减掉它们。 + +``` +resuming: 412/1004 units already recorded (41%), 592 remaining + skipped: gcc/fixture (54/54), clang/fixture (90/90) + partial: gcc/mcpp-2026.8.11.3 — 7 of 45 units +``` + +⚠️ **seed build 不是单元,必须重做。** 增量场景要求一棵"已经是最新"的树,而那 +是 seed build 建立的状态,不在 journal 里。所以恢复一个 cell 时:seed 重跑一次, +然后只补缺的轮次。这要写进文档,否则会有人以为 resume 是完全免费的。 + +### 3.3 进度 + +总量开跑前就能算,所以进度行带上分母: + +``` +[ 512.3s] (412/1004, 41%) mcpp@2026.8.13.1/gcc/release/cold/mcpp-2026.8.11.3 run 2/3 +``` + +再加一个粗略的 ETA:用**已完成单元的中位耗时**外推,而不是平均 —— cold 与 noop +差三个数量级,平均值毫无意义。 + +### 3.4 CLI + +| 选项 | 行为 | +|---|---| +| (默认) | 有 journal 且 key 匹配 → 自动续跑;key 不匹配 → 拒绝并说明哪一项变了 | +| `--fresh` | 忽略 journal,从头测(旧 journal 改名保留) | +| `--dry-run` | 打印计划与总量,不测 | + +`run-standard.sh` 传同一个 `--out-dir`,于是**格与格之间**也自动续跑:被打断后 +重跑同一条命令即可。 + +--- + +## 4. 实施步骤 + +1. `protocol.cppm`:`RunKey` 结构 + journal 行的序列化/反序列化 +2. `main.cpp`:开跑前算总量;`Runner::measure` 每轮后 append + flush +3. `main.cpp`:启动时读 journal、校验 key、从计划中减去已完成单元 +4. 进度行加 `(n/N, x%)` 与 ETA +5. 报告从 journal 归约生成(现有 `Report` 结构不变,只换填充来源) +6. `run-standard.sh`:传 `--out-dir`,去掉每格一个 `--out` +7. **任务 #16 合并进来**:run key 需要被测 mcpp 的 commit,这本来就是要补的 +8. e2e 守卫: + - 杀掉一次运行再续跑,结果与不中断跑完**逐格相同** + - key 变化时**拒绝**续跑(反向必须验) + - 半行 journal 不会让读取崩溃 + +--- + +## 5. 不做什么 + +* **不做跨机器共享 journal。** host 是 key 的一部分,换机器就是另一次测量。 +* **不做单元级并行。** 并行会让每个单元的计时互相污染,而这是个基准。 +* **不缓存构建产物来跳过 seed。** seed 建立的是"树已是最新"的状态,把它缓存起来 + 就是在测缓存,不是测构建。 + +--- + +## 6. 这件事的判据 + +实施完成后,下面这句必须成立并且**被测试钉住**: + +> 在任意时刻杀掉 bench,重跑同一条命令,最终报告与从未中断跑完的报告**逐格相同**; +> 而当任何一项被钉住的东西变了,它**拒绝**续跑并说出是哪一项。 + +后半句和前半句一样重要。今天那次两轮混在一个目录里的事故说明:**能续跑但不校验 +身份,比不能续跑更危险** —— 前者只是浪费时间,后者会产出看起来正常的错误数据。 diff --git a/.agents/docs/2026-08-15-issues-412-422-analysis.md b/.agents/docs/2026-08-15-issues-412-422-analysis.md new file mode 100644 index 00000000..25f5dce0 --- /dev/null +++ b/.agents/docs/2026-08-15-issues-412-422-analysis.md @@ -0,0 +1,359 @@ +# #412 #415 #416 #417 #418 #421 #422 —— 逐条核实与修复方案(2026-08-15) + +**状态:分析 + 方案,待 review。全部对 HEAD 重新核实过,不是照抄 issue。** + +七条都是 2026-08-11/12 写的,分支此后动过。下面每一条先给**核实结论**(还在? +行号变了没?issue 的推断成立吗?),再给方案。**三处 issue 自身需要修正**,标了 ⚠️。 + +| # | 核实 | 性质 | 改动面 | 建议 | +|---|---|---|---|---| +| 416 | 仍在,但 ⚠️ **issue 的因果与症状都不成立**(实测) | 正确性(后果远小于所述) | 小 | 先复现 | +| 422 | 仍在,且真因比 issue 说的更好修 | 正确性(MSVC 不可用) | 小 | **先做** | +| 412 | 三条全在 ⚠️ 编号冲突 | 文档/覆盖 | 小 | **先做** | +| 418 | 两条全在,但字段有同名的活字段 ⚠️ | 死代码 | 小 | 做 | +| 415 | 仍在(`Origin` 无 `Artifact` 档) | 可观测性 | 中 | 做 | +| 417 | 仍在,但**真因未定位** | 首次体验 | 中 | 先探针 | +| 421 | 仍在;⚠️ 文档有一句是错的 | 能力边界 | 大 | 拆成三档 | + +--- + +## #416 — `obj/std.o` 无条件链进每个单元 + +### 核实 + +仍在。行号已从 issue 写的 1362-1381 变到 **`ninja_backend.cppm:1648` / `:1658`**: + +```cpp +case LinkUnit::Binary: +case LinkUnit::TestBinary: + if (has_std_artifacts) ins += " " + escape_ninja_path(std_o_dst); +case LinkUnit::SharedLibrary: + if (has_std_artifacts) ins += " " + escape_ninja_path(std_o_dst); +``` + +`has_std_artifacts` 的含义是「这条工具链有预建的 std 模块」,与**这个链接单元是否 +需要**无关 —— 这一点确实成立。但 issue 声称的**后果**不成立,见下面两处修正。 + +### 方案 + +把条件从「工具链有 std」收窄到「这个单元的闭包里真的有人 `import std`」。 + +判定数据**已经在手**,不需要新的扫描:模块图里每个 TU 的 `requires_` 是扫描器 +填的,`import std` 会出现在里面。所以: + +``` +needs_std(link_unit) = 该单元的任一 TU 的 requires_ 含 "std" / "std.compat" + ∪ 该单元链接的任一【本工程】静态库/对象的同一判定(传递闭包) +``` + +⚠️ **传递性必须递归,不能只看本单元的源码。** issue 自己点了这条,而它有前科: +依赖的 BMI 跨版本毒化那次(#405)就是「边存在但没有任何人依赖它」。一个单元自己 +不写 `import std`,但它 import 的模块接口写了,链接时同样需要 `std.o`。 + +### ⚠️ 两处对 issue 的修正(实测,不是推理) + +**修正 1:`std.o` 不可能是 `libstdc++.so.6` 的来源。** + +``` +$ nm --defined-only obj/std.o → 1 个符号:_ZGIW3std(模块 std 的全局初始化器) +$ nm --undefined-only obj/std.o → 0 个 +$ ls -l obj/std.o → 1280 字节 +``` + +**零个未定义符号**,所以它拖不动任何共享库。issue 把 +「纯 C 的 compat 包多一条 `NEEDED libstdc++.so.6`」归因给 `std.o`,这条因果不成立。 + +真正的嫌疑是**链接驱动**:`ninja_backend.cppm` 一律用 `$cxx`(g++)链接,而 g++ +总是加 `-lstdc++`;全仓 `--as-needed` **只**用在 `-latomic` 那一处 +(`flags.cppm:240`),所以 `-lstdc++` 会无条件成为 `NEEDED`,与 `std.o` 无关。 + +**修正 2:头号症状在本机产物上复现不出来。** + +``` +$ readelf -d ~/.mcpp/registry/subos/default/lib/libXau.so.6 | grep NEEDED + libc.so.6 ← 只有这一条,没有 libstdc++.so.6 +$ nm -D libXau.so.6 | grep -c _ZGIW → 0 +``` + +这份产物的来源版本不明(可能是 #414 之后重建的)。**实施前必须先用当前 mcpp +重新构建一个纯 C 的 compat 包并 `readelf -d`**,确认症状是否还在 —— +否则可能在修一个已经不存在的问题,而真正的 `-lstdc++` 通道没人动。 + +### 这两处修正如何改变方案 + +* **仍然该做**:`std.o` 无条件链进纯 C 单元本身就是错的(一个不该在那里的对象), + 即使它的后果比 issue 说的小得多。 +* **但判据要拆开**:「`readelf -d` 没有 `libstdc++.so.6`」这条**不是**收窄 `std.o` + 就能达成的,它属于 `-lstdc++` / `--as-needed` / 链接驱动那条线。两件事要分成 + 两个 issue,否则会出现「改完了,判据仍然不满足」。 +* **风险比想象中低**:`std.o` 唯一的符号是模块初始化器,而 `import std` 的 TU 会 + 引用它。所以谓词若**漏判**(该链没链),结果是**链接期 undefined `_ZGIW3std`** + —— 响亮的失败,不是静默错误。这让递归传递闭包不必一次写到完美。 + +--- + +## #422 — MSVC:`cxx_runtime` 到不了 std 模块 + +### 核实 + +仍在,而且**真因比 issue 推测的更具体**。`src/toolchain/msvc.cppm:529` 构建 std 的 +命令是: + +``` +cl /nologo /EHsc /O2 /W0 /c /ifcOutput /Fo: +``` + +**一个 `/MT` 或 `/MD` 都没有** —— 用的是 cl 的默认(`/MT`)。工程在 +`cxx_runtime = "host-coupled"` 下编 `/MD`,于是 `_MSVC_MT` / `_MSVC_MD` 对不上, +C5050 之后是真正的 C2375。 + +### ⚠️ issue 给了两条路,其实做第一条就白送第二条 + +issue 说「要么 std 构建遵守 `cxx_runtime`,要么 std 缓存按它分键」。核实发现 +**std 的缓存身份键里已经含 `std_build_commands`(实际命令行)** +(`src/toolchain/stdmod.cppm:136`,且 `metadata_matches` 的 14 个键里也有它)。 + +所以只要把 runtime flag 加进那条命令,缓存目录会**自动**随之分叉,两者不可能再 +静默背离。不需要单独设计缓存键。 + +### 方案 + +1. `msvc.cppm` 的 std 命令构造函数接收「有效 runtime 契约」,发 `/MT` 或 `/MD` + (以及 debug profile 下的 `/MTd` / `/MDd`)。 +2. 契约来源必须与工程 TU **同一处推导**,否则就是又一个「同一决策两处推导」。 + 工程侧的推导在 `src/build/flags.cppm`(`dist::parse_contract` / `contractByRole`), + std 侧要读同一个结果。 +3. 顺带:GCC / Clang 侧不需要动 —— 它们的 std 模块不带 runtime ABI 开关。 + 但**注释要写明为什么只有 MSVC 需要**,否则下一个人会以为漏了。 + +### 判据 + +* `cxx_runtime = "host-coupled"` 的工程在 MSVC 上**能构建并运行**(现在是失败) +* 同一台机器上,`host-coupled` 与 `self-contained` 两个工程各自拿到**不同的** + std 缓存目录 —— 用 `ls /std/` 直接看,两个 key +* ⚠️ 反向也要验:**只**改 `cxx_runtime` 不改别的,std 必须重建。否则说明 flag + 没进 `std_build_commands`,缓存键没分叉,问题只是被当前的冷缓存掩盖了 + +--- + +## #412 — 三处过期的 MSVC 陈述 + +### 核实 + +三条全在: + +| | 位置 | 现状 | +|---|---|---| +| 1. 劝退用户的假 note | `src/toolchain/lifecycle.cppm:664` | 仍在 | +| 2. 与自身断言矛盾的头注释 | `tests/e2e/95_msvc_system_toolchain.sh:9` | 仍在 | +| 3. `217` 在两个平台整条被跳过 | `tests/e2e/217_module_extensions.sh:2` `# requires: gcc` | 仍在 | + +### ⚠️ issue 的方案里有一个编号冲突 + +issue 建议「补一条 `219`」。**`219` 已被 `219_runtime_search_farm_is_last.sh` 占用。** +新测试要另取编号(当前最大是 234,故用 **235**)。 + +### 方案 + +1. 删 `lifecycle.cppm:664` 那句 note。它的实际效果是**劝退一个能用的功能**。 + 替换成陈述现状还是直接删掉,取决于是否有已知限制要说——若无,直接删, + 「没有消息」比「过期的消息」好。 +2. 删 `95` 头注释第 9 行。 +3. 新增 `tests/e2e/235_module_extensions_msvc_llvm.sh`:不依赖 `gcc` 能力, + 按平台选默认工具链(Windows→msvc,macOS→llvm),验证 `.ixx` 走完 + build→link→run。 + +⚠️ 第 3 条是唯一有实质价值的:前两条是删字,第三条是**补一条从未被走过的路**。 +`.ixx` + `module_extensions` + MSVC 这条组合推理上必然通,但推理不是测量 —— +这正是 #411 里指出的同一形状。 + +--- + +## #418 — 两处只写不读的死字段 + +### 核实 + +两条都仍在,但 ⚠️ **`cxxRuntimeTests` 这个名字下有两个字段,只有一个是死的**: + +| 字段 | 位置 | 状态 | +|---|---|---| +| `BuildConfig::cxxRuntimeTests` | `types.cppm:450` | **活的** —— toml.cppm:967 解析,flags.cppm:760/848 读 | +| `TargetEntry::cxxRuntimeTests` | `types.cppm:653` | **死的** —— 解析处(toml.cppm:1379)只读标量 `cxx_runtime`,应用处(prepare.cppm:1154)只应用 `cxxRuntime` | + +`contractByRole` 全仓两处命中(`flags.cppm:62` 声明、`:875` 写),**确认无读取方**。 + +实施时不要 `grep cxxRuntimeTests` 后一把删 —— 会删掉活的那个。 + +### 方案 + +* **`TargetEntry::cxxRuntimeTests`:删。** 理由:per-target 通道目前只支持标量, + 而标量已覆盖所有角色。补全表形式解析是**扩表面积**,而这个通道还没有用户要求它。 + 一个不生效的配置项比没有更糟 —— 但补全它等于新增一个需要长期维护的语义。 +* **`contractByRole`:接出去,不删。** 它记录「每个角色实际拿到的契约(降级之后)」, + 而 #414 之后共享库的契约按格式分档,「我这个 `.so` 到底拿到了哪一档」是用户会 + 真的问的问题,现在只能靠 `readelf` 自己看。写进 `resolution.json`,让 + `mcpp why` / `mcpp doctor` 能回答。 + +### 判据 + +* `TargetEntry::cxxRuntimeTests` 不存在,且 `[target.].cxx_runtime_tests` + 写在 manifest 里会**报未知键**(而不是被静默忽略) +* `contractByRole` 有真实消费方并被测试覆盖:一个共享库工程的 `resolution.json` + 里能读到它拿到的档位,且与 `readelf` 的实际观测一致 + +--- + +## #415 — `$ORIGIN` 不在 runtime 闭包里 + +### 核实 + +仍在。`src/platform/runtime_search.cppm` 的 `Origin` 枚举只有 +`Payload / Package / SubosFarm / HostDefault`,**没有 `Artifact` 档**(全文件 0 处 +命中 "Artifact")。`$ORIGIN` 由 `src/build/plan.cppm:475` 在 per-unit flags 通道 +单独发出。 + +模块自称「Search order = decreasing immutability. This is the one invariant in this +module」,而**最关键的那个目录不在这个排序里** —— #414 修的正是它排错了位置。 + +### 方案(采纳 issue 的轻档,不做重档) + +* `runtime_search.cppm`:`Origin` 加 `Artifact`,`rank()` 置于 `Package` 与 + `SubosFarm` 之间,补 `to_string()`; + ⚠️ `is_machine_local()` 返回 **false** —— `$ORIGIN` 随产物走,不是机器局部的。 + 这一条写错会让 `pack` 的判断反过来(虽然 `pack` 当前不 import 这个模块, + 但那是巧合,不是契约)。 +* `plan.cppm:679` `runtime_search_closure()`:把产物输出目录加进去。 +* 显示侧:`prepare.cppm` 写记录、`doctor.cppm` 打标签。 + +**重档(让闭包成为唯一的 rpath 生产者)不做**:`$ORIGIN` 本质是 per-unit 的 +(只有消费共享库的单元才需要),挪进全局闭包要给闭包引入 per-unit 概念, +改动量与风险明显更大,而收益只是消灭「两个生产者」这个洁癖。 + +### 判据 + +e2e 219 能把「记录的闭包」与「产物的 DT_RPATH」**逐项**比对并通过, +**不对 `$ORIGIN` 做任何过滤或例外** —— 现在它必须过滤,那个过滤就是缺口的证据。 + +--- + +## #417 — 全新 MCPP_HOME 首次构建,rule B 对每个产物报 inconclusive + +### 核实 + +仍在。两条消息在 `src/platform/elf_runtime.cppm:773` / `:791`,**按产物逐条发**, +所以一个图形工程刷 13 条。 + +### ⚠️ 真因未定位,不要照着 issue 的推理直接改 + +issue 自己写了「精确接缝还需要一次探针确认 —— **不要照着这个推理直接改**」。 +这条要照办。当前证据只支持这个描述: + +* 首次运行:`binding.loader` 与 `binding.library_dirs` 为空,而 `binding.search_dirs` + **有**值(已记下 `/lib`) +* 第二次运行:两者都有值,警告消失 +* 磁盘上二者首次运行时**都存在** + +「binding 求值早于 farm 落盘」是**自然读法**,不是已证事实。search_dirs 有值而 +另外两个没有,说明填充它们的**不是同一段代码或同一时刻** —— 这一点本身就值得先查。 + +### 方案:先探针,再修 + +1. **探针**:在 `runtime_binding.cppm:337-380` 的填充点打一条 verbose,记录 + 「此刻 `/lib64`、`/lib` 是否存在、里面有没有 `libc.so.6` 和 + `ld-linux-*`」。全新 MCPP_HOME 跑一次,把真实接缝钉死。 +2. 按探针结果二选一: + * 若确是**时序**:把 binding 的求值推迟到 farm 落盘之后(或在首次构建时重求值一次)。 + * 若是**查找逻辑**在首次运行时的目录形态下失效(例如那时还是符号链接的符号链接): + 修查找,而不是改时序。 +3. **无论哪一种,诊断都要改**:同一个原因对 13 个产物刷 26 条,是噪声不是信息。 + 改成 binding 层面**只说一次**「此刻无法求值,原因 X」。 + +### ⚠️ 必须写进代码注释的一条 + +**即使 rule B 完全正常,它也抓不到 #414 那个崩溃。** +`elf_runtime.cppm:761-801` 只比对 libc / PT_INTERP 的同一性,不管其它 SONAME +解析到谁。修这个 issue 不能替代 #414 的顺序修复,也不要当成它的兜底。 + +### 判据 + +全新 MCPP_HOME 的第一次构建:要么 rule B 给出真实判决(pass/mismatch), +要么 binding **只说一次**「此刻还无法求值」,而不是对每个产物各刷两条。 + +--- + +## #421 — 扫描器 M1:条件 import 与头单元(来自 XRGUI 的真实适配) + +### 核实 + +两条限制都仍在:`scanner.cppm:660`(条件 import)、`:666`(头单元)。扫描器是 +**纯词法**的,用 `if_depth > 0` 判定,**不看条件是否可判定**。 + +### ⚠️ 文档里有一句是错的,这是最该先修的 + +`docs/05-mcpp-toml.md:414`(中文版 §2.3 同): + +> `defines` … also reaches the P1689 module scan, **which is what makes a +> macro-guarded `import` resolvable** + +**这句话对 mcpp 不成立。** `defines` 确实会进**编译器的** P1689 扫描(`.ddi`), +但 mcpp **自己的**前置扫描器在看到条件块里有 `import` 时就直接报错,根本走不到 +宏求值。用户按文档写一个宏保护的 `import`,拿到的是 `forbidden in M1`。 + +**这一条不需要设计,只需要把文档改对** —— 而且它是七条里唯一一个「文档承诺了一个 +不存在的能力」,优先级应当最高。 + +### 三档方案(按代价递增,建议只做前两档) + +**档 A(必做,零风险):把文档改对。** 说明 `defines` 到达的是编译器的扫描, +而 mcpp 的前置扫描器目前拒绝一切条件块内的 `import`。 + +**档 B(建议做):诊断可操作化。** 现在只说 "forbidden in M1"。改成: +* 指出**是哪个宏**在保护它; +* 若该宏出现在 `[build].defines` / `[targets.*].defines` 里,直接说 + 「这个条件是可判定的,当前未实现求值(见 #421)」; +* 若不在(例如 `__cpp_lib_stacktrace` 这种工具链内建宏),说 + 「依赖工具链内建宏,不可判定」。 + +代价很小,而它把「适配一个真实工程」的排查成本从「逐个文件试」降到「读一条消息」。 + +**档 C(不建议现在做):真正求值可判定的条件。** +若条件只依赖 `defines` 里出现过的宏,扫描器信息是足够的。但这意味着扫描器要引入 +一个**最小预处理器**(`#if` 表达式求值、`defined()`、嵌套、`#elif`),而 +「实现一个不完整的预处理器」的失败形态是**静默扫错依赖图**,不是报错。 +M1 的取舍(纯词法 + 硬拒)正是为了避免这个。要做也应当先把边界写死: +只支持 `#ifdef X` / `#ifndef X` / `#if defined(X)` 三种字面形态,别的一律仍然拒。 + +**头单元**:维持现状(拒绝)。头单元本身是泥潭,而 issue 的报告者也说 +「不是说 M1 的取舍不对」。但档 B 的诊断应当同时指出**替代写法** +(把 `import ;` 改成 GMF 里的 `#include `),这是他实际采用的做法。 + +### 附:一处诊断透传(优先级最低) + +`module : private;` 在 GCC 16 的 P1689 扫描路径下报 `module already declared` +(编译路径报的是 `sorry, unimplemented: private module fragment`)。这是 GCC 的 +消息差异,不是 mcpp 的缺陷。扫描器既然已经在做词法解析,识别出这一句并附一句 +「当前工具链未实现私有模块片段」能省掉一轮误导性排查。**可做可不做。** + +--- + +## 建议的顺序 + +1. **#421 档 A** —— 改一句文档,消除「承诺了不存在的能力」。零风险,最高性价比。 +2. **#422** —— MSVC 上 `host-coupled` 现在**根本不能用**,而修法很小且缓存键白送。 +3. **#416** —— ⚠️ **先复现**:用当前 mcpp 重建一个纯 C compat 包,确认 + `readelf -d` 里还有没有 `libstdc++.so.6`。症状若已不在,这条降级为 + 「清理一个不该在那里的对象」,并把 `-lstdc++` / `--as-needed` 另开一条。 +4. **#412** —— 两条删字 + 一条真正的覆盖补齐(注意编号用 235)。 +5. **#418** —— 死字段;⚠️ 注意两个同名字段只有一个该删。 +6. **#415** —— 可观测性,改动中等,判据明确(219 不再需要过滤 `$ORIGIN`)。 +7. **#417** —— **先探针再修**,不要照 issue 的推理直接动时序。 +8. **#421 档 B** —— 诊断可操作化。 +9. #421 档 C / 私有片段透传 —— 暂不做。 + +## 一条贯穿的观察 + +七条里有**四条**(412、418、421 档 A、415)本质是同一件事: +**写下来的状态与实际行为脱节** —— 过期的 note、不生效的字段、承诺了不存在能力的 +文档、自称唯一排序却漏了最关键一项的模型。它们都不是「功能缺失」,而是 +**「代码与它自己的说明书不一致」**,而这类问题的共同代价是:读的人据此做决定, +然后浪费掉的时间不会归因到这里。 diff --git a/.agents/docs/2026-08-15-issues-426-427-analysis.md b/.agents/docs/2026-08-15-issues-426-427-analysis.md new file mode 100644 index 00000000..f6a6cca2 --- /dev/null +++ b/.agents/docs/2026-08-15-issues-426-427-analysis.md @@ -0,0 +1,681 @@ +# #426 #427 与 main 当前红 —— 核实与修复方案(2026-08-15) + +**状态:分析 + 方案,待 review。三条全部在 HEAD 上实测复现,不是照抄 issue。** + +| # | 核实 | 性质 | 影响面 | 改动面 | +|---|---|---|---|---| +| 427 | 复现,且 ⚠️ **影响面比 issue 大得多** —— 与沙箱无关 | 可用性(硬失败) | Linux 上所有工具链安装路径 | 小 | +| 426 | 复现,且 ⚠️ **收益比 issue 说的大** —— 少的是三个 `NEEDED` 不是一个 | 分发正确性 | 纯 C / 纯汇编链接单元 | 中偏大 | +| main 红 | 复现,且 ⚠️ **守卫本身是错的** | CI 阻塞 | 三平台 e2e | 小 | + +三条互不依赖,可并行。#427 应优先:它是唯一的**硬失败**,且已发布三周。 + +--- + +## #427 —— 缺失的描述被升级成致命错误 + +### 一、核实 + +沙箱内复现(mcpp 2026.8.15.1): + +``` +Runtime SubOS 'default' does not describe itself: … (no `subos_info` block) … + Where the C runtime comes from a payload, there is now no declared runtime to + bind to — mcpp declines to guess a version, so the link falls back to the host + and the hermeticity check will say so. +Resolving toolchain +error: toolchain post-install fixup: cannot fix up gcc toolchain + '…/xim-x-gcc/16.1.0': default SubOS has no RuntimeBinding identity (…) +``` + +**输出自相矛盾。** 前三行完整描述了一套降级方案(「declines to guess … the +hermeticity check will say so」),第四行就地为同一个事实杀死构建。降级被设计过、 +被打印给用户,然后另一处调用点把它作废。 + +### 二、触发条件与沙箱无关 + +沙箱那份 `subos/default/.xlings.json`: + +``` +-rw-rw-r-- 1 speak speak 16 Jun 22 04:30 …/subos/default/.xlings.json +{"workspace":{}} +``` + +**16 字节,6 月 22 日** —— 一个早于 `subos_info` 写入器的 SubOS。宿主机上同一路径的 +文件有完整的块,所以宿主机正常。沙箱不是成因,它只是**保存了一个旧 SubOS**。 + +于是真实触发条件是:**默认 SubOS 由早于该块的 xlings 创建**。同样能达成的还有全新 +但未升级的 xlings、容器/CI 镜像里的旧 subos、任何未跑过 `xlings self update` 的机器。 +mcpp 自己的诊断文字就写着 `A newer xlings writes this block`,即它**已知**这是一个 +版本差,却按矛盾处理。 + +### 三、影响面(实测,均在同一沙箱) + +| 动作 | 结果 | +|---|---| +| `mcpp build` | ❌ `toolchain post-install fixup: …` | +| `mcpp build`,`allow_host_libs = true` | ❌ **同样失败** | +| `mcpp toolchain install gcc@16.1.0` | ❌ `post-install fixup failed: …` | + +两条推论: + +1. `allow_host_libs` 救不了 —— 致命判定发生在 hermeticity 策略**之前**且无条件, + 所以这不是「不够 hermetic 就拒绝」,是「不知道就拒绝」。 +2. 影响面不止 `mcpp build`。`lifecycle.cppm:587` 调用时**不传** `runtimeId`,因此 + `mcpp toolchain install` 在 Linux 上**必定**走这条兜底,即受影响的是所有工具链 + 安装路径。 + +### 四、对照实验:那道门是唯一的墙 + +只向沙箱的 `subos/default/.xlings.json` 补一个 `subos_info` 块(`runtime: +glibc@2.39`),其它一律不动: + +``` +Resolved gcc@16.1.0 → @mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/bin/g++ +Compiling ctl427 v0.1.0 (.) +Finished dev [unoptimized + debuginfo] in 0.11s +``` + +构建完全成功。所以沙箱内既非只读、也无第二道墙;**唯一的阻塞是这个 gate**。 +(实验后已还原该文件。) + +### 五、真因,三层 + +**第一层:同一个决定有两处推导。** + +绑定解析器按项目**选中的** SubOS 取路径: + +```cpp +// src/platform/runtime_binding.cppm:162 +if (selection.mode == Mode::McppDefault || selection.subosName == "default") + return cfg.xlingsHome() / "subos" / "default"; +return selection.ownerRoot / ".mcpp" / ".xlings" / "subos" / selection.subosName; +``` + +fixup 的兜底则**硬编码**: + +```cpp +// src/toolchain/post_install.cppm:545 +auto info = mcpp::xlings::subos::read(cfg.xlingsHome() / "subos" / "default"); +``` + +这不只是重复,而且**结果可以不同**:项目选了 `subos = "foo"` 时,兜底会拿 +`default` 的 glibc 去 patch 载荷。这是一条独立于 #427 的正确性缺陷。 + +**第二层:兜底违反它自己调用点写下的架构。** `prepare.cppm:953` 的注释: + +```cpp +// Resolve one exact runtime contract before resolving/fixing a toolchain. +// The fixup is itself a consumer of RuntimeBinding: doing it first would +// recreate #392 by letting directory order choose a libc and only later +// discovering what the project selected. +``` + +「fixup 是 RuntimeBinding 的消费者」是明确的设计,而 `subos/default` 兜底正是这段 +注释禁止的「让别的东西去挑 libc」。**这不需要新设计 —— 它是一次没做完的迁移**: +`runtimeId` 参数已经加上并已从四个调用点传入,旧的自行推导没有删。 + +**第三层:降级分支是死代码。** 被 gate 保护的函数**本来就正确处理空值**: + +```cpp +// src/toolchain/post_install.cppm:439 +if (!glibcLibDir.empty() && … ) { …patchelf… } +else { + mcpp::ui::warning("could not locate sandbox glibc/gcc/patchelf paths; " + "gcc-built binaries may have unresolved PT_INTERP/RUNPATH"); +} +``` + +外层 gate 保证它永远拿不到空值,于是这个 `else` 从未执行过。与 +`repair-placed-where-flow-never-reaches` 是同一形状。 + +### 六、为什么 `221_subos_without_info_still_builds.sh` 是假绿 + +221 正是为这条规则写的(「DATA THAT IS MISSING OR NEWER MUST NOT INVALIDATE THE +PROGRAM THAT READS IT」),却抓不到它: + +* 221 建的是**项目级** SubOS(`[xlings] subos = "bare"` → `.mcpp/.xlings/subos/bare`), + 而 fixup 读的是**硬编码的** `/subos/default`。两者不相交,所以 221 的 + 空 SubOS 对这条代码路径毫无作用。 +* 跑 221 的机器(开发机、CI runner)其 `default` 都描述得出自己,gate 永远通过。 +* 221 的绿色一半断言「加上 `allow_host_libs` 必须能构建」—— 这一条在 #427 的现场 + **实测是失败的**。即该断言是对的,只是从未指向出问题的 SubOS。 + +**教训**:一个「缺失必须降级」的测试,必须让缺失发生在**被读取的那个对象**上。 +测试建了一个 SubOS,被测代码读的是另一个。 + +### 七、方案 + +**A. 删掉第二处推导(核心)。** + +`ensure_post_install_fixup` 不再自行读 SubOS。运行时身份**只能**来自调用方传入的 +`RuntimeBinding` 快照。调用方拿不到,fixup 就拿不到 —— 这正是「fixup 是消费者」的 +含义。删除 `post_install.cppm:543-553` 整段兜底,`subos_info` 的 include 一并移除。 + +副产品:「项目选了 foo,却按 default 打补丁」这条缺陷随之消失,无需单独修。 + +**A′. ⚠️ `mcpp toolchain install` 必须补上解析,否则 A 会把它变成永久跳过。** +(自我 review 发现,见 R1。) + +`lifecycle.cppm:587` 调用时**不传** `runtimeId`,而 `toolchain_install(cfg, …)` 手里 +只有 `cfg`,没有任何 RuntimeBinding。单做 A,这条路径在 Linux 上会 `runtimeId` 恒空 +⇒ **永远跳过 fixup** —— 而它正是最需要 fixup 的路径(「without it a fresh-sandbox +glibc gcc cannot find the C library」)。 + +修正:`toolchain_install` 自己调用一次 `resolve_runtime_binding`(与 `prepare.cppm:962` +同一个解析器、同样的默认 selection),把 `runtimeId` 与 `libraryDirs.front()` 传下去。 +这比被删的兜底**更正确**:它尊重 selection、走统一的降级与 note,而不是硬编码 +`subos/default`。 + +**B. 未知降级,矛盾照旧失败。** + +* `runtimeId` 为空 ⇒ `glibcLibDir` 为空 ⇒ 直接调用 fixup 体,由那个已存在的 `else` + 发一次 warning 并跳过 patch。这恰好回到 2026.8.8.4 的行为,而二分表已证明该版本在 + 同一沙箱同一工程上成功。 +* `runtimeId` **非空但载荷缺失/版本对不上** ⇒ 仍然 `std::unexpected`。 + 这是矛盾不是缺失:身份被声明了却兑现不了,猜测会让同一份 mcpp.toml 在不同机器上 + 得到不同 ABI。`select_glibc_payload_lib` 现有的检查保持致命。 + +判据的分界线就是这一句:**未知降级,矛盾失败。** + +**C. 严重程度归调用方,不归被调方。** + +`ensure_post_install_fixup` 返回「做了什么 / 跳过了什么」,而不是自行决定生死: + +```cpp +struct FixupOutcome { bool applied; std::string skippedReason; }; +std::expected ensure_post_install_fixup(…); +``` + +* `mcpp build`(`prepare.cppm` 四处):`skippedReason` 非空时以 info 级说明一次 + ——「工具链未按运行时打补丁,原因 X;若后续报 `stdlib.h not found` 或 + hermeticity 失败,即由此而来」。构建继续。 +* `mcpp toolchain install`(`lifecycle.cppm:587`):用户显式要求安装,以 warning 级 + 报告并给出 `xlings self update` 这一条可操作指令,退出码仍为 0(安装本身成功了)。 + +同一事实两种量级是合理的;**不合理的是量级被写死在被调方**,让 `build` 无从选择。 + +**D. 顺带修一处诊断。** 现在的错误文本把用户指向 +`/home/speak/.mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0` —— 一个他不该写的目录。 +降级后的 warning 应指向真正的动作:`xlings self update`。 + +### 八、测试(两侧都要钉) + +新增 `tests/e2e/237_default_subos_without_info.sh`,`# requires: elf`: + +1. 构造一个**独立 MCPP_HOME**,其 `subos/default/.xlings.json` 写成 `{"workspace":{}}` + —— 即沙箱现场的字面复制。⚠️ 必须是 `/subos/default`,不是项目级 SubOS, + 否则重蹈 221 的假绿。 +2. 断言 `mcpp build` **成功**,且输出**说明**了降级(「一个没人打印的降级与没有降级 + 无法区分」)。 +3. 反向:把 `subos_info.runtime` 写成一个**不存在的** glibc 版本,断言构建**失败**, + 且消息说的是载荷缺失而非「无法描述自己」。这一条防止 A/B 把矛盾也放行。 +4. 断言 `mcpp toolchain install` 在同一 home 下退出 0。 + +单测 `tests/unit/test_post_install.cpp`:`ensure_post_install_fixup` 传入空 +`runtimeId` 时返回 `applied=false, skippedReason≠""`,而非 error。 + +### 九、判据 + +* 一个 `subos/default/.xlings.json` 只有 `{"workspace":{}}` 的 Linux 机器上, + `mcpp build` 与 `mcpp toolchain install` 均成功; +* 同一机器上 `git grep -n '"subos" / "default"' src/toolchain/` 无结果; +* `subos_info.runtime` 指向不存在的载荷时仍然失败; +* 宿主机(SubOS 描述完整)行为逐字节不变 —— 用 `build.ninja` 归一化 diff 核对。 + +--- + +## #426 —— 链接驱动应由内容决定 + +### 一、核实(实测) + +纯 C 共享库(`[targets.purec] kind = "shared"`,一个只含 C 函数的 `.c`): + +``` +build.ninja: rule cxx_shared + command = $cxx -shared @$out.rsp -o $out $ldflags … + cxx = …/xim-x-gcc/16.1.0/bin/g++ +``` + +同一个 `.o`、同一份 `ldflags`,只换驱动: + +| 驱动 | `NEEDED` | +|---|---| +| `g++` | `libstdc++.so.6` `libm.so.6` `libgcc_s.so.1` `libc.so.6` | +| `gcc` | `libc.so.6` | + +`nm -D --undefined-only` 显示该 `.so` 唯一像 C++ 的未定义符号是 +`__cxa_finalize@GLIBC_2.2.5` —— glibc 的弱符号,不来自 libstdc++。即三个 `NEEDED` +**全部**由驱动带入,零真实依赖。`purec_add` 在两种链接下都正常导出、可 `dlopen`。 + +**issue 低估了收益**:少的不是一个 `NEEDED`,是三个。 + +### 二、两条修法的决定性区别是可移植性 + +issue 列的两条中,`--as-needed` 不能作为主方案,理由与「优雅」无关: + +1. **它不跨平台。** `--as-needed` 是 GNU ld / lld 的特性。macOS 的 ld64 没有它 + (最接近的 `-dead_strip_dylibs` 语义不同且作用于整条链接线),MSVC 的 `link.exe` + 没有对应概念。选驱动则三平台都成立。 +2. **它治不了这个错误。** 一个纯 C 的库由 C++ 驱动链接,即使 `NEEDED` 被裁掉, + 命令行仍然是错的 —— C++ 驱动还会带入 `-lm`、C++ 的启动/异常段落与不同的默认库 + 顺序。`--as-needed` 只是把症状扫掉。 +3. **它的作用域会越界。** 现在全仓唯一一处 `--as-needed` 用的是 + `-Wl,--push-state,--as-needed -latomic -Wl,--pop-state`(`flags.cppm:240`), + 括起来正是为了把作用域限死。全局启用会波及用户显式命名的库,以及只被 `dlopen` + 使用的库 —— GPU/GL 那条链已经为此付过代价。 + +结论:**方案 1(按内容选驱动)。方案 2 不作为替代,可在方案 1 之后单独评估。** + +### 三、方案 + +**数据已经在,只是没有到达发射链接边的那一步。** `CompileUnit` 携带 +`mcpp::SourceKind kind`(`ModuleInterface` / `Cxx` / `C` / `GasAsm` / `NasmAsm`); +`LinkUnit` 只有 `std::vector objects`。 + +改动分三处,**换驱动只是其中一处** —— 只换驱动会半途而废,见 (3)。 + +**(1) 判定谓词,照 `unit_needs_std` 的形状写。** + +`ninja_backend.cppm:1169-1179` 已经有一个完全同形的先例:按 `cu.object` 建一张表, +再对 `lu.objects` 查表。照抄结构即可,**不需要给 `LinkUnit` 加字段**(加字段会牵动 +Plan 的序列化与缓存键,而这个决定只有发射时才用得到): + +```cpp +std::unordered_map objectIsCxx; +for (auto& cu : plan.compileUnits) + objectIsCxx[cu.object.generic_string()] = + cu.kind == SourceKind::ModuleInterface || cu.kind == SourceKind::Cxx; + +auto unit_needs_cxx_runtime = [&](const LinkUnit& lu) { + for (auto& o : lu.objects) { + auto it = objectIsCxx.find(o.generic_string()); + if (it == objectIsCxx.end()) return true; // ⚠️ 未知 ⇒ 保守 + if (it->second) return true; + } + return false; +}; +``` + +⚠️ **与 `unit_needs_std` 的关键差异是查不到时的取值。** 那个查不到取 `false` +(不链 `std.o`),这个查不到必须取 **`true`**。action 产出的对象 +(`prepare.cppm:5182` / `5420-5422`)不在 `plan.compileUnits` 里,语言未知,按 C 链 +会得到未定义符号。 + +**静态依赖不需要单独传播**:`kind = "lib"` 依赖的对象经 `append_package_objects` +(`plan.cppm:1547`)直接进入 `lu.objects`,其 `cu.kind` 就在表里。共享库依赖由动态 +链接器解决,其自身的 `NEEDED` 与本单元的驱动无关。 + +**(2) 规则选择,并同时堵住 `std.compat.o`。** + +`ninja_backend.cppm:1691-1710` 的 `switch` 按谓词选 `c_link` / `c_shared` +(用 `$cc`,该变量已存在)或 `cxx_link` / `cxx_shared`。`cxx_archive` 走 `ar`, +与驱动无关,不变。 + +⚠️ **同一个 `switch` 里 `std.compat.o` 没有收窄**(自我 review 发现,见 R4): + +```cpp +if (has_std_artifacts && unit_needs_std(lu)) ins += std_o_dst; // #423 已收窄 +if (has_std_compat) ins += compat_o_dst; // ← 无条件 +``` + +`std.compat.o` 由 C++ TU 编出,链进纯 C 单元会带来**真实**的 libstdc++ 依赖。实测配置 +下 `has_std_compat` 为 false 故当前不发作,但这是 #416 修了一半留下的不对称。方案 B +必须一并处理:C 链接单元**既不收 `std.o` 也不收 `std.compat.o`**,且 `std.compat.o` +补上与 `std.o` 相同的 `unit_needs_std` 收窄。 + +**(3) ⚠️ `ldflags` 里的 C++ 专属 token 必须同时不发。** + +只换驱动是不够的 —— `flags.ld` 与 `unit_ldflags` 都可能带 C++ 运行时 token: + +| token | 来源 | C 链接下的后果 | +|---|---|---| +| `-lstdc++exp` | `flags.cppm:965`(MinGW + libstdc++) | **显式命名的库,照样链进去** —— 修复失效 | +| `-stdlib=libc++` | `linkmodel.cppm:178/189` | clang 警告并忽略 | +| `--rtlib=compiler-rt --unwindlib=libunwind` | `linkmodel.cppm:189` | 改变 C 链接的运行时选择 | +| `-static-libstdc++` | 契约表经 `unit_ldflags`(`dist::Format::Pe`) | gcc 接受但无意义 | + +做法:**让 `compute_flags` 一次算出两份 ld**,即 `CompileFlags` 增加 `ldC`,与 `ld` +在同一个函数体内并行产生;`ninja_backend.cppm:485` 发两个全局 +`ldflags` / `c_ldflags`。**`ld` 的计算路径一个字符不动**,所以「C++ 链接命令逐字节 +不变」这条反向判据是由构造保证的,而不是靠测试碰运气。 + +`unit_ldflags` 侧同理:契约表按 `dist::Format` 取值时一并按链接语言取。 +⚠️ **只去掉 C++ 运行时那几项**(见 R5):MinGW 的 `-static` 也来自契约表,但它表达的是 +PE 的「自包含」语义、与语言无关,C 单元**必须保留**它。 + +**跨平台**: + +| 平台 | 现状 | 变化 | +|---|---|---| +| Linux / MinGW (gcc) | `g++` | 纯 C 单元改 `gcc`;MinGW 另需去掉 `-lstdc++exp` | +| macOS (clang) | `clang++` | 纯 C 单元改 `clang`,并去掉 `-stdlib=libc++` 等三项 | +| MSVC | `separateLinker` 走 `$ld`(link.exe) | **无变化** —— 驱动不参与链接。加一条断言把这个事实钉住 | + +**不新增开关。** 需要强制的用户已经有 `ldflags = ["-lstdc++"]`。多一个 +`linker_language` 键就是多一处可以与真相不一致的声明。 + +**规模修正**:因 (3),改动面从「中」上修为「中偏大」。若要拆两步,(1)+(2) 单独落地 +在 Linux/gcc 上即可得到实测的全部收益(该配置下 `ldflags` 不含任何 C++ token), +但**不可在 macOS / MinGW 上只做 (1)+(2)** —— 那会得到一个「换了驱动却仍带 +`-lstdc++exp`」的半吊子状态,比不改更难诊断。 + +### 四、边界与风险 + +* **纯 C 目标静态链接了一个 C++ 静态库**:传递规则(第 2 条第二项)覆盖已声明的 + 依赖。若用户用裸 `ldflags = ["-lfoo"]` 引入一个 C++ 库,则会得到未定义 `_Z…` —— + **响亮失败,不是静默错误**,且可由用户加 `-lstdc++` 解决。这是可接受的方向。 +* **不影响 `import std`**:模块接口单元的 `kind` 是 `ModuleInterface`,必然 true。 +* 与 #416 的 `std.o` 收窄正交:那条管「链不链这个对象」,这条管「用哪个驱动」。 + issue #426 已明确证否「症状源自 `std.o`」。 + +### 五、判据(两侧) + +* 纯 C 共享库工程 `readelf -d` 中**没有** `libstdc++.so.6` / `libm.so.6` / + `libgcc_s.so.1`,且导出符号与可 `dlopen` 性不变; +* 一个 C++ 工程的链接命令与产物**逐字节不变**(归一化 diff `build.ninja`); +* 一个「纯 C 可执行 + 依赖一个 C++ 共享库」的工程仍能链接并运行 —— 传递规则的正向判据; +* macOS 上纯 C 单元的 `otool -L` 不含 `libc++`; +* MSVC 上 `build.ninja` 的链接规则**无变化**。 + +--- + +## main 当前红 —— bench 参照版本与 bootstrap pin 的耦合 + +### 一、核实 + +`bb53e81`(发版收尾的 pin bump)后,三平台 e2e 全红,失败点同一处: + +``` +FAIL: bench/matrix.json + reference_mcpp=2026.8.11.3 but .xlings.json bootstraps mcpp 2026.8.15.1 — + the reference arm IS the bootstrapped binary, so these two must agree … +``` + +`.xlings.json` 被 bump 到 2026.8.15.1,`bench/matrix.json` 的 `reference_mcpp` +留在 2026.8.11.3。守卫是 #423 加的,由 `tests/e2e/233_bench_matrix.sh:269` 触发。 + +### 二、⚠️ 守卫本身是错的 + +它断言的危险 —— 「the old column silently becomes some other release」—— +**已经被 `run-standard.sh` 自己防住了**: + +```sh +# bench/run-standard.sh:125-136 +for c in "$HOME"/.xlings/data/runtimedir/mcpp-"$REFERENCE_MCPP"-*/mcpp; do + got="$("$c" --version …)" + if [ "$got" = "$REFERENCE_MCPP" ]; then REF_BIN="$c"; break; fi + echo " note: $c reports '$got', not '$REFERENCE_MCPP'; ignoring it" +done +[ -n "$REF_BIN" ] || echo " note: no mcpp@$REFERENCE_MCPP binary found; …" +``` + +参照臂按**精确版本**取路径,并要求二进制**自述**该版本,取不到就明说列缺失。 +两个 pin 不一致时不会量错东西,只会在没装那个版本时少一列。 + +而守卫断言的前提「the reference arm IS the bootstrapped binary」不成立:bench 标准集 +不在任何 workflow 里(`grep -rn run-standard .github/workflows` 无结果),它在开发机上 +手工跑,`runtimedir` 下常年并存多个版本。参照臂是**被显式点名**的那个,不是被 +bootstrap 装的那个。 + +更根本的一点:bootstrap pin 是**自举起点**,判据是上界而非义务,可以合理滞后于最新 +发布;bench 的参照臂语义是「上一个已发布版本」。把两者钉成相等,是给一个 bench 旋钮 +强加了发布流程的约束。 + +### 三、方案 + +1. **删除 `233_bench_matrix.sh:262-272` 的跨文件相等断言。** 保留同文件里两条真实的 + 不变量:`reference_mcpp` 必须是精确版本;`matrix.json` 的编译器 pin 必须与 + `bench/src/toolchain.cppm` 一致(那一条是真耦合 —— 一处决定装什么、一处决定要哪个 + 路径,不一致会让每个 cell 报一个路径错误)。 +2. **让报告自述它实际量到的版本。** `report.py:237` 现在把列名写死成 `mcpp (old)` / + `mcpp (旧版)`,应改为显示实测版本(`mcpp 2026.8.11.3`);README pin 表里的那一行 + 同样由生成而非手写。 + + ⚠️ **不能改 engine 标签**(自我 review 发现,见 R6)。journal 的 cell 只有 + `engine / compiler / profile / scenario`,参照臂的 engine 标签就是 `mcpp` + (`run-standard.sh:280`),不含版本;而 **engine 标签是 resume 的键**,改它会让 + 已有 journal 全部失效、整套数据重跑。修正做法:`run-standard.sh:131` 已经拿到并 + **校验过**该二进制自述的版本(`$got`),把它连同解析出的路径写进 journal 旁的 + `meta.json`,`report.py` 从那里取。纯增量,不动 resume 键。 + +第 2 条是把守卫想买的性质**结构化地**买到:漂移不再靠断言拦截,而是无处藏身 —— +报告直接写着它量的是哪个版本。 + +### 四、判据 + +* main 三平台 e2e 恢复绿; +* `matrix.json` 与 `.xlings.json` 故意写成不同版本时,`233` 通过,而生成的报告表头 + 显示 `matrix.json` 里那个版本; +* 删掉 `runtimedir` 下的参照二进制后,报告显示该列缺失而非显示错误数据。 + +--- + +## 任务依赖与顺序 + +``` +main 红(独立,最短) ──► 先做,恢复 CI 信号 +#427(独立,硬失败) ──► 并行,优先级最高 +#426(独立,中偏大) ──► 并行 +``` + +三条无共享代码路径:#427 在 `src/toolchain/`,#426 在 `src/build/{plan,ninja_backend,flags}`, +main 红在 `tests/e2e/` 与 `bench/tools/`。可在同一 PR 内并行实现,分三组 commit。 + +## 反向判据(防止修过头) + +* #427:宿主机(SubOS 完整)的 `build.ninja` 与产物必须**逐字节不变**。 + 若变了,说明改动动到了正常路径,而不只是缺失路径。 +* #426:C++ 工程的链接命令必须**逐字节不变**。 +* main 红:必须能构造出「两个 pin 不同」且 `233` 通过的情形,否则说明只是把断言挪了地方。 + +--- + +# 自我 review —— 方案自身的缺陷与修正 + +对上面三份方案逐条找洞,每条都对 HEAD 核实过。**其中 R1 是方案 A 的真缺陷:按初稿 +实施会引入一个新的、更隐蔽的问题。** + +## R1 ⚠️ 方案 A 会让 `mcpp toolchain install` 永久跳过 fixup + +`toolchain_install(const GlobalConfig& cfg, …)`(`lifecycle.cppm`)手里只有 `cfg`, +没有任何 RuntimeBinding,`:587` 因此**不传** `runtimeId`。删掉兜底之后,这条路径在 +Linux 上 `runtimeId` 恒空 ⇒ 永远走降级分支 ⇒ **永远不打补丁**。 + +而 fixup 的存在理由正是这条路径:「without it a fresh-sandbox glibc gcc cannot find +the C library (stdlib.h not found)」。初稿等于把一个硬失败换成一个静默的坏安装 —— +比原缺陷更难诊断。 + +**修正(已并入方案 A′)**:`toolchain_install` 自己调用一次 `resolve_runtime_binding`, +与 `prepare.cppm:962` 用同一个解析器,把 `runtimeId` 与 `libraryDirs.front()` 传下去。 + +**教训**:「删掉重复推导」只在**所有**调用方都能提供那个事实时成立。删之前必须逐个 +调用方核对谁能提供 —— 五个调用点里有一个不能。 + +## R2 降级面比担心的窄(核实结论,方案不变) + +初稿的隐忧:健康机器上 `runtimeId` 若也可能为空,删掉兜底就成了回归。 + +核实:`resolve_runtime_binding` 在缺 `subos_info` 时**不早退** +(`runtime_binding.cppm:277`「CONTRADICTION vs ABSENCE」那段),后面还会用 +`/lib*/libc.so.6` 的物理链接反推身份(`managed_glibc_identity`,`:352`)。 +所以只有**既无声明、又无物理 libc** 的 SubOS 才会得到空身份。 + +沙箱现场正是如此 —— 那个 subos 的 `lib/` 里**没有** `libc.so.6`,物理推导也无从下手。 +即降级路径只覆盖真正一无所知的情形,不会波及正常机器。 + +## R3 降级时的幂等标记(补充,免费的正确性) + +降级写出的 `.mcpp-fixup.json` 其 `glibcLib` 为空,与打过补丁的指纹不同 ⇒ 用户后来跑 +了 `xlings self update`、身份可知之后,fixup 会**自动重跑**。这条性质由现有的内容 +指纹机制免费提供,但必须在测试里钉住,否则将来有人「优化」成布尔标记就会退化成 +「补好了 xlings 却仍不打补丁」。 + +## R4 ⚠️ `std.compat.o` 仍是无条件的 —— #416 只修了一半 + +`ninja_backend.cppm:1697 / :1707`: + +```cpp +if (has_std_artifacts && unit_needs_std(lu)) ins += std_o_dst; // #423 已收窄 +if (has_std_compat) ins += compat_o_dst; // ← 无条件 +``` + +`std.compat.o` 由 C++ TU 编出,链进纯 C 单元会带来**真实**的 libstdc++ 依赖(不同于 +#426 那个纯粹由驱动带入、可完全去掉的)。实测配置下 `has_std_compat` 为 false,故 +当前不发作 —— 但这是 #423 留下的不对称,方案 B 必须一并收窄,否则修好了驱动却在另 +一个配置上重新引入同一个症状。已并入方案 B (2)。 + +## R5 MinGW 的 `-static` 不是 C++ 运行时项 + +方案 B (3) 初稿说「C 单元拿不到 C++ 运行时那几项」,措辞过宽:契约表(`dist::Format::Pe`) +同时提供 `-static`,它表达的是 PE 的自包含语义、与语言无关。C 单元丢掉它会破坏 +`build-mcpp-helper-self-containment` 记的那条结论。已在方案 B (3) 收窄措辞。 + +## R6 ⚠️ 方案 C 的「从 journal 读版本」不成立 + +核实:journal 的 cell 只有 `engine / compiler / profile / scenario` +(`report.py:74`),参照臂的 engine 标签就是 `mcpp`(`run-standard.sh:280`),不含版本。 +而 **engine 标签是 resume 的键** —— 把它改成 `mcpp@2026.8.11.3` 会让所有已有 journal +失配、整套标准集重跑,代价远大于收益。 + +修正:`run-standard.sh:131` 已经取到并**校验过**该二进制自述的版本,写进 journal 旁的 +`meta.json` 即可,`report.py` 从那里取。纯增量,不动 resume 键。已并入方案 C2。 + +## R7 方案 A3 的提示会重复打印 + +一次 `mcpp build` 里 `ensure_post_install_fixup` 最多被调四次(manifest 工具链、 +默认工具链、MinGW 首次、build.mcpp host 工具链)。降级提示要按 `(kind, payloadRoot)` +去重,否则同一条消息刷四遍 —— 与 #417 那半个「说一次就够」是同一条规矩。 + +## R8 顺序与并行性不变 + +R4 让方案 B 多收一处 `std.compat.o`,但仍不与 A / C 争同一个文件。三条依旧可并行, +只是 B 内部多一步。 + +--- + +## 自我 review 的结论 + +| 编号 | 性质 | 处置 | +|---|---|---| +| R1 | **方案缺陷** —— 会引入更隐蔽的新问题 | 已改方案(新增 A′) | +| R6 | **方案缺陷** —— 做法不可行且代价大 | 已改方案(C2 改为 `meta.json`) | +| R4 | **遗漏** —— 方案不完整 | 已并入方案 B (2) | +| R5 | 措辞过宽,会误删 | 已收窄方案 B (3) | +| R3 / R7 | 补充,防止将来退化 | 已并入方案 A | +| R2 | 隐忧证否,方案不变 | 记录核实结论 | + +三条方案在改动后自洽。**A′ 是必须与 A 同时落地的**,单做 A 会造成回归。 + +--- + +# 实施记录 —— 与方案不同的地方(2026.8.15.2) + +方案在实施中被现实修正了四处。记在这里,因为「方案说 X,代码做了 Y」不写下来就 +是下一个人要重新发现的东西。 + +## I1 ⚠️ R6 的前提错了:engine 键**本来就带版本** + +R6 断定 journal 不含参照臂的版本、只能另写 `meta.json`。核实代码后:mbench 按 +**二进制自述的版本**给每条臂命名(`report.py` 见到的是 `mcpp@2026.8.11.3`),版本 +一直都在键里。`short_name` 只是把它丢掉了。 + +所以列名改动是三行:`return "mcpp " + base[len("mcpp@"):]`,不需要新数据源。 +`meta.json` 仍然写,但作用变小且不同 —— 它记的是**请求 vs 实际解析到的路径**, +即「matrix.json 要 2026.8.11.3,这台机器上解析到了/没解析到」,这是 engine 键 +表达不了的。 + +## I2 e2e 到不了 fixup 的门,单测才行 + +方案 A 的测试计划写的是 e2e 构造一个 `subos/default` 缺描述的 MCPP_HOME。写出来 +才发现:任何低成本 e2e 都用符号链接继承工具链载荷,而 `ensure_post_install_fixup` +**第一件事**就是对继承载荷提前返回(「owner is responsible for its fixup」)—— +被测代码一行都执行不到。 + +这正是 §六里 221 的形状,差点原样重演一次。改为: + +* `tests/unit/test_post_install.cpp` —— 载荷是测试自己 registry 里的真实目录, + 门的四个分支(降级/矛盾/不留标记/无需 fixup)逐条钉住; +* `tests/e2e/237` —— 只钉用户可见的契约:构建不再死在 fixup、`allow_host_libs` + 能到链接、`toolchain install` 退出 0。 + +## I3 `[targets.X] sources` 不隔离同包对象 + +方案 B 的 e2e 初稿在**一个工程里**放纯 C、混合、C++ 三个 target。实测:同一个包的 +对象会进入该包每一个链接单元 —— `libpurec.so` 拿到了 `mixed_cxx.o`,于是「纯 C 单元」 +在这个布局下根本不存在。改成三个独立工程。 + +## I5 ⚠️ 237 的绿色一半断言了**机器的属性**,不是 mcpp 的行为 + +初版的绿色一半是「加上 `allow_host_libs = true` 必须能构建」。本机绿,**CI 红**: + +``` +ld: cannot find crt1.o / crti.o / -lm +``` + +落回宿主需要宿主真有一份 C 运行时,而 runner 上一个都没有。方向与 +`shared-gcc-payload-specs-poisoning` 记的那次相反 —— 那次是本机有历史污染,这次是 +**本机有 runner 没有的宿主开发文件**。两者共同点:断言依赖了运行机器。 + +改成不依赖机器的对照:**同一个 home、同一批载荷、同一个工程,只给 SubOS 补上 +`subos_info` 块**,必须构建成功。这同时更强 —— 它证明第 1 部分的失败确实只是降级, +而不是这个环境本来就编不出东西。 + +绑定哪个 glibc 也不能猜:向真实 home 的 `subos_info.runtime` 要,取不到才回落到目录 +扫描。装了两个 glibc 载荷的机器上,`ls | head -1` 会按字母序挑中工具链没有针对它打过 +补丁的那个,让对照因为与被控变量无关的原因失败。 + +## I4 降级时**不写** marker + +方案 A2 原说「降级写出的 marker 其 `glibcLib` 为空,身份可知后会自动重跑」。实现时 +改为**根本不写 marker**:让「什么都没做」有机会被读成「已经做过」是不必要的风险, +而不写 marker 的代价只是每次构建重新判断一次(纯内存)。单测 +`ASkippedFixupLeavesNoMarkerBehind` 钉住这一点。 + +--- + +# 生态真实验证记录(2026.8.15.2 / 2026.8.15.3) + +全部在 `xlings subos use --sandbox` 与宿主机上跑,不是推理。 + +| 项 | 结果 | +|---|---| +| #427 沙箱硬失败 | ✅ 消失。构建推进到真正的位置,诊断链完整:原因 → 后果 → 补救 | +| #427 全新 SubOS | ✅ `mcpp run` → `fresh-subos-ok`(binding 解析到 `glibc@2.44`) | +| #426 纯 C 共享库(发布版产出) | ✅ `NEEDED` 只剩 `libc.so.6`,规则为 `c_shared` | +| 正常 C++ 路径 | ✅ `mcpp run` → `eco-ok` | +| 两端镜像 | ✅ GitHub / GitCode 四件逐字节相同(两个版本都核过) | +| 索引 | ✅ `latest` = 2026.8.15.3 | + +## ⚠️ 验证抓到的东西:mcpp 给出的补救不成立 + +2026.8.15.2 修好硬失败之后,那条降级建议**第一次真正被用户看到**,也才第一次被验证: + +``` +xlings self update # 2026.7.27.3 -> 2026.8.14.1 +→ subos_info 仍然不存在 +xlings self doctor --fix # ▸ healed 129 +→ subos_info 仍然不存在 +``` + +`xlings subos` 没有 repair / refresh 子命令(`new use list remove info stop`),即 +**已存在的 SubOS 没有任何受支持的途径拿到这个块**;新版 xlings 只在**创建**时写它。 + +已开 openxlings/xlings#547,并在 2026.8.15.3 把三处措辞改成陈述事实并指向它。 + +**这句话不是这次新加的** —— 它出自 xlings 写进块里的原始措辞,mcpp 一直在转述。 +把「上游这么说」当成「这是对的」转述给用户,和自己写错一样。而它之所以能一直没被 +发现,恰恰是因为在它前面有一道更早的硬失败挡着:**修好一个缺陷会让它后面那些从未 +被执行到的路径第一次暴露出来**,所以发布后的真实验证不是形式。 + +## 发布运维:GitCode 上传两次都撞 10 分钟步骤上限 + +两次发布的 `publish-ecosystem` 都因 mirror 步骤超时而失败: + +* 2026.8.15.2 —— 八件其实**全部传完**(422s / 256s / 579s / 398s),超时发生在最后一件 + 之后,属于假失败。核验后直接 rerun 补索引 PR。 +* 2026.8.15.3 —— macOS 那件(7.4MB,最大)确实没传上去。本地 `gtc release upload` + 补传(首次 502,重试即成),再 ranged-GET + 两端 `cmp` 核验,然后 rerun。 + +⚠️ **判据必须是 ranged GET,不是 HEAD** —— `mirror_res.sh` 自己写着 GitCode 的 HEAD +会撒谎。我第一次用 `curl -I` 得到全部 401,险些据此判定「GitCode 一件都没上去」。 diff --git a/.agents/docs/2026-08-15-module-edit-granularity.md b/.agents/docs/2026-08-15-module-edit-granularity.md new file mode 100644 index 00000000..cb4f3eb0 --- /dev/null +++ b/.agents/docs/2026-08-15-module-edit-granularity.md @@ -0,0 +1,137 @@ +# 改一处实现,重编多少?—— 模块写法、编译器、与 BMI 的实际行为(2026-08-15) + +**状态:实测结论。每一条都有对照,推翻的中间结论也记在这里。** + +被测:`mcpp 2026.8.13.1`(本分支自举产物)· gcc 16.1.0 / llvm 22.1.8 · Linux x86_64 + +--- + +## 0. 一句话 + +> **让「改实现不级联」这件事跨编译器成立的,只有把定义放进 `.cpp` 实现单元。 +> 在单个 `.cppm` 里把声明和定义分开写,对增量构建零收益。** + +--- + +## 1. 三种写法的对照(8 个导入者,只改函数体) + +| 写法 | gcc:重编 object | clang:重编 object | +|---|---|---| +| ① 单 `.cppm`,声明+定义写在一起 | 1 / 11 | **9 / 12** | +| ② 单 `.cppm`,**声明与定义分开写** | 1 / 11 | **9 / 12** | +| ③ `.cppm` + `.cpp` 实现单元 | 2 / 12 | **2 / 13** | + +**clang 上 ② 与 ① 完全一样。** 边界在**翻译单元**,不在文件内的位置:模块接口 +单元里的一切都属于同一个 TU,clang 把其中的定义序列化进 BMI,与它写在第几行无关。 +实现单元(`module X;`,无 `export`)是**另一个 TU**,内容不进任何 BMI,因此没有 +任何导入方能依赖它。 + +gcc 上 ①② 便宜,是因为 gcc 不把非模板函数体写进 BMI —— 那是**实现选择,不是语言 +保证**,而 clang 不这么做。 + +--- + +## 2. ⚠️ gcc 的「便宜」比看起来窄得多:插一行就级联 + +§1 里 gcc 的 1/11 是用**同行替换**测出来的(`a+=i%3` → `a+=i%3+1`)。改成**插入 +一行**——也就是日常写代码的样子——结论翻转: + +| 编辑 | 后续声明是否移位 | 重编 object | BMI 重写 | +|---|---|---|---| +| 同行替换,不改行数 | 否 | 1 / 11 | 0 | +| 在**函数体内**插一行 | 是 | **10 / 11** | **9** | +| 在**声明之间**插一行 | 是 | **10 / 11** | **9** | +| 在**文件末尾**追加一行 | 否 | 1 / 11 | 0 | + +**真因:GCC 的 BMI 记录后续声明的源码行号。** 只要插入使它们移位,BMI 就变, +级联就发生 —— 与被改函数体的语义无关。追加到文件末尾之后没有任何声明,所以不变。 + +于是 §1 gcc 那一列要这样读:**只有当编辑不改变行数时才成立**,而增删一行代码是 +常态。**实际开发中,gcc 上单 `.cppm` 同样会级联。** + +`.cppm` + `.cpp` 不受影响:实现单元根本不产 BMI,行号移位无处可去。 + +--- + +## 3. 这解释了 mcpp 自己 `edit-body` 为什么是 80 秒 + +基准的 `edit-body` 扰动是**插入**一条语句(`insert_into_first_body`),不是同行替换。 +在 `mcpp-2026.8.11.3/src/version_req.cppm` 上,插入点落在 `Version::str()` 的 +`for` 循环里,其后还有大量声明 —— 于是 BMI 变了,整图级联,80.87s。 + +* BMI 确实变了:`mcpp bmi-equal` 判为**不等价**; +* 对照成立:**同一份源码编两次,BMI 逐字节相同**(没有时间戳噪声)。 + +**mcpp 没有缺陷。** BMI 真的不同,restat 正确地拒绝抑制。级联的原因是**行号移位**, +不是「函数体的语义变了」。 + +--- + +## 4. ⚠️ 生成的 fixture 没有复现这个行为 + +标准集里 gcc/fixture 的 `modules` × `edit-body` 是 **0.94s**(不级联),而 mcpp 真实 +工程是 **80.87s**。同一个场景名,差两个数量级。 + +原因:fixture 的 `unit_0.cppm` 里,被扰动的函数是**文件中最后一个声明**,插入点之后 +没有任何声明可以移位,所以 BMI 不变。 + +**fixture 在这一点上不保真**,而这正是 `--project` 模式存在的理由。任何从 fixture +的 `edit-body` 推出的关于真实工程的结论都不成立。 + +--- + +## 5. 同一个场景名在量两件相反的事 + +`edit-body` 按 variant 改的文件不同(`bench/src/fixture/generate.cpp`): + +| variant | 改的文件 | 实际问的问题 | +|---|---|---| +| `headers` | `unit_0.cpp` | 普通 TU,必不级联 | +| `modules` | `unit_0.cppm` | 接口单元 → **可能级联,取决于是否移位 / 编译器** | +| `modules-impl` | `unit_0_impl.cpp` | 实现单元 → **必不级联** | + +`modules` 与 `modules-impl` 的期望结果是**相反**的,却共用一行。文档必须写明, +否则读者会把两个不同的问题当成同一个指标的两次测量。 + +--- + +## 6. 被推翻的中间结论(留档,免得重走) + +1. **「GCC 16.1 的 BMI 不含函数体 ⇒ 改函数体不必级联」** —— 只对**不改行数**的编辑 + 成立。之前那条记录是用同源两编 + 同行替换得到的,不能推广到日常编辑。 +2. **「`Version::str()` 是导出类的成员函数,所以 GCC 把它序列化进 BMI」** —— 错。 + §1 的 ② 直接反证:成员函数体同行替换不级联。真因是行号移位(§2)。 +3. **「插入一行只要不动 `export` 声明就没事」** —— 错。移位的是**声明的位置**, + 不是声明的内容。 + +--- + +## 7. 方法学:这一轮差点得出三个错误结论 + +| 差点错在哪 | 怎么发现的 | +|---|---| +| `sed '2a\'` 空追加在这台机器上是**空操作**,于是「插空行不级联」 | 打印**改动前后的行数**,发现 6→6 | +| 插入的注释把同一行剩下的 `} return a; }` **注释掉了**,代码不成立,`object=1` 是构建失败的假象 | 每次测量都**断言构建成功**,而不只看重编了几个 | +| 用同行替换代表「改函数体」,得出 gcc 不级联 | 换成与基准**同形**的插入扰动,结论翻转 | + +三条都是同一个形状:**扰动本身没有被验证**。测「改一处会重编多少」时,必须先证明 +那一处真的按预期被改了,且改完仍然能编。 + +--- + +## 8. 结论与待办 + +**给写模块的人:** + +* 要「改实现不级联」,把定义放进 **`.cpp` 实现单元**。这是唯一跨编译器成立的办法。 +* 在单个 `.cppm` 里分开写声明和定义是**代码组织**上的整洁,增量构建上**零收益**。 +* gcc 上单 `.cppm` 看起来便宜,只在编辑不改行数时成立。 + +**给这个套件:** + +- [ ] SPEC 写明 `edit-body` 按 variant 改的是哪个文件,以及两者期望相反(§5) +- [ ] README 修正:gcc 上 `edit-body` 的级联来自**行号移位**,不是「接口变了」(§3) +- [ ] fixture 的 `modules` 变体应在被扰动函数之后放置声明,否则不保真(§4); + 改了会让已发布的 `edit-body` 数据不可比,需要重测并声明 +- [ ] 考虑增加一个**不改行数**的扰动(如 `edit-body-inplace`),把「语义变了」与 + 「行号移了」分开量 —— 现在它们混在一行里 diff --git a/.agents/docs/2026-08-15-xmake-clang-import-std.md b/.agents/docs/2026-08-15-xmake-clang-import-std.md new file mode 100644 index 00000000..0e25a6ac --- /dev/null +++ b/.agents/docs/2026-08-15-xmake-clang-import-std.md @@ -0,0 +1,95 @@ +# xmake + clang 的 `import std`:错误消息把人指向了死路(2026-08-15) + +**结论先说**:xmake 那句 `maybe try to add --sdk=` 在这个场景下是 +**死路**。真正的开关是 **runtime**,而 `--sdk` 只在 runtime 已经把库判定成 libc++ +之后才会被读到。为此我试了三轮 `--sdk`,全部落空。 + +--- + +## 1. 现象 + +``` +warning: std and std.compat modules not found! maybe try to add --sdk= or install libc++ +error: missing std dependency for module mcpp.build.flags +``` + +在 Linux + 载荷 clang(`xim-x-llvm/22.1.8`)+ 真实工程上稳定复现;同一个 clang 在 +生成的 fixture 上没有问题,同一个工程用 gcc 也没有问题。 + +## 2. 真因(读 xmake 源码得到,不是推测) + +`rules/c++/modules/support.lua`: + +```lua +function get_cpplibrary_name(target) + if target:has_runtime("c++_shared", "c++_static") then return "c++" -- libc++ + elseif target:has_runtime("stdc++_shared", ...) then return "stdc++" -- libstdc++ + ... + -- 没有指定 runtime 时,按平台回落 + elseif target:is_plat("linux", ...) then return "stdc++" +end +``` + +`rules/c++/modules/clang/support.lua` 再按这个值分支: + +```lua +if cpplib == "c++" then + -- 找 libc++.modules.json;找不到才提示 --sdk +elseif cpplib == "stdc++" then + -- 委托给 gcc 的实现 +end +wprint("std and std.compat modules not found! maybe try to add --sdk=...") +``` + +我们的工具链**从未声明 runtime**,所以 Linux 上回落成 `stdc++` —— xmake 跑去 +**LLVM 载荷里**找 GCC 的 modules.json,当然没有,于是走到函数末尾那句通用警告。 + +**`--sdk` 只在 `c++` 分支里被读到。** 我们从来没进过那条分支,所以传它没有任何 +效果 —— 而消息本身就是这么建议的。 + +⚠️ **这是"错误消息指向了一个它自己没走到的分支"**:那句话是函数**末尾**的兜底 +warning,不属于任何一个分支,却引用了只有其中一个分支才用得到的选项。 + +## 3. 有效的组合(实测) + +| 尝试 | 结果 | +|---|---| +| `--sdk=` | 无变化 | +| 内建 `llvm` 工具链 + `--sdk` | 无变化 | +| target 上 `set_runtimes("c++_static")` | 无变化(自定义 standalone 工具链拿不到这套接线) | +| 配置层 `--runtimes=c++_static` + 自定义工具链 | 无变化 | +| **内建 `llvm` 工具链 + `--sdk` + `--runtimes=c++_static`** | **警告消失,开始编译模块 BMI** | + +载荷里 `lib//libc++.modules.json` **一直存在** —— 正是 `c++` 分支要找的 +那个文件。 + +已落到 `bench/src/engines/xmake.cppm`:载荷 clang 走内建 `llvm` 工具链并同时传 +`--sdk` 与 `--runtimes`。 + +## 4. 推进之后撞到的下一个问题(真上游缺陷) + +``` +.../include/c++/v1/__format/format_functions.h:99:30: + error: call to implicitly-deleted default constructor of + 'formatter, wchar_t>' + ... + note: in instantiation of 'std::basic_format_string' +``` + +**这与 mcpp 自己修过的是同一个缺陷**,见 +`.agents/docs/.../clang-precompile-emits-full-bmi`:clang 的 `--precompile` 发的是 +**full BMI**,把它发布给下游会让 clang 22 编错一个下游 TU —— 窄格式串报 +`formatter<..., wchar_t>`,报错点在 std 头文件里,离真因很远。mcpp 的解法是 +`-Xclang -emit-reduced-module-interface`。 + +xmake 的 clang 模块实现目前发布的是 full BMI,所以真实工程上会撞到同一个坑。 +**这一条是上游的**,但现在被精确定性了,而不是"xmake 不行"。 + +## 5. 给下一个人的判据 + +* **不要按错误消息的建议行动,先确认那条建议属于哪个分支。** 这次的建议在兜底 + warning 里,而兜底 warning 按定义是"所有分支都没走成"。 +* **xmake 选 std 模块看的是 C++ 库,而库是从 runtime 推的。** 用 clang + libc++ + 时,`--runtimes=c++_static`(或 `c++_shared`)是必需的,不是可选优化。 +* **自定义 `standalone` 工具链拿不到 runtime 的接线。** 需要 runtime 语义时用内建 + 工具链(`llvm`),把载荷通过 `--sdk` 指进去。 diff --git a/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md b/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md new file mode 100644 index 00000000..5b171cbc --- /dev/null +++ b/.agents/docs/2026-08-16-msvc-as-a-managed-toolchain.md @@ -0,0 +1,421 @@ +# MSVC 纳入 mcpp 工具链体系 —— 设计 + 验证方案(2026-08-16) + +> **状态:已实现(2026.8.16.1)。** §6 的六个决策点全部有了答案,记在 §7; +> 其中**一条原方案的判断在实现时被推翻**,已在原处标注而不是悄悄改掉。 +> 落地结果与实测见 §7,跨仓库的任务拆分与依赖见 §8。 + +**三条缺陷全部在 HEAD 上核实过源码,不是照抄 issue; +上游依赖(xlings 包)已经就位并在 Windows CI 上装通。** + +对应 issue: #432。触发来源: [Sunrisepeak/xrgui#3](https://github.com/Sunrisepeak/xrgui/pull/3) +(用 mcpp 给 XRGUI 加 Windows 构建,已全绿——但代价是一个 workaround)。 + +| # | 缺陷 | 性质 | 改动面 | 是否阻塞 xrgui | +|---|---|---|---|---| +| A | `msvc` 是唯一不可声明版本的工具链 | 设计缺口 | 中 | **是** | +| B | `find_windows_sdk()` 写死两个绝对路径 | 可移植性 | 小 | 是(payload 化 SDK 后) | +| C | `cxx_runtime` 与 `linkage` 对静态 CRT 说法不一 | 接口一致性 | 小 | 否 | + +A 与 B 合起来才有意义:A 让 mcpp 能拿到指定的 toolset,B 让它能拿到配套的 SDK。 +C 独立,可并行。 + +--- + +## 0. TL;DR(给 review 的一页纸) + +- **MSVC 是 mcpp 工具链体系里唯一的例外**:gcc / llvm 由 mcpp 自己装、按声明解析; + 只有 MSVC 走 `msvc@system` 去**探测宿主**,而那个探测**无法被调用方覆盖**。 +- 后果不是「不够优雅」,是**同一份源码在两台机器上会被不同编译器编译,且不会报错**—— + xrgui#3 实测:同一轮 CI 里 mcpp 用 14.51、xmake 用 14.52,直到 14.51 ICE 才暴露。 +- **上游已经准备好了**:`xim:msvc` / `xim:windows-sdk` / `xim:curl` 已发布, + payload 布局刻意做成 mcpp 现状即可识别的形状,且 `xim:msvc@14.52.36629` + 正是 xrgui 需要的那个 toolset。 +- 提案:**`msvc@` 与 `gcc@` 同构**——非 system spec 走 `to_xim_package()` + 安装并解析;`msvc@system` 保留原义(用宿主已装的 VS)。 +- **验证不靠新写的测试,靠 xrgui**:它是这套东西的真实用例,且已经绿了—— + 验收标准是**删掉 workaround 之后仍然绿**,这是一个不能自证的判据。 + +--- + +## 1. 核实 + +### A. `msvc` 无法被指定 —— `src/toolchain/msvc.cppm` + +发现顺序: + +``` +1. vswhere -latest -products * -requires ...VC.Tools.x86.x64 +2. VSINSTALLDIR / VS*COMNTOOLS +3. Program Files\Microsoft Visual Studio\\ +``` + +两个事实叠加成缺口: + +1. 第 1 步**没有 `-prerelease`**,所以 Insider 实例对它完全不可见; +2. 第 1 步一旦成功,第 2 步**不会执行**——而那是调用方唯一能控制的入口。 + +`find_vs_via_env()` 的判据本身很宽松,只要求目录存在: + +```cpp +if (auto* dir = std::getenv("VSINSTALLDIR"); dir && *dir) { + std::filesystem::path p{dir}; + if (std::filesystem::exists(p / "VC" / "Tools" / "MSVC")) + return p; +} +``` + +**实测(xrgui#3)**:GitHub `windows-2025-vs2026` 镜像上同时存在 + +``` +C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Tools\MSVC\14.51.36231 ← 预装 +C:\VS2026Insider\VC\Tools\MSVC\14.52.36629 ← 装的 +``` + +同一轮 CI 里 mcpp 选 14.51、xmake 用 14.52。而 **14.51 编不了那个代码库**: + +``` +mo_yanxi_utility/src/utility/math/basic/vector2.ixx(67): + fatal error C1001: Internal compiler error. + (compiler file '...\CxxFE\sl\p1\c\template.cpp', line 26415) + note: IFC import detected. +``` + +**导出完整 vcvars 环境无效**,也是实测的:多花约 7 分钟装 Insider,构建日志里的 cl +一个字都没变。最终只能在 CI 里**把 `vswhere.exe` 挪开**,逼 mcpp 落到第 2 步。 +那个 workaround 现在还在 xrgui 的 workflow 里,是这份方案要消掉的东西。 + +### B. Windows SDK 路径写死 —— 同文件 + +```cpp +for (const char* base : {"C:\\Program Files (x86)\\Windows Kits\\10", + "C:\\Program Files\\Windows Kits\\10"}) { +``` + +无 `WindowsSdkDir` 覆盖、无注册表回退。toolset 那边没有对应问题: +`find_vs_via_env()` 只要求 `/VC/Tools/MSVC` 存在。 + +### C. 静态 CRT 有两个入口 —— 且注释互相矛盾 + +| 键 | MSVC 上的行为 | +|---|---| +| `[build] linkage = "static"` | **真的发 `/MT`** | +| `[build] cxx_runtime = "self-contained"` | **未实现**,warn 一次后退回 host-coupled | + +- `src/build/flags.cppm:605` — *"/MD default, **/MT under static linkage**"*,且确实发了 +- `src/build/distribution.cppm:202` — *"Under the MSVC runtime mcpp never made that promise + — **there is no /MT emission at all**"* + +`linkage` 那条实现得很扎实:`flags.cppm:611`(项目 TU)与 `prepare.cppm:4980`(std 模块) +共用同一个 `msvc_crt_flag()`——正是 #422 的修法,两边不可能再分叉。问题只在**入口有两个**, +而名字更像干这事的那个是无效的那个。 + +--- + +## 2. 上游已经就位(不需要 mcpp 侧配合的部分) + +xim-pkgindex 现已发布三个包(#626 / #627 / #628 均已合入,Windows CI 实测装/卸载通过): + +| 包 | 版本 | 说明 | +|---|---|---| +| `xim:msvc` | `14.44.35207`(latest)、**`14.52.36629`** | payload 包,多版本共存 | +| `xim:windows-sdk` | `10.0.26100` | ucrt/um/shared 头 + um 库 + rc/mt | +| `xim:curl` | `8.21.0` | 下载器本身也在生态内 | + +**payload 布局是刻意照着 mcpp 现状做的**: + +``` +/xpkgs/xim-x-msvc/14.52.36629/ +└── VC/Tools/MSVC/14.52.36629/{bin/Hostx64/x64/cl.exe, include/, lib/x64/, modules/std.ixx} + +/xpkgs/xim-x-windows-sdk/10.0.26100/ +├── Include/10.0.26100.0/{ucrt,um,shared}/ +└── Lib/10.0.26100.0/{ucrt,um}/x64/ +``` + +即 **toolset 那一半 mcpp 今天就能识别**(把 `VSINSTALLDIR` 指过去即可,前提是缺陷 A 修掉); +**SDK 那一半够不到**,因为缺陷 B。 + +--- + +## 3. 方案 + +### 3.1 A —— 让 `msvc@` 与 `gcc@` 同构 + +现状(`src/toolchain/lifecycle.cppm:503`): + +```cpp +// msvc@system: mcpp never installs MSVC — report what's there, or +// print installation guidance. +if (mcpp::toolchain::is_system_toolchain(*spec)) { ... return 1; } +``` + +`is_system_toolchain()` 对**所有** msvc spec 为真,所以 `msvc@14.52.36629` 也走这条死路。 + +**提案:按 spec 是否带具体版本分流** + +| spec | 语义 | 解析 | +|---|---|---| +| `msvc@system` | 用宿主已装的 VS(**保留现状,含探测顺序**) | `detect_installation()` | +| `msvc@` | 由 mcpp 安装并使用指定 toolset | `to_xim_package()` → `xim:msvc@` | + +改动集中在三处: + +1. `is_system_toolchain()` 只在 version 为 `system`/空时为真; +2. `to_xim_package()` 增加 msvc 行(`xim:msvc`,版本原样); +3. 解析已安装 payload 时,`VC/Tools/MSVC/` 直接由包版本拼出—— + **不再探测**,因为版本是声明的。 + +> **为什么这比"给 vswhere 加 `-prerelease`"更值得做**:后者只是让探测更可能猜对。 +> 而问题的本质是「这份构建用了哪个编译器」不该由机器状态回答。xlings V2 spec 的 +> R3 把这条写成了规则:*"If a fix ADDS a path rather than REMOVING one, it is a +> workaround. Making two independent answers more likely to agree is not a fix."* +> +> 不过 `-prerelease` 仍建议顺手加上:它让 `msvc@system` 在装了 Insider 的机器上 +> 至少能看见它。这是**兼容改进**,不是缺陷 A 的答案。 + +**兼容性**:`msvc@system` 行为不变,现有工程零影响。新增的是一条以前会报错的路径。 + +### 3.2 B —— `find_windows_sdk()` 认 `WindowsSdkDir` + +顺序改为: + +``` +1. WindowsSdkDir + WindowsSdkVersion (vcvars 本来就导出这两个) +2. 已解析 msvc 包的 windows-sdk 依赖 (走 3.1 的路径时) +3. 现有的两个绝对路径 (回退) +``` + +第 2 条是 3.1 的自然延伸:`xim:msvc` 声明了 `deps = { "xim:windows-sdk@10.0.26100" }`, +所以走包路径时 SDK 的位置是**已知的**,不必再探测。 + +### 3.3 C —— 消掉第二个入口 + +二选一: + +- **(推荐)** `cxx_runtime = "self-contained"` 在 MSVC 上直接映射到静态 CRT, + 与 `linkage = "static"` 走同一个 `msvc_crt_flag()`; +- 或保持未实现,但把诊断改成明确指向 `linkage`。 + +无论哪个,`distribution.cppm:202` 那句 *"there is no /MT emission at all"* 都要改—— +它与 `flags.cppm` 的实现直接矛盾。 + +--- + +## 4. 验证方案 —— 用 xrgui,不用新造的测试 + +xrgui#3 是这套东西的**真实用例**,而且**它已经是绿的**。这一点很重要: +验证不是"让它变绿",而是**在删掉 workaround 之后它是否仍然绿**—— +一个不能靠调整测试来自我满足的判据。 + +### 4.1 基线(今天的状态) + +``` +.github/workflows/mcpp-windows.yml + ├─ 装 VS 2026 Insider 到 C:\VS2026Insider ~7 min + ├─ 直接从该路径导出 vcvars(不经 vswhere) + ├─ 把 vswhere.exe 挪开 ← workaround,本方案要消掉的 + └─ 断言 mcpp 解析到 14.52,否则立刻失败 +``` + +两条腿(mcpp / xmake)全绿,`xrgui_tests` 54 项通过。 + +### 4.2 验收步骤 + +| 阶段 | 动作 | 通过标准 | +|---|---|---| +| V0 | 不改 xrgui,先在一台装了 VS 的机器上 `mcpp toolchain install msvc 14.52.36629` | payload 落在 `xpkgs/xim-x-msvc/14.52.36629`,`mcpp why toolchain` 报告它 —— **且宿主的 VS 仍在**(证明不是靠探测碰巧对) | +| V1 | xrgui 的 `mcpp.toml` 改 `[toolchain] windows = "msvc@14.52.36629"` | `mcpp why toolchain` 报告 14.52.36629 | +| V2 | **删掉 workflow 里「挪开 vswhere.exe」那一步** | 仍解析到 14.52.36629 —— 这是缺陷 A 修复的**唯一有效证据** | +| V3 | 删掉 Insider 安装与 vcvars 导出两步 | mcpp 自己装 toolset + SDK;`mcpp build --features tests` 通过,`xrgui_tests` 仍 54/54 | +| V4 | 对比 xmake 腿 | 两条腿仍然只差构建系统 —— 但**注意**:xmake 仍需 Insider 安装,V3 不能删它需要的那部分 | + +**V2 是关键**:workaround 还在的时候,缺陷 A 是否修好无法区分—— +`VSINSTALLDIR` 被采纳和 vswhere 找不到东西,现象一样。 + +### 4.3 反向验证(容易被忽略) + +- **`msvc@system` 未回归**:在同一台机器上把 spec 换回 `msvc@system`, + 应仍解析到宿主的 VS(而不是刚装的 payload)。两条路径必须互不污染。 +- **版本切换真的生效**:`xlings use msvc 14.44.35207` 后重新构建, + `cl` 报告的版本随之改变。多版本不是摆设。 +- **SDK 来自包而非宿主**:装完后临时重命名 `C:\Program Files (x86)\Windows Kits`, + 构建应仍然成功(缺陷 B 修好的判据)。这条在 CI 上做比在本机安全。 + +### 4.4 已知不被覆盖的部分 + +- **`14.52` 的安装路径 CI 没跑过**。xim-pkgindex 的 `windows-test` 装的是 `latest`(14.44)。 + 两者差异只在 payload URL 与目录版本,后者已逐个从真实 payload 读出核对 + (14.44 的三个 payload 版本 35228/35220/35226 都解到 `35207`;14.52 的 payload + 与目录一致,都是 `36629`),但**没有实际装过一次**。V0 会第一次覆盖它。 +- **Insiders payload 会轮换** —— 但这条正在被消掉,见 §4.5。 + +### 4.5 镜像:把"地址会失效"变成"地址锁定" + +原本这份方案把 Insiders 的轮换列为已知风险:14.52.36629 下架时 URL 404。 +**这一点可以直接消掉,而不是接受**——review 反馈给的方向是镜像到 gitcode。 + +关键在于:**镜像的是同一份字节,所以 sha256 不变**。于是 payload 条目从 + +```lua +{ name = "...", sha256 = "...", url = "https://download.visualstudio.microsoft.com/..." } +``` + +变成一个**来源列表**,校验规则完全不动: + +```lua +{ name = "...", sha256 = "...", + urls = { "https://gitcode.com/xlings-res/msvc/.../", -- 镜像优先 + "https://download.visualstudio.microsoft.com/..." } } -- 官方回退 +``` + +`fetch_verified()` 依次尝试,**无论从哪个来源取到,都按同一个 sha256 校验**。 +所以镜像不是新的信任源——它只是同一份字节的第二个地址。取不到就换下一个, +两个都失败才报错。 + +这比 `XLINGS_RES` 的 res 形状更合适:后者的自动 URL 约定是 +`{name}-{version}-{os}-{arch}.{ext}`,而 MSVC 一个版本是**一组**文件、各自带着 +微软自己的文件名(SDK 那边光 CAB 就 15 个)。而 payload 表本来就是 recipe 自己解析的, +加一个来源列表不需要框架配合。 + +**要镜像的量**(已实测): + +| 内容 | 文件数 | 体积 | +|---|---|---| +| `msvc@14.44.35207` | 4 个 vsix | 83.5 MB | +| `msvc@14.52.36629` | 4 个 vsix | 102.4 MB | +| `windows-sdk@10.0.26100` | 4 MSI + 15 CAB | 139.1 MB | +| 合计 | 27 | **325 MB** | + +**边界要说清楚**:把微软的编译器二进制放到第三方主机,与"安装时从微软 CDN 下载" +是两件不同的事——前者是再分发。这是维护者的决定,不是技术选择;本文只描述机制。 +上传本身需要 gitcode 凭据,不在本方案的执行范围内。 + +**落地拆两步**,因为它们的风险完全不同: + +1. **recipe 侧支持多来源**(纯代码,可先合):`urls` 列表 + 依次回退, + 单来源时行为与今天完全一致 → 零风险,且为镜像就绪; +2. **实际上传 + 填入镜像 URL**:需要凭据与上面那条边界的决定。 + +第 1 步做完之后,即使一个镜像都还没有,这套东西也不会变差;而一旦 14.52 真的下架, +补一行 URL 就能恢复,不必重新找一个还活着的 toolset 版本再验证一遍。 + +--- + +## 5. 落地顺序 + +``` +C(独立,小) ──────────────────────────────┐ + ├─→ 发布 ─→ xrgui V1..V4 +A(核心) ─→ B(依赖 A 的包路径) ─→ V0 ─────┘ +``` + +- C 可以随时单独落,不阻塞任何人; +- A 不修,B 修了也没用(SDK 找得到,编译器还是错的); +- V0 应在 A+B 发布**之前**用本地构建跑一次,否则 xrgui 那边的红会同时有两个可能来源。 + +--- + +## 6. 待 review 决策点(已全部拍板,答案见 §7.1) + +1. **`msvc@` 的语义**是否如 3.1 所提(按版本分流,`@system` 保留原义)? +2. `-prerelease` 是否顺手加上(改善 `msvc@system`,不替代 A)? +3. C 选哪一个:让 `cxx_runtime` 生效,还是把诊断指向 `linkage`? +4. B 的第 2 条(从已解析的包依赖里取 SDK)是否值得做,还是只做 `WindowsSdkDir` 就够? +5. 验证方案里 4.3 的三条反向验证是否都要进 CI —— 尤其"重命名 Windows Kits"那条, + 它最有说服力,也最容易在别的 job 上产生副作用。 +6. **镜像(§4.5)**:recipe 侧的多来源支持可以先合(零风险);实际镜像涉及再分发, + 需要维护者拍板,且上传需要 gitcode 凭据。是否现在就做第 1 步? + +--- + +## 7. 落地结果(2026.8.16.1) + +### 7.1 六个决策点的答案 + +| # | 决定 | 落地方式 | +|---|---|---| +| 1 | **按版本轴分流,`@system` 完全保留** | `is_system_toolchain()` 加版本判据;`msvc@system` 的语义与探测链一字未改(只有顺序修了,见 2) | +| 2 | **加** | vswhere 加 `-prerelease`,但顺序也变了 —— 见 §7.2 那条被推翻的判断 | +| 3 | **让 `cxx_runtime` 生效** | 两个键都走 `msvc_wants_static_crt()`;`distribution.cppm:202` 那句自相矛盾的注释删掉 | +| 4 | **做,但不是「从包依赖里取」** | 改成从**编译器自己的路径**反推 store —— 见 §7.2 | +| 5 | **不进 CI** | 见 §7.3 | +| 6 | **两步一起做了** | recipe 侧多来源 + 27 个 payload 实际镜像完成并逐个校验(xim-pkgindex#629) | + +### 7.2 两条实现时改掉的判断 + +**① 缺陷 B 的第 2 条来源不是「已解析的包依赖」,而是编译器自己的位置。** + +原方案说:走包路径时 `xim:msvc` 声明了 `deps = { "xim:windows-sdk@10.0.26100" }`, +所以 SDK 的位置是已知的。能做,但它要求 **mcpp 侧知道那个依赖的名字和版本** —— +把 SDK 版本写进 mcpp,而它本该只是包的事。 + +实际做法:`sibling_sdk_roots(clPath)` 用已有的 `xpkgs_from_compiler()` 从 cl.exe +的路径反推出它所在的 store,再取 `xim-x-windows-sdk/*`。**编译器自己的路径就说明了 +它来自哪个 store**,SDK 是它在那里的邻居。零配置、零版本硬编码,而且对 +`msvc@system` 自动返回空(系统 cl 不在任何 store 里)。 + +**② `VSINSTALLDIR` 与 vswhere 的顺序,原方案说得不够。** + +原方案把 `-prerelease` 列为「兼容改进,不是缺陷 A 的答案」,这是对的; +但它没说**顺序本身就是缺陷**。vswhere 在几乎每台开发机上都返回点什么,所以 +`VSINSTALLDIR` 事实上不可达 —— 这正是 xrgui 那个 workaround 存在的原因,而 +workaround 是要被删掉的。所以顺序改成: + +``` +VSINSTALLDIR → vswhere(-prerelease) → VS*COMNTOOLS → 绝对路径 +``` + +`VS*COMNTOOLS` 留在 vswhere **之后**,这一点是新的:它们是机器全局的残留 +(2017 的 `VS150COMNTOOLS` 不该压过当前安装),而 `VSINSTALLDIR` 是有人为这个 +shell 设的。原来的 `find_vs_via_env()` 把两者混在一起,提前它就会连带提前残留。 + +### 7.3 4.3 的三条反向验证:两条进了 e2e,一条没进 + +| 反向验证 | 结论 | +|---|---| +| `msvc@system` 未回归 | **进了** —— `239_msvc_managed_toolset.sh` 第 3 步:同一台机器、同一个项目,spec 换回 `msvc@system` 必须解析到系统的 cl。没有这条,「受管能用」与「受管把一切换掉了」这两种结果长得一模一样。 | +| 版本切换真的生效 | **进了(以更强的形式)** —— 单测 `ResolvesTheDeclaredToolsetAndNotItsNeighbour`:两个 toolset 都在,要**老的**那个。「取最新」的实现会在这里失败,而在真机上它可能碰巧对。 | +| 重命名 `Windows Kits` | **没进**。它确实最有说服力,但它会让同一个 runner 上**其它 job** 的 MSVC 构建随机失败,而 CI 上的 job 隔离不到目录改名这一层。改由单测覆盖:`ExtraRootsCoverTheManagedPayload` 在没有任何 env、没有任何 Windows Kits 的 Linux 上跑 —— 这比重命名更彻底,因为那台机器上**本来就没有** SDK 可以被找到。 | + +### 7.4 单测第一次能在 Windows 之外跑 + +改动前,msvc 的发现逻辑**一行都无法在 Linux 上测试**:每个入口都从探测机器开始, +而探测在 `#if defined(_WIN32)` 里。 + +`installation_at()` 接受目录而不是探测机器,`find_windows_sdk()` 接受 root 列表, +两者都不再被平台宏包住 —— 于是 fixture 目录树就能驱动真实代码路径。新增 6 个 +单测在 Linux CI 上跑,其中三条是这次改动的核心判据: + +``` +MsvcManaged.ResolvesTheDeclaredToolsetAndNotItsNeighbour 两个都在,要老的 +MsvcManaged.AbsentToolsetIsNulloptNotASubstitute 缺了就是缺了,不替换 +MsvcSdk.DeclaredRootOutranksTheManagedOne 声明压过探测 +``` + +`test_toolchain_msvc` 25 项全绿,全套单测 83/83。 + +--- + +## 8. 跨仓库任务拆分与依赖 + +四个仓库,三条可并行的链。边是**真实依赖**,不是先后偏好: + +``` +xim-pkgindex #629 ──────────────┐ (包必须先发布,mcpp 才装得到) + 多来源 urls + 27 payload 镜像 │ + windows-sdk 导出 WindowsSdkDir │ + ▼ +mcpp #4xx (2026.8.16.1) ────→ 发布 ────→ xrgui:删 workaround + 改 spec + A 受管 toolset (V2 是唯一不能自证的证据) + B SDK 搜索顺序 + C CRT 双入口 ← 与 A/B 无依赖,可并行 + D 发现顺序 ← 与 A 同文件,顺序上一起改 +``` + +**为什么 C 和 D 仍然放在同一个 PR 里**:C 与 A/B 确实无依赖,但它改的是同一个 +「MSVC 在 mcpp 里到底怎么被描述」的问题,分开发会让 CHANGELOG 读者以为是两件事; +D 与 A 改同一个函数,分开发第二个 PR 必然要重写第一个 PR 刚写的注释。 + +**唯一的硬依赖**是 xim-pkgindex → mcpp:`mcpp toolchain install msvc 14.44.35207` +装的就是那个包。所以 #629 必须先合并并发布,`239_msvc_managed_toolset.sh` 才 +可能通过 —— 在此之前它会走 SKIP 分支(索引取不到时干净跳过),而不是红。 diff --git a/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md b/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md new file mode 100644 index 00000000..e7e5db98 --- /dev/null +++ b/.agents/docs/2026-08-16-msvc-ecosystem-cross-repo-plan.md @@ -0,0 +1,152 @@ +# MSVC 在 xlings 生态里打通 —— 跨仓库计划、依赖与验收(2026-08-16) + +> 配套 `2026-08-16-msvc-as-a-managed-toolchain.md`(mcpp 侧设计)。 +> 那份讲**为什么这么改**;这份讲**改动落在哪几个仓库、谁挡着谁、以及每一步凭什么算通过**。 + +--- + +## 0. 一句话 + +四个仓库、五条改动,只有**一条硬依赖**:包必须先发布,mcpp 才装得到,xrgui 才用得上。 +其余都可以并行,而把它们排成一条线是这类工作最常见的浪费。 + +--- + +## 1. 从八个角度看这次改的到底是什么 + +用户点名的八个角度不是修辞,它们各自对应一条具体改动。逐条落到实处: + +| 角度 | 改前的具体事实 | 改后 | +|---|---|---| +| **架构** | MSVC 是工具链体系里唯一「无法声明版本」的家族;`is_system_toolchain()` 对**所有** msvc spec 为真 | 版本轴决定来源。**获取**与 gcc 同路(xim 安装),**解析**与 `msvc@system` 同路(`installation_from_tools_dir`)——两条轴正交,受管 toolset 不是第二条代码路径 | +| **稳定性** | 同一份源码在两台机器上被不同编译器编译**且不报错**;xrgui#3 实测 mcpp 用 14.51、xmake 用 14.52,直到 ICE 才暴露 | 声明了版本就必须拿到那个版本,拿不到是 nullopt 而不是替代品 | +| **优雅简洁** | 两个旋钮(`linkage` / `cxx_runtime`)指向同一个物理开关,只有一个管用,注释互相矛盾 | 一个 `msvc_wants_static_crt()`,项目 TU 与 std 模块问同一个函数 | +| **用户体验** | 没装 VS 时告诉你「mcpp 不安装 MSVC,自己去装」——而现在这句话是假的 | 两条路都给出:装一个 pin 住的 toolset,或用机器自己的 VS。`install_guidance()` 里两条命令都能直接抄 | +| **兼容性** | —— | `msvc@system` 语义一字未改;唯一的破坏性变更(`msvc@19.44`)有精确的替代指引,而它原本**只在一个命令里生效、构建路径完全忽略** | +| **跨平台** | msvc 发现逻辑一行都无法在 Windows 之外测试(入口全在 `#if defined(_WIN32)` 里) | `installation_at()` 收目录、`find_windows_sdk()` 收 root 列表,6 个单测在 Linux CI 上跑真 fixture | +| **一致性** | `search` 说 `xim:msvc` 在,`info` 说 `not found` —— 同一台机器、同一个索引 | xlings#550:区分「不存在」与「这个平台没有构建」,并说出它在哪些平台有 | +| **无感升级** | —— | 现有工程零影响:`msvc@system` 不变、默认 CRT 仍是 `/MD`(判据取 manifest 字面值而非解析后的 contract,否则每个 Windows 构建都会翻成 `/MT`) | + +--- + +## 2. 五条改动与它们的仓库 + +| # | 仓库 | 改动 | PR | +|---|---|---|---| +| **X** | xim-pkgindex | payload 多来源 `urls` + 27 个 payload 镜像;windows-sdk 导出 `WindowsSdkDir`/`WindowsSdkVersion` | #629 ✅ 已合 | +| **M** | mcpp | A 受管 toolset / B SDK 搜索顺序 / C CRT 双入口 / D 发现顺序 | #434 | +| **L1** | xlings | D4 的管道那一半量到了(文档) | #549 | +| **L2** | xlings | `info` 对别的平台的包说 "not found" | #550 | +| **V** | xrgui | 删掉 vswhere workaround,验证整条链 | 待 mcpp 发布 | + +--- + +## 3. 依赖图 —— 边是真实依赖,不是先后偏好 + +``` +X (xim-pkgindex #629) +│ 包必须先发布:`mcpp toolchain install msvc 14.44.35207` 装的就是它 +│ 在此之前 mcpp 的 e2e 239 走 SKIP 分支,而不是红 +▼ +M (mcpp #434) ──→ 发布 2026.8.16.1 ──→ V (xrgui) + │ + L1、L2 与上面这条链 无 依赖 ────────┘(可随时合) +``` + +**为什么 C、D 没有拆成独立 PR**:C 与 A/B 确实无依赖,但它回答的是同一个问题 +——「MSVC 在 mcpp 里到底怎么被描述」;拆开会让 CHANGELOG 的读者以为是两件事。 +D 与 A 改同一个函数,拆开的第二个 PR 必然要重写第一个 PR 刚写的注释。 + +**为什么 L2 不在 M 里**:它是 xlings 的缺陷,是**在验证 M 的过程中**被发现的 +(`xlings info msvc` 在 Linux 上说 not found),但它与 MSVC 无关 —— 每一个 +windows-only 包在 Linux 上都会这样,反之亦然。 + +--- + +## 4. 验收标准 —— 哪些能自证,哪些不能 + +这一节是这份计划的重点。**能被"调一下测试"满足的判据,不算证据。** + +| 判据 | 能否自证 | 说明 | +|---|---|---| +| 27 个 payload 镜像正确 | **不能** | 判据不是「上传成功」,是**下载回来 sha256 与微软一致**。而这恰好抓到了真问题:上传工具对 16 个文件报了失败、release 列表却显示它们在,实际 404。**谁都不能信,只能信字节。** | +| 受管 toolset 真的被用了 | **不能** | e2e 239 的每一条断言都写成「系统编译器来应答就会失败」:cl.exe 必须在 mcpp 的 store 里、toolset 目录必须是 spec 声明的那个 | +| 两条来源互不污染 | **不能** | 同一台机器、同一个项目,spec 换回 `msvc@system` 必须解析到系统 cl。少了这条,「受管能用」与「受管把一切都换掉了」长得一样 | +| 声明的 toolset 优先于"最新" | **不能** | 单测:两个 toolset 都在,要**老的**那个。「取最新」的实现会在这里失败,而真机上它可能碰巧对 | +| **xrgui 删掉 workaround 后仍然绿** | **不能** | 全套里最强的一条。workaround 还在时,「`VSINSTALLDIR` 被采纳」与「vswhere 找不到东西」现象完全一样 —— **无法区分缺陷 A 是否真修好**。而且删掉之后 vswhere 与 14.51 **都还在**:错误答案没有被拿走,它只是必须输 | +| 单测 83/83、静态检查 1788 项 | **能** | 有用,但它们证明的是「没有回归」,不是「这件事做成了」 | + +--- + +## 5. 验证过程本身抓到的:包好的 toolset 根本链接不了默认构建 + +这条不在计划里,是**做第 4 节那份「不被覆盖」清单时查出来的** —— 我去检查 +「打包的 14.52 能不能构建 xrgui」,答案是不能,而且对**任何**项目都不能。 + +payload 集里只有**静态** CRT。`.CRT.x64.Desktop.base` 带的是 +`libcmt` / `libcpmt` / `libvcruntime`,而默认的 `/MD` 需要 `msvcprt.lib`。 +判据是头文件自己写的(`use_ansi.h`): + +```c +#if defined(_DLL) && !defined(_STATIC_CPPLIB) +#define _LIB_STEM "msvcprt" // /MD ← payload 集里没有 +#else +#define _LIB_STEM "libcpmt" // /MT ← 有 +``` + +`msvcprt.lib` / `msvcrt.lib` / `vcruntime.lib` / `oldnames.lib` 在 +`Microsoft.VC..CRT.x64.Store.base` 里。**名字是它被跳过的原因** —— +听起来像 UWP。它确实也带 `uwp/` 和 `store/` 子目录,但**桌面**用的动态导入库 +就放在 `lib/x64` 顶层,而且别处都没有。已为两个 toolset 补上(xim-pkgindex#630)。 + +**真正的缺陷不是少了一个 payload,是没有任何东西会发现它。** +`installed()` 只查 `cl.exe` 和 `std.ixx` —— 两个都在。于是安装报成功, +真 Windows runner 上的 `windows-test` 全绿,而这条工具链**在它的默认配置下不可用**; +失败会出现在用户的链接步骤里,离原因三层远。 + +现在 `installed()` 对每种 CRT 模型各查一个导入库(`/MT` 的 `libcpmt.lib`、 +`/MD` 的 `msvcprt.lib`),两者都不能再用同样的方式静默消失。 + +> 这也是对 §4 那张表的一个注脚:「1790 项静态检查通过」与 +> 「windows-test 在真机上装成功」都是真的,而这条工具链同时是坏的。 +> **一个比"能用"弱的"装好了"判据,报的不是安装成功,是解压成功。** + +--- + +### 5.1 第二条:装好的 toolset 在 `toolchain list` 里根本不出现 + +拿**已发布的** 2026.8.16.1 二进制对着一个 payload 形状的 fixture 跑,发现 +装好的 msvc toolset 一行都不显示。 + +枚举问的是 `toolchain_frontend(root / "bin", pkg)`,而 cl.exe 在 +`VC/Tools/MSVC//bin/Host//`,深四层。拿不到 → `continue` → 消失。 + +**这个布局有三个地方需要知道:安装知道、构建知道、列表不知道。** +第三份内联副本就是这么来的。现在三者共用 `payload_frontend()`(#436)。 + +值得记的是它**怎么被发现的**:不是测试。为此写的单测钉的是 +`identify_xim_payload("msvc")` —— 那一条本来就是对的。 +**「身份映射」与「枚举」是两个问题,而只有一个被问了。** + +> e2e 239 的 1b 步会抓到它 —— 但要等包发布之后 Windows e2e 再跑一轮。 +> 测试写在了对的粒度上,只是还没轮到它跑。**「有测试」和「测过了」不是一回事。** + +--- + +## 6. 已知不被覆盖的部分(不要当成已完成) + +1. **没有任何 CI 用这条 toolset 链接过东西**。这正是上面那条缺陷能一路走到 + 发布的原因,而补上 `installed()` 的检查只是让「文件在不在」变严, + **不等于「链接得通」**。真正能覆盖它的是 xrgui 的 V3(用 + `msvc@` 构建一个真实项目),那一步还没跑。 +2. **`msvc@14.52.36629` 的安装路径没有在 CI 上跑过**。index 的 `windows-test` + 装的是 `latest`(14.44)。两者只差 payload URL 与目录版本,后者已逐个从真实 + payload 读出核对,但**没有实际装过一次**。 +2. **镜像回退没有被真正触发过**。两个前提单独验过了 —— `curl -f` 遇 404 退 22 + 且不留文件(所以 `pcall` 会接住、`os.isfile` 为假),官方地址仍然服务同样的 + 字节 —— 但「镜像挂掉时自动走官方」这条完整路径没有被执行过。 + 现在至少它**不会静默**:走到第一个之后的地址会 `log.warn`。 +3. **D4 的控制台那一半仍然开着**。管道那条路量到了(xlings#549), + 而 CI 结不了控制台的案:runner 上 job 没有附着的控制台。 +4. **gitcode 的 probe 资产删不掉**。API 没有删除端点(两个路径都 404), + 已在镜像 README 里点名说明,而不是留一堆没人知道是什么的文件。 diff --git a/.agents/docs/2026-08-16-msvc-ecosystem-final-report.md b/.agents/docs/2026-08-16-msvc-ecosystem-final-report.md new file mode 100644 index 00000000..eeaba00e --- /dev/null +++ b/.agents/docs/2026-08-16-msvc-ecosystem-final-report.md @@ -0,0 +1,741 @@ +# MSVC × xlings 生态打通 —— 综合报告(2026-08-16) + +## 0. 一句话 + +MSVC 从「mcpp 工具链体系里唯一无法声明版本的家族」变成了与 gcc 同构的一等公民。 +**而这次真正的收获在验证环节**:在已经发布、CI 全绿的东西里查出了一连串缺陷 —— 其中「解包」这一处就有六层, +其中一条让包对默认构建完全不可用,一条是我自己写的测试**不可能失败**, +还有一条是我明知道该怎么避免却还是犯了(零先例的 sandbox API)。 + +它们的共同形状只有一个:**"没发生"和"成功了"输出一样。** + +**终点判据**(真机、非 skip): + +``` +PASS: msvc@14.44.35207 installs, builds, stays distinct from msvc@system, and removes + ── e2e 239,1 passed / 0 skipped + +xrgui: Resolved msvc@system → msvc 19.52.36629 (C:\VS2026Insider\...\14.52.36629\cl.exe) + [ PASSED ] 54 tests. ── 两条腿全绿,且 vswhere workaround 仍然是删掉的 +``` + +而每修好一个,下一个才够得着 —— 这条链一共走了十一层: + +``` +installed() 只查 cl.exe → 只查目录(缺 kernel32.lib 照样说装好了) + → 缺 kernel32.lib → 缺 um 头 / shared 头 / rc.exe / mt.exe + → mcpp 认 SDK 只看头文件 → 构建终于通了,remove 在 Windows 上失败 + → 报错不说是哪个文件 → vctip.exe 占着 payload,谁都卸不掉 + → 不再安装它 ≠ 修好已经装了的 → installed() 得能认出「旧 recipe 的产物」 + → 改名目录(前提就错了) → 空目录骨架也删不掉,判据得是「没有文件残留」 +``` + +**每一层都被上一层挡着看不见,而每一层的"绿"都是真的绿。** +最后一层尤其说明问题:它是在「构建成功」之后才够得着的 —— +在此之前没有任何一次运行走到过 remove 这一步。 + +--- + +## 1. 交付物 + +| 仓库 | PR | 状态 | +|---|---|---| +| mcpp | #434 MSVC 版本轴(主体) | ✅ 合入,**18/18 绿** | +| mcpp | — | ✅ **发布 `2026.8.16.1`**,四平台产物 + 索引 bump 完成 | +| mcpp | #436 装好的 toolset 不出现在 list 里 | ✅ 合入,18/18 绿 | +| mcpp | #435 记录被查出的缺陷(文档) | ✅ 合入,18/18 绿 | +| mcpp | #438 e2e 239 只能 pass/skip,不可能 fail | 已并入 #440(单 PR) | +| mcpp | #440 payload 根目录 + SDK 两半判据 + Windows remove 三层 + review §3b/§3c/§3 | ✅ **合入,19/19 绿,e2e 239 真机 PASS** | +| mcpp | #441 拒绝 `gcc@system` | ❌ **已关闭 —— 我自己造出来的问题** | +| mcpp | #442 host helper 链接策略对齐(**closes #437**)+ review §1 更正 | ✅ 合入,17/17 绿 | +| mcpp | #443 版本号 → `2026.8.16.3` | ✅ 合入 | +| mcpp | #444 自我 review 抓到的两条(清扫波及面 / yes-no 起编译器) | ✅ 合入,19/19 绿 | +| mcpp | — | ✅ **发布 `2026.8.16.3`**,gitcode 镜像 **21/21 字节一致** | +| xim-pkgindex | #641 `xim:mcpp` → 2026.8.16.3 | ✅ 合入,索引已发布 | +| xrgui | #6 `MCPP_VER` → 2026.8.16.3 | ✅ **合入,两条腿全绿,54/54** | +| mcpp | — | ✅ **发布 `2026.8.16.2`**(带 #436),索引已 bump | +| xim-pkgindex | #629 payload 多地址 + 27 个镜像 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #630 补动态 CRT 导入库 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #631 `xim:mcpp` → 2026.8.16.1 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #632 GNU tar 盘符 / `os.curdir` / skill 规则 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #633 zip 要 bsdtar / 引号 / 去掉 cd | ✅ 合入,15/15 绿,**真机装通** | +| xim-pkgindex | #634 `xim:mcpp` → 2026.8.16.2 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #635 cl.exe 没有 clui.dll 跑不起来 | ✅ 合入 | +| xim-pkgindex | #636 SDK 缺核心导入库 + um/shared 头 + rc/mt(17 payload,+120 MB) | ✅ 合入,windows-test 真机装通 | +| xim-pkgindex | #637 msvc 不再安装 vctip.exe(否则卸不掉) | ✅ 合入,15/15 绿,索引已发布 | +| xim-pkgindex | #638 复用 `toolsdir()`,不再手写第二份布局 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #639 已经装了旧布局的机器要被拉回来 | ✅ 合入,15/15 绿 | +| xim-pkgindex | #640 `installed()` 规则进 skill + lint + windows-test **真的运行**程序 | ✅ 合入,15/15 绿 | +| xlings | #549 D4 管道侧实测 | ✅ 合入 | +| xlings | #550 `info` 平台误报 + e2e flake | ✅ 合入,全绿 | +| xlings | #551 卸载扛得住被占用的文件(park-and-sweep 下沉) | ✅ 合入 | +| xrgui | #5 删 workaround | ✅ **合入,两条腿全绿,54/54** | +| xrgui | #3(原 Windows 支持 PR) | 已关闭 —— #5 是它的超集,并且**不带那个 workaround** | + +--- + +## 2. 架构:版本轴决定来源 + +改前:`is_system_toolchain()` 对**所有** msvc spec 为真 —— manifest 写了版本也被丢掉。 +后果不是不优雅,是**同一份源码在两台机器上被不同编译器编译且不报错** +(xrgui#3 实测:同一轮 CI 里 mcpp 用 14.51、xmake 用 14.52,直到 ICE 才暴露)。 + +| spec | 来源 | 用哪个编译器 | +|---|---|---| +| `msvc@system`(或裸 `msvc`) | 机器自己的 VS | 这台机器装的那个(**语义一字未改**) | +| `msvc@` | mcpp 安装的 xlings payload | 声明的那个,每台机器都是 | + +关键在于**两条正交的轴**:「获取」与 gcc 共用(xim 安装),「解析」与 +`msvc@system` 共用(`installation_from_tools_dir`)。受管 toolset 因此不是 +第二条代码路径,长不出自己的 bug。 + +已在**发布产物**上验证:Linux 上 `mcpp toolchain install msvc 14.44.35207` +走的是 xim 路径而不是 `msvc_wrong_host()` —— 分流生效。 + +### ⭐ 最强的一条证据:xrgui 删掉 workaround 之后仍然绿 + +这是整套里**唯一不能靠调测试自我满足**的判据。workaround 还在的时候, +「`VSINSTALLDIR` 被采纳」与「vswhere 找不到东西」现象完全一样。 + +xrgui#5 把「把 `vswhere.exe` 挪开」那一步**删掉了**,而 vswhere **还在**、 +它排第一的 Enterprise **14.51 也还装着** —— 什么都没被拿走,错误答案只是必须输: + +``` +Resolved msvc@system → msvc 19.52.36629 + (C:\VS2026Insider\VC\Tools\MSVC\14.52.36629\bin\Hostx64\x64\cl.exe) +``` + +`xrgui_tests` **54/54**,mcpp 与 xmake 两条腿全绿。原来的 PR #3 已关闭 —— +#5 是它的超集,而且**不再需要那个 workaround**。 + +合入 master 之后 `mcpp (Windows / MSVC)` 在 master 上**又跑了一遍,仍然绿** —— +不是同一次运行的复用,是合并后的树重新构建的结果。 + +### 顺带修掉的三条 + +- **`VSINSTALLDIR` 现在优先于 vswhere**。vswhere 几乎总能返回点什么,所以 + `VSINSTALLDIR` 事实上不可达 —— **猜测不该压过答案**。vswhere 加 `-prerelease`。 + `VS*COMNTOOLS` 仍排 vswhere **之后**(机器全局残留 ≠ 有人为这个 shell 设的)。 +- **SDK 搜索**:`WindowsSdkDir` → store 里的 `xim:windows-sdk` payload → 写死路径(降为回退)。 + 第二条零配置、零版本硬编码:**编译器自己的路径就说明了它来自哪个 store**。 +- **`cxx_runtime` 与 `linkage`**:在 MSVC ABI 上它们是同一个物理开关,现在都走 + `msvc_wants_static_crt()`。默认仍是 `/MD`(判据取 manifest 字面值,不是解析后的 + contract —— 否则每个 Windows 构建都会翻成 `/MT`)。 + +--- + +## 3. 验证:哪些能自证,哪些不能 + +**能被"调一下测试"满足的判据不算证据。** + +| 判据 | 自证? | 结果 | +|---|---|---| +| 声明的 toolset 优先于"最新" | ❌ | 单测:两个都在,要**老的**那个。「取最新」会失败,真机上可能碰巧对 | +| 两条来源互不污染 | ❌ | e2e 239:换回 `msvc@system` 必须解析到系统 cl | +| 受管 toolset 真的被用了 | ❌ | e2e 239 每条断言都写成「系统编译器应答就失败」 | +| 27 个 payload 镜像正确 | ❌ | **下载回来** sha256 与微软一致 —— 而这抓到了真问题(下面 §4) | +| SDK 里到底有什么 | ❌ | **解 MSI 的 File/Component/Directory 表**算出每个文件的安装路径 —— 名字和文档都在骗人(§4.8) | +| 删掉 vctip.exe 是否安全 | ❌ | 依据是 **cl.exe 自己的字符串** `Not launching VCTIP: Binary not found` —— 它本来就有这条分支 | +| 单测 83/83、静态 1798 项 | ✅ | 证明没回归,不证明做成了 | + +**msvc 发现逻辑第一次能在 Windows 之外测试**:`installation_at()` 收目录而不是 +探测机器,`find_windows_sdk()` 收 root 列表,两者都不再被平台宏包住 —— +6 个新单测在 Linux CI 上跑真 fixture。改动前这里一行都测不了。 + +--- + +## 4. 验证过程抓到的缺陷 —— 这一节是重点 + +### 4.1 上传工具说失败、列表说在、实际 404 + +镜像 27 个 payload 时,`gtc` 对 16 个文件报了失败,而 release 列表显示它们**在**。 +下载回来一验:**404,根本不在**。 + +原因是 gitcode 拒绝 `.cab` 扩展名、也拒绝名字里的空格,而失败发生在 `obs_callback` +之后 —— 资产**注册了**、对象**没落盘**。改用 `<下划线名>` / `.cab.bin` +重传,再逐个下载校验:**27/27 与微软一致**。 + +> 判据不是「上传成功」,是「字节回来了且摘要一致」。这两者差了 16 个文件。 + +### 4.2 已发布、CI 全绿的 toolset 链接不了默认构建 ⚠️ + +查「打包的 14.52 能不能构建 xrgui」时发现:**不能,而且对任何项目都不能。** + +payload 集里只有**静态** CRT。判据是头文件自己写的(`use_ansi.h`): + +```c +#if defined(_DLL) && !defined(_STATIC_CPPLIB) +#define _LIB_STEM "msvcprt" // /MD ← payload 里没有 +#else +#define _LIB_STEM "libcpmt" // /MT ← 有 +``` + +`msvcprt.lib` 在 `Microsoft.VC..CRT.x64.Store.base` 里 —— **名字是它被跳过的原因**, +听起来像 UWP,实际把桌面用的动态导入库放在 `lib/x64` 顶层。 + +**真正的缺陷不是少一个 payload,是没有任何东西会发现它。** `installed()` 只查 +`cl.exe` 和 `std.ixx`,两个都在 → 安装报成功 → 真 Windows runner 上 +`windows-test` 全绿 → 工具链在默认配置下不可用,失败会出现在用户的链接步骤里。 + +现在 `installed()` 对每种 CRT 模型各查一个导入库,并且失败时**说出缺哪个文件**。 +补丁后的 `windows-test` 在真机上仍然绿 —— 这次那个绿是有意义的。 + +> **一个比"能用"弱的"装好了"判据,报的不是安装成功,是解压成功。** + +### 4.3 装好的 toolset 在 `toolchain list` 里根本不出现 + +拿**已发布的** 2026.8.16.1 二进制对着一个 payload 形状的 fixture 跑,发现 +装好的 msvc toolset 一行都不显示。 + +原因:枚举问的是 `toolchain_frontend(root / "bin", pkg)` —— 而 cl.exe 在 +`VC/Tools/MSVC//bin/Host//`,深四层。拿不到 → `continue` → 消失。 + +**这个布局有三个地方需要知道:安装知道、构建知道、列表不知道。** +第三份内联副本就是这么来的。现在三者共用 `payload_frontend()`。 + +值得记的是它**怎么被发现的**:不是测试。我为此写的单测钉的是 +`identify_xim_payload("msvc")` —— 那一条本来就是对的。 +**「身份映射」和「枚举」是两个问题,而我只问了其中一个。** +(e2e 239 的 1b 步会抓到它,但那要等包发布后 Windows e2e 再跑一轮。) + +--- + +### 4.4 我自己写的 e2e 只能 pass 或 skip,不可能 fail ⚠️ + +包发布之后,Windows e2e 第一次真的跑到了 `239_msvc_managed_toolset.sh`。 +它打印了 `PASS`。**而它其实失败了。** + +skip 的判据是**事后**匹配失败文本,而其中一个模式是 `*"index"*` —— +几乎每条 mcpp 命令都会打印 "package index"。于是**任何**真实的安装失败 +都会走进 skip 分支。那次安装跑了 **135 秒**、失败了、脚本报 PASS。 + +> **用失败的样子来决定 skip,不是 skip,是一种不去看。** + +现在 skip 在动手**之前**由一条正向检查决定(`toolchain list` 里有没有 msvc 行), +之后的任何失败都是失败,并且**把输出打出来**而不是折进一行消息里。 + +### 4.5 它藏住的那条:GNU tar 把 `C:` 当成主机名 + +``` +tar -xf "C:\Users\...\.payloads\Microsoft.VC...vsix" +tar: Cannot connect to C: resolve failed +``` + +GNU tar 先按 `host:path` 解析,再看盘符;Windows 自带的 bsdtar 不会。 +**同一个 runner 镜像、同一份 recipe、不同的 tar** —— 这就是它在 index 的 +`windows-test`(System32 的 bsdtar)通过、在 mcpp 的 e2e(Git for Windows +把 GNU tar 排在前面)失败的全部原因。 + +`--force-local` 只有 GNU tar 认、bsdtar 直接拒绝,等于换一个坏掉的环境。 +**当时**改成 cd 进 payload 目录用相对文件名 —— 而那一版后来自己变成了 +第 4 层的故障,见 §4.5b。 + +**同一份日志还确认了一件好事**:五个 payload(含 #630 新加的 Store) +**全部从 gitcode.com 取到** —— 镜像在真实 Windows runner 上验证通过了, +坏的只是解包。 + +### 4.5b 解包这一个代码块,拆出五层原因 + +修好 skip 判据之后,同一个 `tar` 调用连着炸了五次,**每一次都是真原因, +而且每一层只有在上一层被拿掉之后才看得见**: + +| # | 报错 | 真正的原因 | +|---|---|---| +| 0 | *(无 —— 报 PASS)* | e2e 的 skip 匹配到了 `*"index"*` | +| 1 | `Cannot connect to C:` | GNU tar 把 `C:` 当主机名 | +| 2 | `does not look like a tar archive` | **GNU tar 根本不读 zip** | +| 3 | `"C:\Windows/System32/tar.exe"` | `path.join` 混分隔符,exe 路径也要 `winpath()` | +| 4 | 命令完全正确却找不到文件 | `system.exec` **不继承** `os.cd` | +| 5 | `The filename... syntax is incorrect` | `cmd /c` 遇到「带引号的 exe + 带引号的参数」会弄坏整行 | + +最后的形态**比我动手之前更简单**:不改 cwd、不用相对路径、不依赖 PATH。 +中间几版是在加 workaround,最后一版是在**删** —— 第 1 层的相对路径本来 +就只是为了绕开第 2 层里那个错误的 tar,钉死 bsdtar 之后它没了理由, +却留下来变成了第 4 层的故障。 + +### 4.5c 第六层在 mcpp 自己这边:payload 根目录被猜了 + +装成功之后暴露的: + +``` +error: msvc payload installed at '...\xim-x-msvc\14.44.35207\VC' +``` + +注意结尾的 `\VC`。`XpkgPayload::root` 只在版本目录**直接**含 +`bin/` `include/` `lib/` 时才认它是根,否则**钻进唯一的子目录** —— +而装好的 msvc payload 只有一个条目 `VC/`。 + +修法不是去迁就那个启发式,是**不再问它**:位置是 +(store, name, version),三者在两个调用点都是已知的。 +`resolve_xpkg_path` 继续负责安装,只是不再被问「在哪」。 + +### 4.6 还有一条,而且我明知道该怎么避免:`os.curdir` 不在 sandbox 里 + +修 tar 时我写了 `local prevdir = os.curdir()`。Windows CI: + +``` +msvc.lua:360: attempt to call a nil value (field 'curdir') +``` + +**这条 recipe 已经在同一堵墙上撞过三次**(`os.execv`、`path.absolute`、 +`os.files`),每一次的信号都一样:**整个 index 里零处使用**。 + +```bash +grep -rn "os\.curdir" pkgs/ | wc -l # 0 → 别用 +``` + +零处使用的 sandbox API 基本可以认定不在 runtime 里 —— 不是概率判断, +是「能用的东西早就有人用了」。而代价不对称:猜对省几秒,猜错要等一轮 +Windows CI,失败信息还出现在离你写的那行几层远的地方。 + +已经把这条规则和四个条目一起写进了 `xpkg-creater` 技能的 sandbox API 表。 +当时的改法是 `os.cd(pkginfo.install_dir())` —— 而最终版**把 `os.cd` 整个删了**, +因为 `system.exec` 根本不继承它(§4.5b 第 4 层)。也就是说:这条 API 缺失 +是在一段**本来就不该存在**的代码里踩到的。 + +--- + +### 4.8 Windows SDK:名字写着 "Desktop" 的,一样都不给 ⚠️⚠️ + +§4.2 之后 `installed()` 改成查文件名而不是查目录。它立刻报了两个不在: +`Include//um/winnt.h` 和 `bin//x64/rc.exe`。 + +它们**从来就没在过**。recipe 里那份注释写着 +「`Windows SDK Desktop Headers x64` → um / shared 头文件」—— 那是我写的,而且是错的。 + +把每个 MSI 的 `File` / `Component` / `Directory` 三张表解出来 +(7z 取 OLE 流,`!_Columns` 取 schema —— SDK 的 MSI 列宽**并不统一**, +按固定宽度猜会让后面每一列静默错位),算出每个文件的真实安装路径: + +| 以为在 | 实际在 | 实际内容 | +|---|---|---| +| Desktop Headers x64 | — | **只有 4 个文件**:三个 Hyper-V 头 + 一个 catalog | +| Desktop Tools x64 | — | 99 个 bin 工具,**没有 rc,也没有 mt** | +| — | **Store Apps Headers** | 528 个 um 头:`windows.h` `winnt.h` | +| — | **Store Apps Headers OnecoreUap** | `shared/`:`windef.h` `sal.h` `winerror.h` | +| — | **Store Apps Tools** | `rc.exe` `rcdll.dll` `mt.exe` | + +**一个 Win32 程序需要的每一样东西,都在名字写着 "Store Apps" 的 MSI 里; +四个写着 "Desktop" 的,给的是长尾、几个 bin 工具、三个 Hyper-V 头。** +和 §4.2 的 `CRT.x64.Store.base` 是同一个陷阱,这是第三层和第四层。 + +补齐 3 个 MSI + 14 个 cab(8 MSI + 35 cab,170 MB → 291 MB)。 +其中 Tools 一个就 99 MB,换一个 200 KB 的资源编译器 —— 付了, +因为 `programs = {"rc", "mt"}` 写在包里,而那两个文件当时**不在磁盘上**。 + +`required_files()` 现在是**覆盖**而不是抽样:每条对应一个「只有它才提供」的 +payload(`gdi32.lib` 只在 Desktop Libs —— 它的 365 个库和 Store Apps Libs 的 +116 个**完全不相交**),所以哪个 payload 没下来/没解开,报错就点名哪一个。 + + +补完之后**离线核过一遍全集**(把 8 个 MSI 的表并起来,算每个文件的安装路径), +一个 x64 桌面程序链接需要的东西全部到位,包括下一层本来会炸的 ucrt 库: + +``` +Lib//ucrt/x64/{libucrt.lib, ucrt.lib} Include//ucrt/corecrt.h +Lib//um/x64/{kernel32,user32,gdi32,uuid} Include//um/{windows.h,winnt.h} +bin//x64/{rc.exe, mt.exe} Include//shared/{windef.h,sal.h,winerror.h} +``` + +—— 这一步是**在 CI 发现之前**做的:这一轮已经反复出现「修好一层才够得着下一层」, +所以这次没有等它自己撞出来。 + +**无感升级这一条是顺带成立的,而且成立的理由值得记一笔。** `windows-sdk` 的版本号 +没变(还是 `10.0.26100`),按「目录在就算装了」的判据,已经装了旧的那一份的人 +**永远等不到这个修复**。 + +但 xim 决定「是否已安装」时会调 recipe 自己的 `installed()` 钩子 +(`installer.cpp:2451` `executor.check_installed(ctx)`),而 `installed()` 现在 +是**覆盖式**的七条文件断言 —— 旧的那棵树少 `winnt.h`、少 `windef.h`、少 `rc.exe`, +判据直接为假,于是重装。 + +也就是说:**把"装好了"的判据从"目录在"改成"能用",顺手把升级路径也修好了。** +这不是额外做的设计,是同一个决定的另一面。 + +### 4.9 mcpp 这边同一个病:SDK 只查了头文件 ⚠️ + +补完 SDK 之后,e2e 239 仍然挂在同一句: + +``` +LINK : fatal error LNK1104: cannot open file 'kernel32.lib' +``` + +`find_windows_sdk()` 认一个根的条件是 `Include//ucrt/corecrt.h` 存在 —— +**只看头文件**。而 SDK 是头文件**和**导入库两半。 + +于是半装的 payload「找到了」,而且因为版本号更高,**排在机器自己那套完整 +SDK 前面**;每个 TU 都编过,一直到最后一步才炸,**日志里没有一行提到 SDK**。 + +这条和 §4.2、§4.8 是同一句话的第三种说法,只是这次在 mcpp 自己的代码里: +`has_usable_msvc()` 的注释早就写了「用比构建真正需要的更弱的信号去选工具链, +正是这个判据要防的 bug」—— 它对编译器坚持了两半,对 SDK 没有。 + +现在两半都要。半装的根被跳过,搜索落到下一个,**本来就能用的构建就能用了**。 +三个测试各自对着旧判据跑过:headers-only 被拒、完整的仍然收 +(否则「全拒」也能让测试变绿)、高版本的半装根输给低版本的完整根。 +第一和第三条在没有修复时失败。 + +### 4.10 修好之后才够得着的下一个:remove 在 Windows 上失败 + +SDK 修好之后 e2e 239 的前三步全过了 —— 装上、用 payload 里的 cl 构建、 +跑出 Hello、切回 `msvc@system` 仍然解析到系统 cl。第四步 remove 挂了: + +``` +error: remove failed: Access is denied. +``` + +`remove_all` 直接上,不清只读位、不重试、报错不说是哪个文件。payload 从 +.vsix/.msi 解出来,归档条目带只读属性;而 POSIX 只要**目录**可写就能 unlink +子项 —— 所以这个问题在 Linux/macOS 上不可能出现,**而单元测试都跑在那边**。 +另外 `/Zi` 构建之后 mspdbsrv.exe 还在 payload 里活几秒。 + +两个原因表现一模一样,所以两个都处理:清可写位重试 + 给活着的进程一个 +**有上限**的窗口。并且报错点名卡在哪个文件。 + +> 没有配单元测试:唯一会跑它的平台上,删只读文件本来就成功 —— +> **那个测试不可能失败**。这一轮已经修过一个这样的测试(§4.5),不再造第二个。 +> 判据是 Windows runner 上的 e2e 239。 + +--- + +#### 而它点名的那个文件,是第八层 + +加上「说出卡在哪个文件」之后,CI 立刻给出了答案: + +``` +error: remove failed: Access is denied. + stuck at: ...\bin\Hostx64\x64\Microsoft.VisualStudio.Telemetry.dll +``` + +**两个猜测都不对** —— 不是只读位,也不是 mspdbsrv。占着它的是 `vctip.exe`: +cl.exe 拉起来的后台**遥测上传进程**,它比编译器活得久,而且就住在 +payload 目录里面。于是这个 toolset **装得上、编得过、跑得起来,就是卸不掉**。 + +关键在于:**占用者不是 mcpp 能删掉的东西**。任何一边的 `remove` 都删不掉 +另一个进程正打开的文件 —— `xlings remove msvc` 一样卸不掉。所以修复必须在 +payload 那一层:recipe 解包后直接不装 `vctip.exe`。 + +删它安全,依据是 cl.exe **自己的字符串**,不是我猜的: + +``` +Not launching VCTIP: Binary not found @ '%S' +Not launching VCTIP: CLR not present +``` + +—— 二进制不在,是它本来就处理的一条分支。而且没有环境变量可以关掉它: +cl.exe 里没有任何遥测开关(vctip 自己的 `-upload:optout` 写的是**机器级**状态, +装个包不该动那个)。 + +顺带一提,一个构建工具链本来也不该因为你编译了一下就联网上报。 + +**另外修掉了我自己在这一层引入的一处:** 那个「找出卡住的文件」的探测, +第一版是**靠试删**来找的 —— 诊断变成第二次破坏。`remove_all` 已经把能删的 +都删了,所以**活下来的第一个文件就是卡住的那个**,根本不需要再删。 +同时报错现在明说:**失败的 remove 不是空操作**,剩下的是一个有洞的工具链。 + +#### 第九层:修好「不再安装」,不等于修好「已经装了的」 + +#637 合入并发布之后,mcpp #440 的 e2e 239 **还是挂在同一个文件上**。 + +原因很干脆:#637 让 recipe 不再**安装** vctip.exe,对**已经装了它的机器** +一点作用都没有 —— 而那正是卸不掉的那批机器。包的版本号不会因为 recipe 改了 +就变,于是 xim 问 `installed()`,旧 payload 回答「装好了」,安装钩子直接跳过, +vctip.exe 原地不动。**修复被自己的判据挡在门外。** + +所以 `installed()` 现在也检查 vctip.exe **不在**: + +> `installed()` 的含义是「payload 处于**这份 recipe 产出的状态**」, +> 不是「这儿有个 payload」。两者只在 recipe 变更时不一样,而那正是它要紧的时候。 + +这和 §4.8 那条「无感升级顺带成立」是同一个机制,只是这次针对的是新 recipe +**删掉**的东西,而不是新增的东西 —— 加文件靠断言文件在就能发现, +删文件必须显式说「它不该在」。 + +**顺带解释了一件事:为什么索引侧的 CI 从来没发现它。** index 的 windows-test +做的是「装 → 检查 → 卸」,全程**不编译任何东西**。而 vctip.exe 是 cl.exe +**运行时**才被拉起来的 —— 不跑编译器,就没有进程,卸载自然成功。 +所以 msvc.lua 前后几个 PR 的 windows-test 一路全绿,包括「post-uninstall checks」。 + +只有真正构建过东西的 e2e 才够得着这一条。**一个不运行被测物的验收步骤, +验的是解压。** + +### 4.11 一条不是缺陷、但今天撞了五次:「合入索引」不等于「装得到」 + +| # | 任务 | 起跑 | 索引就绪 | 差 | +|---|---|---|---|---| +| 1 | xrgui CI | 07:16:55 | 07:17:35(产物发布) | 抢跑 40s | +| 2 | xrgui CI 重跑 | 07:20:28 | 07:17:29(指针已更新) | 指针 CDN 缓存 | +| 3 | mcpp #438 e2e | 07:36:38 | 07:41:20(tar 修复入索引) | 抢跑 4.5 min | +| 4 | mcpp #440 e2e | 10:31 起跑,239 跑在 10:52 | 10:26:49(SDK 补全发布) | **赶上了** —— 构建第一次链通 | +| 5 | mcpp #440 e2e 重跑 | 11:31 起跑,239 跑在 11:39 | ~11:40(vctip 修复发布) | 抢跑 ~1 min | + +**这不是这次改动引入的**,但它意味着「合入 → 索引 → 装得到」之间有一个 +肉眼不可见的窗口,而**窗口期内的失败信息是 `not found`** —— 与「这个包根本 +不存在」一模一样。第 3 次尤其值得看:它让一个**已经修好**的缺陷看起来像 +没修好,只有对着时间戳才分得清。 + +`xlings` 已经会说 `run \`xlings update\` if the package was just published` —— +那句提示正是为这个窗口写的,它是对的,只是在 CI 里没有人能照做。 + +第 5 次是我自己踩的:合入 #637 之后**没等发布完就重跑**了 mcpp 的 CI。 +之后改成「先等 raw 索引里真的能 grep 到那行代码,再 push」—— +也就是把判据从"合入了"换成"取得到",和这一整轮的主题是同一句话。 + +小插曲,同一个毛病的第三种形态:第一版等待脚本 grep 的模式**少写了一个右括号** +(`vctip.exe") then` vs 实际的 `vctip.exe")) then`),于是它永远等不到 —— +而"等不到"和"还没发布"输出一模一样。**连我用来检查判据的东西,判据本身也得能失败。** + +**而且第 5 次差点把结论带偏**:它失败在同一个文件上,看起来像"修复没用", +实际有两个原因叠着(抢跑 + 老 payload 不会自动重装)。分清它们靠的是时间戳 +和"runner 上那份 payload 是 #637 之前装的"这个事实,不是重跑一次看看。 + +--- + +## 4b. 沙箱真实验证(`xlings subos ... --sandbox --cmd`) + +在**干净 subos** 里,对**已发布的 2026.8.16.2 二进制**做的验证: + +| 验的是什么 | 命令 | 结果 | +|---|---|---| +| 索引装得到 mcpp | `xlings update && xlings install xim:mcpp` | ✅ `xim:mcpp@2026.8.16.2` 装上 | +| xlings 生态本身可用 | `xlings install gcc` → 编译 → 运行 | ✅ `eco ok` | +| **两条来源在发布产物上真的分开了** | `mcpp toolchain install msvc system` | ✅ `the msvc toolchain is only available on Windows hosts`(探测机器那条路) | +| 同上,受管那条 | `mcpp toolchain install msvc 14.44.35207` | ✅ `Installing msvc@14.44.35207 via mcpp's xlings`(走 xim,**不是**宿主守卫) | + +最后两行是这轮架构改动**在发出去的二进制上**的判据:同一个命令、只差一个版本号, +走的是两条完全不同的路径。 + +### 两处已知、且与本轮无关的现象(如实记) + +1. **`xlings info msvc` 仍然说 `not found`。** 平台感知的新消息在 xlings#550 + 里(2026-08-16 15:16 合入),而最新 release tag 是 `v2026.8.14.1`, + 本机 0.4.51 **早于**那个修复。CI 上 E2E-76 带新断言通过,证明消息确实产生了。 +2. **干净沙箱里 mcpp 自举 gcc 失败(exit 127)。** 装出来的 gcc@16.1.0 其 ELF + 解释器指向一个本机早已不存在的目录;xlings 自己 store 里的同一个 payload + 解释器是对的 —— 说明发布产物没问题,是 mcpp 自举那步没打上 patchelf。 + mcpp 自己的日志点名了原因:SubOS 缺 `subos_info`(**openxlings/xlings#547**)。 + **已知在跟踪,与 MSVC 这轮无关,没有动它。** + +--- + +## 4c. 第十、十一层:改名改错了东西,以及"卸载"的判据 + +vctip 修好之后,占用者换成 `mspdbcore.dll` —— `/Zi` 构建拉起的 **mspdbsrv.exe**, +它比 cl.exe 多活几十秒,而且就住在正要被删的 payload 里。等它不现实(等多久都是猜)。 + +### 我先前的前提是错的,CI 当场否掉 + +我写的是:「Windows 拒绝删除含打开文件的目录,但允许**重命名**」,于是去改名 +payload 目录。**错在哪:** Windows 允许重命名一个**打开着的文件**(更新器就是这么 +替换运行中的 .exe),但**不允许**重命名一个**含有**打开文件的目录。改名失败, +remove 照样报错。 + +正确的形状是反过来的:**把文件逐个挪出去**,再删掉此时只剩目录的那棵树。 + +### 然后是第十一层:空目录也删不掉 + +文件都挪走了,`remove_all` 仍然失败 —— 这次是**目录**上的 sharing violation +(某个进程把它当工作目录;mspdbsrv 正是在 payload 里被拉起来的)。 + +判据因此定为 **「没有文件残留 = 已卸载」**: +**一个没有文件的工具链就是没装**,这正是 `remove` 承诺的事; +报失败等于报了相反的事实。骨架在下一条生命周期命令里清扫。 + +而这立刻照出对称的另半句谎:`toolchain default` 原来只查目录**存在**, +会把骨架当成装好的工具链交给构建。现在它问 `payload_frontend` 要一个 +**能解析出来的编译器**。 + +> 又是同一句话:**装好了 = 能用,不是"在那儿"。** + +### 测试:第三次得出"这个测试不可能失败" + +POSIX 上唯一能让文件删不掉的办法是去掉父目录的写权限 —— 而这个函数的**第二遍** +正是要把写权限加回来(那是它要修的只读 payload 那条)。**fixture 在被测代码碰到 +它的那一刻就变成可删的了。** 那是函数在正常工作,不是漏洞。 + +所以两个依赖该 fixture 的测试删掉了,留下的是在任何平台都成立的那两条 +(普通 payload 走普通路径、sweep 只吃 `.trash-*` 不吃装好的 toolset)。 +判据是 Windows runner 上的 e2e 239。 + +--- + +## 4d. review 落地了什么 + +| 项 | 落在哪 | +|---|---| +| §3b `doctor` 还留着 #436 修掉的第四份布局拷贝 | mcpp#440 | +| §3c `has_usable_msvc` 把"机器有 VS"当成"这里能用 MSVC" | mcpp#440 | +| §3 `/MD` 产物在干净 Windows 上跑不起来(`vc_redist_dir` → PATH) | mcpp#440 | +| §3e 主构建与 host helper 链接策略不一致(**#437**) | mcpp#442 | +| §4 `installed()` 语义写进 skill §2.2.1 + lint | index#640 | +| §5 windows-test **真的运行**声明的程序 | index#640 | +| §6 park-and-sweep 下沉到 xlings 卸载路径 | xlings#551 | + +**没做的四项**(§1 收拢 Origin、§2 SDK 绑定 + 版本轴、§3d PE 版 pack、§7 索引修订号) +都要动 spec/schema 层或跨仓库,不适合塞进发版窗口 —— 在 review 里写到了可以直接开工的程度。 + +### 一处我自己造出来的问题,已作废 + +review 初稿把 `is_system_toolchain()` 里的 `Family::Msvc` 读成"不对称,应该推广", +提议支持 `gcc@system`。**方向反了**:xlings 是用户态 OS,host 依赖是要**降到最低**的。 +被纠正后我又去给自己发明的拼写写了个"显式拒绝"(#441),那是从自己的错误里长出来的 +新范围,已关闭。而且那条消息还说错了 —— `prepare.cppm:1376` 有一条**故意保留**的 +裸 `system` 逃生口,真正提供 host 依赖的那一处**不带族名**。 + +--- + +## 5. 发布与 gitcode 资源 + +- **mcpp 2026.8.16.1**:四平台产物 + 别名 + `SHA256SUMS` 共 20 个资产, + release CI 全绿。**发布产物本身验过**:下载 linux-x86_64 跑 + `mcpp toolchain install msvc 14.44.35207`,走的是 xim 路径而不是 + `msvc_wrong_host()` —— 版本轴分流在**发出去的二进制**上生效。 + + ⚠️ 这个 tag 打在 #436 / #438 **之前**,所以它带着 §4.3 那条 + 「装好的 toolset 不出现在 `toolchain list` 里」。两个修复合入后应当补一个 + `2026.8.16.2`,让发布版本与 main 一致 —— 已列入待办,`.1` 的其余部分 + (安装、构建、default、remove)不受影响。 +- **gitcode 镜像**:四个平台产物都在 + `gitcode.com/xlings-res/mcpp/releases/download/2026.8.16.1/`(206 实测)。 + 注意 tag **不带 `v` 前缀** —— 第一次我按 `v2026.8.16.1` 探测得到 404, + 差点误报成「镜像没传上去」。 +- **MSVC 生态资源**:29 个 payload(27 + #630 新增的 2 个 Store)手动 + `gtc release upload` 到 `xlings-res/{msvc,windows-sdk}`,逐个下载校验 + sha256 与微软一致。 + +--- + +## 5b. 发布 2026.8.16.3:`publish-ecosystem` 连续两版卡在同一步 + +| | 结果 | +|---|---| +| 四平台产物 + manifest 封存 | ✅ 全部成功 | +| GitHub release 21 个资产 | ✅ 齐 | +| `publish-ecosystem` 的 gitcode 镜像步 | ❌ **10 分钟超时,重跑仍然超时** | + +`.2` 那次也是同一步超时(当时重跑成功了)。**连续两个版本卡在同一位置, +已经不是 flake。** 值得当成一条待办给 pipeline。 + +按授权用本地 `gtc` 补齐 14 个缺失资产,再**下载回来逐个哈希**:**21/21 与本地一致**。 + +### 又一次:HEAD 不能当判据 + +第一次扫描镜像用的是 HTTP HEAD,结论是"21 个全缺"。改用真实 GET 才发现 +**实际是 19 缺 2 在** —— gitcode 对确实存在的文件也会给 HEAD 返回非 200。 + +要是照着 HEAD 的结论去传,就会对着已经在的两个重复上传; +要是反过来(HEAD 说在、实际不在),就会漏。**判据必须是"字节回来了且摘要一致"** +—— 和 §4.1 那 16 个文件是同一课,只是这次我差点自己又栽一遍。 + +索引 bump 用的是 release pipeline 自己的 `version-check.py --apply --only mcpp`, +所以 sha256 是**从已发布产物算出来的**,不是手抄。 + +--- + +## 6. 镜像:地址会失效,字节不会 + +27 个 payload(325 MB)镜像到 `gitcode.com/xlings-res/{msvc,windows-sdk}`, +官方 CDN 作为**最后**一个回退。**sha256 规则一行没动** —— 从哪个地址取到都按同一个 +摘要校验,所以镜像不是第二个可信来源,是同一份字节的第二个地址。 + +回退的两个前提都单独验过:`curl -f` 遇 404 退 22 且不留文件(所以 `pcall` 接得住)、 +官方地址仍服务同样的字节。走到第一个之后的地址会 `log.warn` —— +**看不见的回退等于没有回退**。 + +--- + +## 7. 明确没有覆盖的部分 + +1. **「用受管 toolset 真的链接一次」这条,在写这份报告时正在 CI 上跑第一次**。 + mcpp#438 修好 e2e 239 的 skip 判据之后,加上索引里的 tar 修复(`8b291db`), + 239 会第一次真的装 `xim:msvc@14.44.35207` 并用它构建一个 hello。 + §4.2 那条缺陷能走到发布,正是因为在此之前**没有任何 CI 用这条 toolset + 链接过东西**;补强 `installed()` 只是让「文件在不在」变严, + **不等于「链接得通」**。 + + xrgui 目前用的仍是 `msvc@system`(见 §2 —— 那一条腿存在的意义是 + 「只有构建系统不同」,pin toolset 会让两条腿同时差两件事)。 + 用受管 toolset 构建 xrgui 是下一步,不是这一轮。 +2. **`msvc@14.52.36629` 的安装路径没在 CI 上跑过**(windows-test 装的是 14.44)。 + 本机按 recipe 的方式解包了 14.52 的五个 payload,`installed()` 要的四个文件 + 加上 `/MD` 的其余部分(`msvcrt.lib` / `vcruntime.lib` / `oldnames.lib`) + **全部就位** —— 但那验的是解包,不是 Windows 上的安装。 +3. **镜像回退没被真正触发过** —— 两个前提验过了,完整路径没执行过。 +4. **D4 的控制台那一半仍然开着**。管道那条路量到了(零撕裂,且样本是**修复前**的 + 2026.8.10.1,所以结论是「管道从来没坏过」,这收窄了 D4 而不是回答它); + CI 结不了控制台的案 —— runner 上 job 没有附着的控制台。 +5. **gitcode 的 probe 资产删不掉**(API 两个路径都 404),已在镜像 README 里点名。 +6. **index CI 用 4 个版本以前的 xlings 验证 recipe**(`ci-test.yml` 钉 + `v2026.8.10.1`)。没在这里改 —— 换版本可能影响别的 recipe,应当是它自己的 PR。 +7. **Linux 侧沙箱里 `mcpp` 自举 gcc 失败,不是这轮引入的。** + 在干净 subos + 干净 `MCPP_HOME` 里,mcpp 装的 gcc@16.1.0 其 ELF 解释器指向 + 一个**本机早已不存在的目录**,`g++ --version` 直接 127。 + xlings 自己 store 里的同一个 payload 解释器是对的 —— 也就是说发布的产物没问题, + 是 mcpp 自举那一步没打上 patchelf。mcpp 自己的日志点了名: + SubOS 缺 `subos_info`(**openxlings/xlings#547**),所以它「拒绝猜一个版本」。 + 属于已知在跟踪的问题,与 MSVC 这轮无关,**没有在这轮动它**。 + xlings 生态本身在同一个沙箱里验过是通的:`xlings install gcc` → 编译 → 运行 → `eco ok`。 +8. **`info` 的修复已合入但尚未发布**。本机 `xlings` 是 0.4.51,还是旧的, + 所以在这台机器上跑 `xlings info msvc` 依然是 `not found`。 + 证据来自 CI:E2E-76 带着新断言(`no build for` + 必须点名平台)通过, + 97/97 全绿 —— 也就是说新消息**确实产生了**,只是还没到本机。 + +--- + +## 7b. 自我 review:在自己刚打完 tag 的代码里又找到两条 + +`2026.8.16.3` tag 打完之后,我对 `v2026.8.16.2..v2026.8.16.3` 的 diff 做了一次 +独立 review,抓到两条真的: + +**1. 清扫会误删另一个正在解压的版本(🔴 数据损失)** + +`sweep_parked_payloads` 扫整个 family 目录,删掉**任何**没有文件的版本目录。 +但安装是**逐步**往目录里写文件的 —— 这正是 `package_fetcher` 用标记文件而不是 +"目录在"判断装完没有的原因。于是另一个进程半解压的目录和"残骨架"短暂无法区分, +而共用一个 `MCPP_HOME` 的机器(自托管 runner、共享开发机)上两个 mcpp 同时跑 +是常态,**恰恰就是 park/sweep 这套东西存在的理由**。 + +修法:`.trash-*` 照旧全扫(那个名字只有这段代码会写); +"没有文件的骨架"收窄成**只扫这条命令点名的那一个版本**。 + +**2. 一个 yes/no 问题在起编译器(延迟回归)** + +`msvc_available_here()` 只想知道"这儿有没有能用的 toolset",却走完整 +`installation_at()` —— 里面跑 cl.exe 拿 banner 定**版本号**,然后把它丢掉。 +而这个判据在**每次构建**的 MSVC ABI 门上都被问到,装了多个 toolset 的机器 +每次构建付好几次子进程 —— 正好是这个判据当初为之添加的那类机器。 + +修法:`installation_at(..., identifyVersion=false)`。 +**布局仍然只有一份实现** —— 是加参数,不是再抄一遍路径拼接 +(那正是 review §1 骂的事)。 + +新测试对着旧的全目录扫法跑过,**会失败**。 + +> 这一节存在本身就是判据:**"CI 全绿"和"这段代码是对的"是两件事。** +> 两条都是 19/19 绿之后才被读出来的。 + +--- + +## 8. 按你给的八个角度逐条对照 + +| 角度 | 这轮做了什么 | 依据 | +|---|---|---| +| **架构** | MSVC 拆成**两条正交的轴**:「获取」与 gcc 共用(xim),「解析」与 `msvc@system` 共用(`installation_from_tools_dir`)。受管 toolset 因此不是第二条代码路径,长不出自己的 bug | §2 | +| **稳定性** | 九层缺陷全部收口,每一层都补了**对着缺陷跑过**的测试;三处「不可能失败的测试」被识别出来,其中一处直接不写(平台上删只读文件本来就成功) | §4 | +| **优雅简洁** | 判据统一成一句话:**「装好了」= 能用,不是「解压完了」**。`installed()`、`find_windows_sdk()`、`has_usable_msvc()` 现在是同一条规则的三处应用;重复的布局拼写合并回 `toolsdir()`/`payload_frontend()` | §4.8–4.10 | +| **用户体验** | 报错说出**具体是哪个文件**(缺哪个 payload / 卡在哪个 DLL),并说清「失败的 remove 不是空操作」;99 MB 换 rc+mt 是明账,写在 recipe 头部 | §4.8、§4.10 | +| **兼容性** | `msvc@system` 语义**一字未改** —— e2e 239 专门有一条反向断言:切回 `msvc@system` 必须解析到系统 cl,否则失败 | §2、§3 | +| **跨平台** | msvc 发现逻辑第一次能在 **Windows 之外**测试(收目录/收 root 列表,不再被平台宏包住),新单测跑在 Linux CI 上;SDK 库路径按**宿主架构**拼,不写死 x64 | §3 | +| **一致性** | msvc 与 gcc 现在同构:同样的 `xim:` 获取、同样的 `payload_frontend`、同样的多地址 + sha256 镜像规则 | §2、§6 | +| **无感升级** | 版本号没变也能把老机器拉回来 —— 靠的是 `installed()` 忠实描述「**这份 recipe 产出的状态**」:新增的文件靠断言它在,**删掉**的文件靠断言它不在 | §4.8、§4.9(第九层) | + +**如果只留一句**:这九层缺陷没有一层是"写错了",全都是**验收判据比"能用"弱**。 +把判据改成"能用"之后,它们一层层自己冒出来了。 + +--- + +## 9. 复现用的工具 + +`msimap.py`(离线解 MSI 的 File/Component/Directory 表,算每个文件的安装路径) +留在 scratchpad 里。它是这轮唯一能回答"这个 MSI 里到底有什么"的东西 —— +**payload 的名字、微软的文档、我自己写的注释,三个都错了,只有表是对的。** diff --git a/.agents/docs/2026-08-16-toolchain-architecture-review.md b/.agents/docs/2026-08-16-toolchain-architecture-review.md new file mode 100644 index 00000000..300a49cb --- /dev/null +++ b/.agents/docs/2026-08-16-toolchain-architecture-review.md @@ -0,0 +1,470 @@ +# 工具链架构 review:两种来源、选择与切换、构建与分发(2026-08-16) + +> 配套 `2026-08-16-msvc-as-a-managed-toolchain.md`(设计)与 +> `2026-08-16-msvc-ecosystem-cross-repo-plan.md`(跨仓库落地)。 +> 那两份讲**已经做了什么**;这份讲**现在的形状哪里还不对,以及怎么更优雅**。 +> +> 依据是把 `msvc@` 真正跑通的这一轮:九层缺陷、五次索引发布窗口、 +> 三处"不可能失败的测试"。下面每一条都指得出具体的 `file:line`, +> 不是风格偏好。 + +> ⚠️ **§1 / §2 / §3 / §3d 已被方案取代**: +> `2026-08-16-windows-toolchain-three-axes-design.md`。 +> 那份追问推翻了这里两处形状 —— `gcc@system` 的推广方向、以及新加 +> `windows_sdk` manifest 键 —— 两处都在方案的 §0.2 里写明了为什么错。 +> 本文其余各节(§3b / §3c / §4 / §5 / §6 / §7)已落地,保留作记录。 + +--- + +## 0. 结论先行 + +已经做对的那件事,是**把"获取"和"解析"拆成两条正交的轴**: + +| | 获取(哪来的) | 解析(怎么找到 cl.exe) | +|---|---|---| +| `msvc@system` | 探测这台机器 | `installation_from_tools_dir()` | +| `msvc@` | xim 装的 payload | **同一个** `installation_from_tools_dir()` | + +受管 toolset 因此不是第二条代码路径,长不出自己的 bug。**这个模型是对的, +下面所有建议都是把它贯彻得更彻底,没有一条要推翻它。** + +问题集中在一句话:**这条轴只对 MSVC 存在,而且只贯彻了一半。** + +--- + +## 1. 一个平台特例,摊在 26 处 ⭐ + +### ⚠️ 本节初稿是错的,先说清楚 + +初稿把 `is_system_toolchain()` 里的 `Family::Msvc` 读成"不对称,应该推广到所有族", +提出支持 `gcc@system`。**方向反了,而且那个东西根本不存在。** + +xlings 是**用户态 OS**,mcpp 建立在它之上,整条设计就是**把 host 依赖降到最低**: +工具链来自 payload,这才使得"manifest 写 14.44.35207"在**每台**机器上成立, +而不是只在恰好装了它的那台上成立。`msvc@system` 是 **Windows 的让步** +(Visual Studio 常常已经装了、又不总能重新分发),不是一种可推广的能力。 + +被纠正之后我又去给自己发明的拼写写了个"显式拒绝",那是**从自己的错误里长出来的 +新范围**,已经作废(mcpp#441 已关闭)。而且那条拒绝消息还说错了一件事 —— +它写"只有 `msvc@system` 存在",可 `prepare.cppm:1376` 有一条**故意保留**的裸 +`system` 逃生口: + +```cpp +} else if (tcSpec.has_value() && *tcSpec == "system") { + // Explicit user opt-in to system PATH compiler — kept as escape hatch. +``` + +**真正提供 host 依赖的那一处,拼写是不带族的 `system`。** 记在这里, +免得下一份 review 再把它当成"缺失的能力"。 + +### 剩下的、与那个错误无关的部分 + +`registry.cppm:428` 的判据本身没问题。问题是**这个平台特例的代价被摊开了**: +lifecycle.cppm **13 处**、prepare.cppm **13 处** msvc 专有分支, +而 gcc / llvm / mingw 在 lifecycle.cppm 里**一处都没有** +(mingw 更是零分支 —— 它整个被表达成一个 *target*,`x86_64-windows-gnu`, +那才是这套设计想要的形状)。 + +其中由 `is_system_toolchain` 直接触发的四处: + +| 位置 | 内容 | +|---|---| +| `lifecycle.cppm:637` | `toolchain install` 拒绝安装 system | +| `lifecycle.cppm:848` | `toolchain default` 持久化 `"msvc@system"` **并清掉 `default_target`**(别的族没有这一步) | +| `lifecycle.cppm:931` | `toolchain remove` 拒绝 | +| `prepare.cppm:1260` | 构建期短路整条 xim 路径 | + +`prepare.cppm:1257` 那个变量叫 `tcSpecIsMsvc`,但它其实是 `is_system_toolchain` +的结果 —— 名字说的是"族",值说的是"来源"。 + +另外三处是同一条规则的**重复拼写**,和特例本身无关,纯粹是复制: + +1. `xim_tool()` + `installation_at()` + 错误信息 —— `prepare.cppm:1322-1341` + 与 `lifecycle.cppm:730-755` 几乎逐字重复 → 提一个 + `resolve_managed_msvc(env, pkg)`。第三份出现的时候就是下一个 #436。 +2. sysroot 依赖规则两份且**不等价**(`prepare.cppm:1471-1480` 只看 musl, + vs `lifecycle.cppm:694-702` musl + PE + 宿主)→ 合成一个谓词。 + `lifecycle.cppm:692` 的注释声称它们互相对应。 +3. `prepare.cppm:1002-1005` 的注释说解析是 4 步,实际链路有 **9 个输入**。 + 注释比代码老,而这段代码是**决定用哪个编译器**的。 + +### 建议:收拢,不推广 + +把"来源"变成一个显式的、**解析一次**的东西,而不是每个子命令自己再问一遍: + +```cpp +enum class Origin { Managed, SystemMsvc }; // 只有两种,而且只会有两种 +``` + +`prepare` / `lifecycle` 各解析一次,之后按它分发。特例仍然是特例, +但只在**一处**被认出来。 + +## 2. SDK 是"依赖",却被当成"搜索路径" ⭐ 高优先级 + +### 现状 + +编译器拿到了版本轴,**SDK 没有**。 + +`msvc.cppm:589` 的 `find_windows_sdk()` 按顺序扫: +`WindowsSdkDir` → 兄弟 xpkgs → 写死的 `C:\Program Files (x86)\Windows Kits\10`。 + +但对 `msvc@` 来说,SDK 是 recipe 里**声明过的依赖** +(`xim:windows-sdk@10.0.26100`),位置是确定的。确定的东西被拿去搜, +就会出现今天这两个后果: + +1. **半装的 payload 因为版本号更高,排在机器自己那套完整 SDK 前面**, + 每个 TU 都编过,最后炸在 `LNK1104: cannot open file 'kernel32.lib'`, + 而日志里没有一行提到 SDK。(已修:`pick()` 现在两半都要, + 但那是"别选错",不是"直接知道选哪个"。) +2. **manifest 无法钉 SDK 版本**。可以写 `msvc@14.44.35207`, + 不能写"配 10.0.26100 那套 SDK"。两台机器仍可能用不同 SDK 编同一份源码 —— + 正是版本轴当初要解决的那个问题,只是换到了下一层。 + +### 建议 + +对受管 toolset,**绑定**而不是搜索: + +``` +msvc@ → SDK 从同一条 xim 解析拿(和编译器同一个机制) +msvc@system → 维持现在的搜索链(机器上的东西只能靠找) +``` + +再给 manifest 一个可选的显式轴: + +```toml +[toolchain] +windows = "msvc@14.44.35207" +windows_sdk = "10.0.26100" # 可选;省略则用 toolset 声明的那个依赖 +``` + +**收益**:两条来源在**两半**上都对称(编译器 + SDK),而不是只在编译器这一半; +"声明什么就用什么"这句承诺覆盖整个工具链而不是它的一部分。 + +--- + +## 3. `cxx_runtime = "toolchain-coupled"` 对 MSVC 被拒,但工具链**确实带着**运行时 ⭐ 高优先级 + +### 现状 + +`distribution.cppm:432`: + +> `cxx_runtime = "toolchain-coupled"` has no meaning for the MSVC runtime +> (it ships with the OS/redistributable, **not with the toolchain**) + +这句话对 `msvc@system` 是对的,对 `msvc@` 是**错的**。 +受管 payload 里就有: + +``` +VC/Redist/MSVC/14.44.35112/x64/Microsoft.VC143.CRT/ + vcruntime140.dll msvcp140.dll msvcp140_2.dll vcruntime140_threads.dll +``` + +recipe 的 payload 集里明确包含 `Microsoft.VC..CRT.Redist.X64.base.vsix` +(`pkgs/m/msvc.lua:134`)—— **我们下载了它,然后一个字节都没用。** +而 VS 自身安装也带同样的目录,所以两条来源都能支持。 + +于是分发矩阵上出现一个洞: + +| | 自包含 | 宿主耦合 | 工具链耦合 | +|---|---|---|---| +| gcc / libstdc++ | 静态链接 | 用系统 libstdc++ | **拷 libstdc++ 到产物旁** ✅ | +| MSVC | `/MT` ✅ | `/MD` ✅ | **拒绝** ❌ | + +### 建议 + +PE 上实现 toolchain-coupled:把 `Microsoft.VC*.CRT/*.dll` 拷到产物旁边 —— +和 gcc 拷 libstdc++ 是同一件事、同一个语义。 + +**收益**:`/MD` 构建**可分发**,不再要求目标机装 VC++ 运行时; +三行分发契约在两个 ABI 上一致;已经付过的下载有了用处。 + +### 而且这不只是"少个功能",是 `mcpp run` 的一个潜在失败 + +默认是 `/MD`,产物链的是 `vcruntime140.dll` / `msvcp140.dll`。 +这两个**不是** Windows 自带的(自带的是 `ucrtbase.dll`,Win10 起进系统), +它们来自 VC++ 可再发行包。 + +而全仓库搜不到一处 `Redist` / `vcruntime140` / `msvcp140`: + +``` +$ grep -rn "Redist\|vcruntime140\|msvcp140" src/ +(无匹配) +``` + +也就是说:一台**只装了受管 toolset、没装过 VS 也没装过 redist** 的干净 Windows, +`mcpp build` 会成功,`mcpp run` 会以「找不到 vcruntime140.dll」失败。 +今天没暴露,是因为 CI runner 上装着 Visual Studio —— +**又一次「验收环境比目标环境富裕」**,和 §5 是同一个形状。 + +`runtimeLibraryDirs`(Windows 上就是 PATH,`env.cppm:163`)这条通道是现成的, +缺的只是把受管 payload 的 `VC/Redist/.../Microsoft.VC143.CRT` 放进去。 +所以这一条同时修两件事:**运行**(把 DLL 放上 PATH)和 +**分发**(把 DLL 拷到产物旁)。 + +**注意**:debug 的 `vcruntime140d.dll` 在 `debug_nonredist/` 下, +**不可再分发** —— 实现时必须只取 `x64/Microsoft.VC143.CRT/`,不能取 +`debug_nonredist/`。这一条要写进测试。 + +--- + +## 3b. `mcpp doctor` 里还留着 #436 修掉的那个 bug —— 现成的第四份拷贝 🔴 立刻可修 + +`doctor.cppm:398`: + +```cpp +auto bin = mcpp::toolchain::toolchain_frontend( + vEntry.path() / "bin", mcpp::toolchain::to_xim_package(s)); +if (bin.empty()) continue; // ← 装好的 msvc toolset 在这里被丢掉 +``` + +这就是 #436 修的那一条:cl.exe 在 `VC/Tools/MSVC//bin/Host//`, +深四层,`bin/` 下什么都没有 → `continue`。#436 把 +`toolchain list` 换成了 `payload_frontend(root, pkg, family)`, +**但 doctor 没跟着换。** + +后果:同一台机器上 `mcpp toolchain list` 看得见的 msvc toolset, +`mcpp doctor` 看不见 —— 两个都"成功",而且不一致。 + +**修法**:`doctor.cppm:398` 改用 `payload_frontend`。一行。 +这也是 §1 那句"布局规则被抄了第 N 份"的活证据:#436 消掉了第三份, +第四份一直在。 + +--- + +## 3c. `has_usable_msvc()` 把"这台机器有 VS"当成了"这里能用 MSVC" ⭐ 中高优先级 + +`msvc.cppm:660` 只探测机器: + +```cpp +return find_std_module_source().has_value() && find_windows_sdk().has_value(); +``` + +但它被用在三个对**受管 toolset 同样适用**的决策上: + +| 位置 | 决策 | +|---|---| +| `prepare.cppm:1097` | 首次运行时要不要把 Windows 用户导向 mingw | +| `prepare.cppm:1389` | 离线错误文案 | +| `prepare.cppm:1558` | MSVC ABI 修复门 | + +于是:**一台钉了 `msvc@14.44.35207`、但没装 Visual Studio 的机器, +这个判据回答 `false`** —— 明明工具链就在 payload 里躺着。 + +这和 §1 是同一个病:**判据问的是"族/机器",而答案取决于"来源"。** + +**建议**:拆成两个问题 —— `system_msvc_is_usable()`(现在这个)和 +`msvc_is_available(spec)`(system 就探测,受管就看 payload 在不在)。 +调用点各自选一个,而不是共用一个含糊的。 + +--- + +## 3d. Windows 上的"打包分发"是空的 ⭐ 高优先级(范围最大) + +`pack.cppm:637`: + +```cpp +#if defined(_WIN32) + // `mcpp pack` is not yet supported on Windows. +``` + +整个 `mcpp pack` 是 ELF 专用的:`LD_TRACE_LOADED_OBJECTS` / `ldd` / +`patchelf` / `tar`。Windows 上直接返回错误,让人去用 CI 的 zip。 + +而且**两套系统并不通话**: + +- `distribution.cppm` 决定的是**契约**(自包含 / 宿主耦合 / 工具链耦合), + 只影响编译链接**旗标**; +- `pack.cppm` 决定的是**真的拷哪些文件**,它**从不读 Contract**, + 也不认识工具链。 + +所以今天 `cxx_runtime` 这个契约在打包这一步是**没有执行者**的 —— +ELF 上靠 `ldd` 闭包歪打正着,PE 上则完全没有这一步。 + +**建议**(按性价比排序): + +1. **先让 §3 的 toolchain-coupled 落地**(把 VC CRT DLL 拷到产物旁)。 + 这一步不需要 `pack` —— 它属于构建产物布局,而且顺手修掉 §3 那个 + `mcpp run` 在干净机器上的失败。 +2. 再做 PE 版 `pack`:DLL 闭包用 `dumpbin /dependents` 或读 PE 导入表 + (后者无外部依赖,更符合这套代码库的口味),`is_system_lib()` 换成 + Windows 的系统 DLL 白名单(kernel32/user32/ucrtbase/…),打 zip 而不是 tar。 + 已有设计稿:`.agents/docs/2026-05-19-pack-windows-design.md`。 +3. 让 `pack` **读 Contract**:`--mode` 与 `cxx_runtime` 现在是两套词汇, + 讲的是同一件事。至少要在两者矛盾时报出来 + (`pack.cppm:192-229` 已经有这类拒绝的先例,可以照着长)。 + +--- + +## 3e. 主构建和 host helper 用不同的链接策略(#437) 🔴 已修 + +同一个工具链、同一个环境,两条链接路径给出不同结果: + +- **主构建**在 macOS 上刻意 `-fuse-ld=lld`(`flags.cppm` 的 macOS 分支, + 注释原文:*"Xcode 15.4's ld aborting at launch ... when its libc++ + resolution was diverted"*); +- **host helper**(`build.mcpp`)走 `hostflags.cppm` 的 trust-cfg 分支, + **返回空 link token**,于是用 Xcode 的 `/usr/bin/ld` —— 那个 ld 自己链 libc++, + 跑在 payload 的 `DYLD_*` 里,dyld 把它的 libc++ 解析到 payload 那份, + 缺 `__ZdaPv`,**还没开始链接就 abort**。 + +> **mcpp 用 llvm 能把整个工程构建出来,却构建不了自己的 `build.mcpp`** —— +> 这不是 macOS 环境问题,是两条路径没对齐。主构建早就绕过了这个坑, +> host helper 这条没跟上。 + +修法:trust-cfg 分支在 macOS 上也追加 `-fuse-ld=lld`。cfg 选的是 runtime, +**从来没选过链接器**。 + +**它和这份 review 其余部分是同一个形状**:一条规则有两处实现,其中一处知道 +真相、另一处不知道 —— 和 §1 那三处重复拼写、§3b 的 doctor 第四份拷贝完全同类。 +根因记在 `2026-08-13-build-optimization-status.md` §9a-3。 + +--- + +## 4. `installed()` 的语义:必须是"这份 recipe 产出的状态" ⭐ 跨仓库规则 + +这一轮九层缺陷里,真正致命的几层全是这一条。 + +### 两条子规则 + +**(a) 它必须是覆盖,不是抽样。** 每条断言对应一个"只有它才提供"的 payload。 +`windows-sdk` 现在是这样做的:`gdi32.lib` 只在 Desktop Libs +(它的 365 个库和 Store Apps Libs 的 116 个**完全不相交**),所以哪个 payload +没到,报错就点名哪一个。 + +**(b) 它必须能表达"不该在什么"。** 版本号不会因为 recipe 改了就变, +所以 `installed()` 是**唯一**能把老机器拉回来的东西。#637 让 recipe 不再安装 +`vctip.exe`,但已经装了的机器一点没变 —— 直到 #639 让 `installed()` +检查它**不在**。 + +> 新增文件靠断言"它在"就能发现,**删掉的文件必须显式说"它不该在"**。 + +### 建议 + +1. 写进 `.agents/skills/xpkg-creater/SKILL.md`(规则 + 这两个反例)。 +2. 加一条 CI lint:`install()` 落 N 个 payload 的 recipe, + `required_files()` 至少要提到 N 个互不相同的路径。 + 便宜,而且正好挡住"覆盖退化成抽样"。 + +--- + +## 5. 不运行被测物的验收步骤,验的是解压 ⭐ 中优先级 + +索引侧 windows-test 做的是「装 → 检查 → 卸」,**全程不编译**。 +而 `vctip.exe` 是 `cl.exe` **运行时**才被拉起的 —— 不跑编译器就没有进程, +卸载自然成功。于是 msvc.lua 连续几个 PR 的 windows-test 全绿, +包括「post-uninstall checks」,而卸载对任何真正构建过东西的人都是坏的。 + +### 建议 + +工具链类包的 windows-test 增加一步:**编译并链接一个真程序**,再卸载。 +至少把包自己 `programs` 里声明的东西各跑一次。 +代价是几十秒,挡住的是"装得上但用不了"这整类缺陷 —— 这一轮里它出现了四次。 + +--- + +## 6. 卸载的健壮性只做在 mcpp 一侧 ⭐ 中优先级 + +`remove_payload_tree()`(`lifecycle.cppm:224`)现在会:清只读位 → 有上限重试 +→ **改名挪走** → 下次生命周期命令清扫。 + +但 `xlings remove msvc` 有**一模一样**的问题:Windows 不让删含打开文件的目录, +而 `/Zi` 构建留下的 `mspdbsrv.exe` 就住在 payload 里。 + +### 建议 + +把 park-and-sweep 下沉到 xlings 的卸载路径。谁装的谁卸,这个能力不该只有 +mcpp 有 —— 否则每个消费者都要自己重写一遍,而写错的方式很多 +(我第一版就是"靠试删来找占用者",诊断变成了第二次破坏)。 + +--- + +## 7. 索引发布窗口没有可观测的"就绪"信号 ⭐ 中优先级 + +今天撞了**五次**。「合入 → 索引 → 装得到」之间有个肉眼不可见的窗口, +而窗口期内的失败信息是 `not found` —— 和"这个包根本不存在"一模一样。 + +最难受的是第 3 次和第 5 次:它们让**已经修好**的缺陷看起来像没修好, +只有对着时间戳才分得清。 + +### 建议 + +索引发布一个**修订号**,消费者能等到 `>= rev`。 +退一步:至少让"刚发布、还没到"与"没有这个包"在报错里能区分开。 +`xlings` 已经有 `run \`xlings update\` if the package was just published` +这句提示 —— 方向是对的,只是 CI 里没有人能照做。 + +--- + +## 8. 已经做对、不要动的部分 + +写下来是为了避免后续 review 把它们"顺手改掉": + +- **两条正交的轴**(§0)。这是整个设计的承重墙。 +- **`msvc_wants_static_crt()` 单一推导**(`dialect.cppm:134`)。 + 三处调用(`flags.cppm:613`、`flags.cppm:851`、`prepare.cppm:5023`) + 传的都是**manifest 原始标量**,所以编译器收到的 `/MT` 和分发表说的 + 自包含不可能不一致。**不要**改成读解析后的 contract —— 那会让每个 + Windows 构建都翻成 `/MT`。 +- **payload 多地址 + 单一 sha256**。镜像不是第二个信任根,是同一份字节的 + 第二个地址。 +- **能力驱动的 e2e 拆分**(`ci-windows-msvc-xlings.yml`)。用能力而不是文件清单 + 分流,新测试靠声明 `# requires: xlings-msvc` 加入,没有第二份清单会走样。 + +--- + +## 8b. 落地状态(2026-08-16 当天) + +| 项 | 状态 | +|---|---| +| §3b doctor 用 `payload_frontend` | ✅ mcpp#440 已合入 | +| §3c 拆开 `has_usable_msvc` → `msvc_available_here` | ✅ mcpp#440 已合入 | +| §3 `vc_redist_dir` → `mcpp run` 的 PATH(5 个测试) | ✅ mcpp#440 已合入 | +| §3e host helper 链接策略对齐(#437) | 🔄 mcpp#442 CI 中 | +| §4 `installed()` 规则写进 skill + lint(3 条各自对着回归跑过) | ✅ index#640 已合入 | +| §5 windows-test 真的运行声明的程序 | ✅ index#640 已合入 | +| §6 park-and-sweep 下沉到 xlings 卸载路径 | 🔄 xlings#551 CI 中 | +| §1 收拢 Origin + 消三处重复 | ⬜ 未做(动 spec 层,单独一轮) | +| §2 SDK 绑定 + `windows_sdk` 版本轴 | ⬜ 未做(要改 manifest schema) | +| §3d PE 版 `pack` + 让 pack 读 Contract | ⬜ 未做(最大一块) | +| §7 索引发布修订号 | ⬜ 未做(跨仓库) | + +**没做的四项都有共同点**:要么动 spec/schema 层,要么跨仓库 —— 都不适合在 +一次发版窗口里塞进去。§1 §2 §3d 三条在这份文档里已经写到可以直接开工的程度。 + +--- + +## 9. 建议的落地顺序 + +| # | 项 | 规模 | 影响面 | 依赖 | +|---|---|---|---|---| +| 0 | **§3b doctor 用 `payload_frontend`** | **一行** | mcpp | 无。现成的 bug,先修 | +| 1 | §4 `installed()` 规则 + lint | 小 | xim-pkgindex | 无。最便宜,挡住的最多 | +| 2 | §5 索引 CI 真编译一次 | 小 | xim-pkgindex | 无 | +| 3 | §3 MSVC toolchain-coupled(含 `mcpp run` 的 PATH) | 中 | mcpp | 无。DLL 已在 payload 里 | +| 4 | §3c 拆开 `has_usable_msvc` | 小 | mcpp | 无 | +| 5 | §2 SDK 绑定 + 版本轴 | 中 | mcpp | 无 | +| 6 | §6 park-and-sweep 下沉 | 小 | xlings | 无 | +| 7 | §1 收拢 Origin + 消三处重复 | 大 | mcpp | 建议在 §2/§3c 之后 —— 都动 spec 层 | +| 8 | §3d PE 版 `pack` | 大 | mcpp | 排在 §3 之后:§3 先解决"能跑" | +| 9 | §7 索引修订号 | 大 | xlings + 索引 | 收益最分散 | + +第 0–4 项互不依赖,可以并行,合计不大。§1 和 §3d 是两块真正的工作量, +建议各自单独一轮 —— 尤其 §1 动的是 spec 层,改完之后其余几项的 diff 会更小。 + +⚠️ §1 初稿的 `gcc@system` 提案已整段作废(mcpp#441 已关闭)—— +那是我读错了设计意图凭空造出来的东西。xlings 是用户态 OS, +host 依赖是要**降到最低**的,不是要补齐的能力。 + +**如果只做三件**:§3b(一行)、§4(规则,挡住的最多)、§3(让干净 Windows +上 `mcpp run` 真的能跑)。 + +--- + +## 10. 一句话 + +这一轮九层缺陷,没有一层是"写错了"。全都是**验收判据比"能用"弱**: +`installed()` 只查 cl.exe、只查目录;`find_windows_sdk()` 只查头文件; +索引 CI 从不编译;e2e 只能 pass 或 skip。 + +把判据改成"能用"之后,它们一层层自己冒出来了。 +上面七条建议,本质上是同一句话在七个位置的应用。 diff --git a/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md b/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md new file mode 100644 index 00000000..09fcf704 --- /dev/null +++ b/.agents/docs/2026-08-16-windows-toolchain-three-axes-design.md @@ -0,0 +1,370 @@ +# Windows 工具链的三条轴:来源、SDK、运行时(2026-08-16) + +> 取代 `2026-08-16-toolchain-architecture-review.md` 的 §1 / §2 / §3 / §3d。 +> 那份是**发现**(问题在哪、file:line),这份是**方案**(怎么改、怎么算通过)。 +> review 的 §3b / §3c / §4 / §5 / §6 / §7 已落地,不在此文范围。 +> +> 起因是 review 之后的一轮追问,它推翻了我原方案里的两处形状错误 —— +> 两处都记在 §0.2,因为**错的那版看起来同样合理**,不写下来会被重新提出来。 + +--- + +## 0. 先把三样东西分开 + +### 0.1 今天被混在一起的三层 + +Windows 上一次构建牵涉三个**互相独立**的问题,而现在它们纠缠在一起: + +| 轴 | 问的是什么 | 今天在哪 | 状态 | +|---|---|---|---| +| **来源** | 编译器**哪来的**:机器上的 VS,还是 xim 装的 payload | `is_system_toolchain()` `registry.cppm:428` | ✅ 已建模,但代价摊在 26 处 | +| **SDK** | 用**哪套**头/导入库/工具 | `find_windows_sdk()` `msvc.cppm:628` | ❌ **靠搜**,没有身份 | +| **运行时分发** | 产物**带不带** `vcruntime140.dll` | `distribution.cppm:432` | ❌ 对 MSVC **一律拒绝** | + +**判据:三个问题的答案可以两两独立取值。** 受管 toolset 可以配系统 SDK(今天 xrgui CI 就是), +系统 VS 也带自己的 Redist。任何把它们绑成一个开关的设计都是错的。 + +### 0.2 两处已被推翻的形状(不要重新提出来) + +**(a) 不要把 `@system` 推广到所有族。** + +初稿把 `is_system_toolchain()` 里的 `Family::Msvc` 读成"不对称,应该补齐",提议 +支持 `gcc@system`。**方向反了**:xlings 是用户态 OS,mcpp 建立在它之上,整条设计 +就是**把 host 依赖降到最低**。`msvc@system` 是 **Windows 的让步** +(Visual Studio 常常已经装了、又不总能重新分发),不是一种缺失的通用能力。 + +顺带记一笔:真正提供 host 依赖的那一处,拼写是**不带族的 `system`** +(`prepare.cppm` 里 `*tcSpec == "system"` 那条 `// Explicit user opt-in to system +PATH compiler — kept as escape hatch`)。别再把它当成"缺失的能力"。 + +**(b) 不要新加 `windows_sdk = "..."` 这个 manifest 键。** + +理由有两层: + +1. **模型里已经有位置了。** `runtime_binding.cppm:26` 的 `runtimeId` 注释原文就是 + *"Provider-native runtime identity (`glibc@...`, `macos_sdk@...`, `ucrt@...`)"* + —— `ucrt@...` **早就写着**,只是全仓库没有一处填它,而消费者硬编码 `glibc@` 前缀 + (`runtime_validation.cppm:387`)。 +2. **它和 `_WIN32_WINNT` 会变成两个看起来能互相替代、实际管不同事的旋钮**,见 §2.4。 + +--- + +## 1. 轴一:来源 —— 收拢特例,不是推广它 + +### 1.1 问题 + +`is_system_toolchain()` 的判据本身没问题。问题是**这个平台特例的代价被摊开了**: +lifecycle.cppm **13 处**、prepare.cppm **13 处** msvc 专有分支,而 gcc / llvm / mingw +在 lifecycle.cppm 里**一处都没有**(mingw 更是零分支 —— 它整个被表达成一个 +*target*,`x86_64-windows-gnu`,那才是这套设计想要的形状)。 + +另外三处是**同一条规则的重复拼写**,与特例本身无关: + +1. `xim_tool()` + `installation_at()` + 错误信息 —— prepare 与 lifecycle 几乎逐字重复 +2. sysroot 依赖规则两份且**不等价**(一份只看 musl,一份 musl + PE + 宿主), + 而注释声称它们互相对应 +3. 解析链的注释说 4 步,实际有 **9 个输入** + +### 1.2 方案 + +把"来源"变成一个**解析一次**的值,而不是每个子命令自己再问一遍: + +```cpp +enum class Origin { Managed, SystemMsvc }; // 只有两种,而且只会有两种 +``` + +- `prepare` / `lifecycle` 各在入口解析一次,之后按它分发 +- `parse_toolchain_spec` 对**非 msvc 的 `@system`** 显式报错并指出替代写法 + (今天是"未实现",于是失败发生在别处、消息说的是别的事) + +配套消掉上面三处重复:提 `resolve_managed_msvc(env, pkg)`;合并 sysroot 谓词; +把解析顺序变成一张**数据表**,让注释无处可撒谎。 + +### 1.3 边界 + +- **裸 `gcc` 的语义不能变**(那会改现有 manifest 的行为)。只有显式 `@system` 是新语义 +- 裸 `system`(不带族)那条逃生口**保留**,它是有意的 + +--- + +## 2. 轴二:SDK —— 绑定,而不是搜索 + +### 2.1 问题 + +编译器拿到了版本轴,**SDK 没有**。`find_windows_sdk()` 按顺序扫: +`WindowsSdkDir` → 兄弟 xpkgs → 写死的 `C:\Program Files (x86)\Windows Kits\10`。 + +但对 `msvc@`,SDK 是 recipe 里**声明过的依赖** +(`xim:windows-sdk@10.0.26100`),位置是确定的。把确定的东西拿去搜,三个后果: + +1. **环境能悄悄改写钉住的选择** —— 一个游离的 vcvars 设了 `WindowsSdkDir`, + manifest 钉的 SDK 就失效了 +2. **今天那个 LNK1104 就是搜出来的** —— 半装的 payload 因为版本号更高, + 排在机器自己那套完整 SDK 前面,每个 TU 都编过,最后炸在链接, + **日志里没有一行提到 SDK** +3. **两台机器仍可能用不同 SDK 编同一份源码** —— 正是版本轴当初要解决的那个问题, + 只是换到了下一层 + +### 2.2 方案:按来源分流 + +``` +msvc@ → SDK 从同一条 xim 解析拿(和编译器同一个机制) +msvc@system → 维持现在的搜索链(机器上的东西只能靠找) +``` + +受管那条不再"搜到就用",而是**问 toolset 声明了哪个 SDK 依赖**。搜索链降级为 +`msvc@system` 专用。 + +**`WindowsSdkDir` 的地位要一起想清楚。** 它是"有人明确指定",按"声明压过探测" +应当优先;但对受管 toolset 它又会破坏可复现性。建议: + +- `msvc@system`:`WindowsSdkDir` 优先(维持今天) +- `msvc@`:**忽略它,但要说出来** —— 一行 `note:`,别静默 + +理由是这条轴的全部意义就是"声明什么就用什么",而静默覆盖会让它失效且不可见。 + +### 2.3 运行时身份:填那个留了很久的槽 + +`runtimeId = "ucrt@10.0.26100"`,从 toolset 的 SDK 依赖投影而来,进 +`runtimeContractHash`。 + +**但它和 glibc 不完全同构,这个差别必须写进代码注释**: + +| | Linux | Windows | +|---|---|---| +| `glibc@2.39` 绑的是 | 一个 **payload**:头 + `.so` 都在里面 | — | +| 能不能真绑上去 | ✅ patchelf 让产物**真的跑在那份 glibc 上** | ❌ `ucrtbase.dll` 在系统里,**换不掉也不该换** | +| 于是标识的含义 | **运行时绑定** | **兼容性下限声明** | + +我们的 `windows-sdk` 包**只装了 ucrt 的一半**(头 + 导入库), +**没有** `Universal CRT Redistributable`,因为 Win10 起 `ucrtbase.dll` 是系统组件、 +应用不该自带。这是**有意的**,要写进注释,否则下一个人会以为能像 glibc 那样 +把 ucrt 打进产物。 + +配套:`runtime_validation.cppm:387` 与 `post_install.cppm` 里硬编码的 `glibc@` 前缀 +要从"只认 glibc"改成"按 provider 分派",否则填了也没人认。 + +### 2.4 **不要**加 manifest 的 SDK 版本键 + +一般 Windows 开发者怎么处理 SDK: + +| 做法 | 普遍程度 | 后果 | +|---|---|---| +| VS 装一个"最新",`10.0`(字面意思就是"本机最新") | 绝大多数 | **同一份源码在两台机器上链到不同 SDK,且无提示** | +| 钉死 `10.0.22621.0` | 有纪律的团队 | 声明路线 | +| xwin / msvc-wine 自己下 payload | Rust 圈 / 交叉编译 | 本质就是我们做的事 | + +**默认(跟着 toolset 依赖走)已经强过主流做法** —— 它至少保证每台机器一致, +而主流做法连这一点都不保证。 + +差别到底在哪,分两半: + +| | 影响 | 为什么 | +|---|---|---| +| **ucrt 那一半** | **很小** | Win10 起 `ucrtbase.dll` 是系统组件且向后兼容 | +| **um(Win32 API)那一半** | **会咬人** | 新 API 只存在于新 SDK 的头/导入库 | + +两种失败难度天差地别: + +- 链到**老** SDK 用了新 API → **编译期**失败,一眼看出 ✅ +- 链到**新** SDK 但跑在老 Windows → **运行期**失败(加载失败 / `GetProcAddress` 返回 null)❌ + +**而真正声明"我承诺跑在哪个 Windows"的是 `_WIN32_WINNT` / `WINVER` 宏,不是 SDK 版本。** +SDK 版本决定的是"我能看见哪些 API"。加一个 `windows_sdk =` 键会让用户以为它就是 +兼容性下限旋钮 —— **两个旋钮看起来能互相替代,实际管不同事**,这是比不做更糟的结果。 + +结论:**这一条不做**。真有少数场景要钉,先把 `_WIN32_WINNT` 的表达方式想清楚, +再决定要不要第二个旋钮。 + +--- + +## 3. 轴三:运行时分发 —— `toolchain-coupled` 对 MSVC 是成立的 + +### 3.1 问题 + +`distribution.cppm:432` 说: + +> `cxx_runtime = "toolchain-coupled"` has no meaning for the MSVC runtime +> (it ships with the OS/redistributable, **not with the toolchain**) + +这句话对 `msvc@system` 是对的,对 `msvc@` 是**错的** —— 受管 payload 里就有 +`VC/Redist/MSVC///Microsoft.VC143.CRT/{vcruntime140,msvcp140,...}.dll`, +而且 VS 自身安装也带同一个目录,所以**两条来源都能支持**。 + +于是分发矩阵上有个洞: + +| | 自包含 | 宿主耦合 | 工具链耦合 | +|---|---|---|---| +| gcc / libstdc++ | 静态链接 | 用系统 libstdc++ | **拷 libstdc++ 到产物旁** ✅ | +| MSVC | `/MT` ✅ | `/MD` ✅ | **拒绝** ❌ | + +### 3.2 已落地的一半 + +`vc_redist_dir()` 已经把 toolset 自带的那份 CRT 放进 `linkRuntimeDirs`, +Windows 上它就是 PATH —— 所以 **`mcpp run` 已经能在只有受管 toolset 的干净机器上 +跑起来**(在此之前默认 `/MD` 产物会以"找不到 vcruntime140.dll"失败, +而 CI 看不到,因为 runner 上装着 VS)。 + +### 3.3 还差的一半 + +**把那些 DLL 拷到产物旁**,即 PE 上真正实现 `toolchain-coupled` —— 和 gcc 拷 +libstdc++ 是同一件事、同一个语义。 + +⚠️ **只取 `/Microsoft.VC*.CRT/`,不能取 `debug_nonredist/`** —— +后者(`vcruntime140d.dll` 等)**不可再分发**。这条要有测试钉住, +`vc_redist_dir()` 已经这么做了,拷贝那一步要沿用同一个判据。 + +### 3.4 三层的最终形状 + +``` +ucrt → SDK 给导入库,运行时由 OS 提供 → 下限声明(runtimeId) +vcruntime → toolset 自带 Redist,可随产物走 → cxx_runtime(§3) +um/shared → SDK 给,纯链接期 → 无运行期对应物 +``` + +--- + +## 4. 打包:读二进制,别运行它 + +### 4.1 问题比"没做 Windows"更深 + +`pack.cppm:323` 的 `ldd_parse` 求依赖闭包的方式是: + +``` +LD_TRACE_LOADED_OBJECTS=1 '' +``` + +**它要把目标二进制跑起来。** 所以这不是"缺一个 PE 分支": + +- **跨不了 OS**(Linux 上跑不了 PE) +- **跨不了架构**(x86_64 上跑不了 aarch64 产物,即便同是 Linux) + +`pack.cppm:638` 那条 `#if defined(_WIN32)` 的拒绝只是结果,不是原因。 + +### 4.2 分层 + +| 层 | 职责 | 平台相关? | +|---|---|---| +| **契约** `distribution.cppm` | 决定**该带什么** | ❌ **已存在** | +| **闭包** | 静态读导入表:ELF `DT_NEEDED` / PE 导入表 / Mach-O `LC_LOAD_DYLIB` | ✅ 纯解析 | +| **系统库判据** | ELF 现有那张表 / PE 的 kernel32、user32、ucrtbase… / Mach-O `/usr/lib`、`/System` | ✅ | +| **重定位** | ELF: patchelf `$ORIGIN/../lib` / **PE: 无操作**(DLL 放 exe 旁就是规则) / Mach-O: `install_name_tool` + `@loader_path` | ✅ | +| **打包** | tar.gz / zip | ✅ | + +**换成静态解析之后,跨 OS 打包不是额外功能,是自然结果** —— 没有任何一步需要执行产物。 + +### 4.3 还有一处断链 + +**`pack` 从不读 Contract。** `MechanismInput.msvcStaticCrt` 只影响编译链接**旗标**; +`pack` 决定真拷哪些文件,却不认识 `cxx_runtime`。所以那个契约在打包这一步 +**没有执行者** —— ELF 上靠 `ldd` 闭包歪打正着,PE 上完全没有。 + +把第一层接到第二、四层,才是"一套系统"而不是"两套"。矛盾时要报出来 +(`pack` 已有"带自己 libc 的包不能消费宿主库"这类拒绝的先例,照着长)。 + +--- + +## 5. 兼容性与迁移 + +| 改动 | 是否改用户可见格式 | 迁移 | +|---|---|---| +| §1 Origin 收拢 | ❌ 纯内部 | 无 | +| §1 拒绝 `gcc@system` | ⚠️ 之前"未实现"、现在显式报错 | 消息要给替代写法 | +| §2 SDK 绑定 | ❌ 行为变更,非格式 | 受管 toolset 上 `WindowsSdkDir` 从"生效"变"忽略+提示" | +| §2 `ucrt@` 身份 | ⚠️ 进 `runtimeContractHash` → **缓存键变** | 一次性全量重建,和别的 contract 变更同类 | +| §3 PE toolchain-coupled | ❌ 新增能力 | 之前是 degraded,现在能满足 | +| §4 PE pack | ❌ 新增能力 | 之前是硬错误 | + +**唯一需要留意的是 §2 的 hash 变更** —— 它会让 Windows 上已有的构建缓存失效一次。 + +--- + +## 6. 验收判据(每条怎么算通过) + +**没有一条可以靠"CI 绿了"算过。** 这一轮十一层缺陷,每一层出现时 CI 都是绿的。 + +| # | 判据 | 为什么它不能被自我满足 | +|---|---|---| +| §1 | 26 处分支的数量**下降**,且 `gcc@system` 报错里出现替代写法 | 数量可数;消息内容有断言 | +| §2 | 受管 toolset 下,**设一个指向别处的 `WindowsSdkDir`,构建仍用 payload 的 SDK**,且打印了 note | 环境能覆盖=没绑定,这条直接证伪 | +| §2 | `runtimeContractHash` 在 Windows 上随 SDK 版本变化 | 不变就是没进 hash | +| §3 | **在一台没有 VS、没装过 redist 的干净 Windows 上**,`/MD` 产物拷到别处能跑起来 | CI runner 有 VS,**必须用干净机器或容器**,否则这条测不出东西 | +| §3 | 产物旁**没有** `*d.dll` | debug CRT 不可再分发 | +| §4 | **在 Linux 上**为 Windows 产物打出 zip,内含正确 DLL 闭包 | 跨 OS 是这条的全部意义;同 OS 打包证明不了 | +| §4 | `cxx_runtime` 与 `--mode` 矛盾时**报出来** | 静默通过=契约无执行者 | + +> §3 和 §4 的判据都要求**比 CI 环境更贫瘠的机器**。这一轮反复出现的形状是 +> "验收环境比目标环境富裕",这两条是直接针对它写的。 + +--- + +## 6.5 落地状态(2026-08-17,mcpp 2026.8.17.1) + +> 落地过程中撞到的、不在计划里的三件事(clang 在两个目标上同时出问题、一处文档 +> 自相矛盾、`--mode static` 覆盖用户 target),连同验证结果与遗留账,记在 +> `2026-08-17-windows-three-axes-final-report.md`。 + +**§1 / §2 / §3 / §4 全部实现,§2.4 明确不做。** 逐条对应: + +| 条目 | 状态 | 落点 | +|---|---|---| +| §1 Origin 建模 | ✅ | `enum class Origin`(`model.cppm`)—— 放在数据模型里,让 spec 侧(`registry`)与已定位编译器侧(`msvc`)指的是**同一根轴** | +| §1 拒绝 `gcc@system` | ✅ | `parse_toolchain_spec`,错误里同时给出 pin 与 PATH 逃生口两种写法 | +| §1 解析一次 | ✅ | `prepare` 原先把同一个字符串**解析两遍**、各自下结论;现在一次,`origin_of()` 分发 | +| §1 消重复:`resolve_managed_msvc` | ✅ | `registry.cppm`。"payload 在哪、为什么不能用 fetcher 的 `root`"两份手写实现,而理由只写在其中一份里 | +| §1 消重复:sysroot 谓词 | ✅ | `needs_linux_sysroot_payloads()`。两份**不等价**,而其中一份的注释声称它们互为镜像 —— 少的是 PE 那一项 | +| §1 解析链注释 | ✅ | 一张按 `TcOrigin` 枚举名写的表。原先两处、分别声称 3 步和 4 步,合起来点到 9 个输入里的 5 个,还互相矛盾 | +| §2.2 SDK 按来源分流 | ✅ | `msvc::resolve_sdk_for()`;受管来源忽略 `WindowsSdkDir`/`WindowsSdkVersion` 并打印 `note:` | +| §2.2 无 SDK payload 时 | ✅ | 退回机器 SDK 并**说出来**(可用 > 失败,但不可复现这件事必须留痕) | +| §2.3 `ucrt@` 身份 | ✅ | `bind_windows_ucrt()`,进 `runtimeContractHash`;`runtime_provider()` 取代各处 `starts_with("glibc@")` | +| §2.4 manifest SDK 键 | ⛔ **不做** | 理由见该节 | +| §3.3 PE `toolchain-coupled` | ✅ | `dist::Mechanism::deployToolchainRuntime` → `CompileFlags::toolchainRuntimeDeploy` → ninja `stage_file` 边 | +| §3.3 排除 debug CRT | ✅ | 判据只有一处(`vc_redist_dir()`),拷贝那步复用它而不是另立一条按名字的规则 | +| §4 静态读闭包 | ✅ | `mcpp.pack.binfmt`:ELF `DT_NEEDED`、PE 导入表 **+ 延迟导入表** | +| §4 跨 OS 打包 | ✅ | Linux 上给 Windows 产物打 zip;`mcpp.pack.zip` 自己写压缩包(没有哪个 zip 工具在每个宿主上都存在) | +| §4.3 `pack` 读 Contract | ✅ | `cxx_runtime` 决定 toolchain runtime 目录进不进搜索集;与 `--mode` 矛盾时拒绝 | + +**两处对方案本身的修正**,来自实现过程: + +1. **`dist::Format` 改为先看 target triple、再退回宿主。** 原先除 MinGW 外一律 + 问宿主,于是"Windows 上的契约"在 Linux runner 上**根本无法断言** —— 而这轮 + 大部分 review 就发生在 Linux runner 上。只**新增**答案:说不出 OS 的 triple + 仍走原来那条推导,现有构建一个都不变。 +2. **`force_bundle` 在 PE 上也要能覆盖系统表。** ELF 上一直如此;PE 上第一版把 + 系统排除写成了无条件的,那会让一个明确写下来的决定变成装饰。 + +**§4 未做的一半,以及理由。** ELF 闭包**仍然**通过运行产物取得 +(`LD_TRACE_LOADED_OBJECTS`),`binfmt` 的 `DT_NEEDED` 读取只用于识别与诊断。 +原因不是懒:`ldd` 交回的是**已解析的路径**,而 `DT_NEEDED` 只有名字,把名字变 +成路径要重新实现 loader 的搜索规则(`DT_RPATH` → `LD_LIBRARY_PATH` → +`DT_RUNPATH` → `ld.so.cache` → 默认目录,加上 `$ORIGIN` 展开与 hwcaps 子目录)。 +在一条**已经正确、且被 e2e 覆盖**的路径上重写这个,风险远大于收益。 +**代价要说清楚:跨架构的 ELF 打包(x86_64 上给 aarch64 产物打包)仍然不支持**, +而这正是 §4.1 指出的第二个限制。PE 那条没有既有实现,所以它从一开始就是静态的。 + +--- + +## 7. 落地顺序 + +| # | 项 | 规模 | 依赖 | +|---|---|---|---| +| 1 | §3.3 PE `toolchain-coupled`(拷 DLL) | 中 | 无。`vc_redist_dir()` 已在 | +| 2 | §2.2 SDK 按来源分流 | 中 | 无 | +| 3 | §2.3 `ucrt@` 身份 + 解开 `glibc@` 硬编码 | 中 | 建议紧跟 2 | +| 4 | §1 Origin 收拢 + 消三处重复 | 大 | 建议在 2/3 之后 —— 都动同一片代码 | +| 5 | §4 PE pack | 大 | 排在 1 之后:先解决"能跑",再解决"能分发" | + +**§2.4(manifest SDK 版本键)不做**,理由见该节。 + +前三项互不依赖,可并行。§1 和 §5 各自单独一轮。 + +--- + +## 8. 一句话 + +这三条轴今天被搅在一起,是因为**只有编译器那一条被显式建模过**。 +SDK 靠搜、运行时靠拒绝、打包靠一个要跑起来才work的原语 —— +每一处都在"环境恰好合适"时正常工作,而在贫瘠环境里失败, +**且失败信息不指向真正的原因**。 + +把三条轴各自变成一个**声明出来的、解析一次的值**,是这份方案唯一在做的事。 diff --git a/.agents/docs/2026-08-17-distribution-architecture-analysis-and-design.md b/.agents/docs/2026-08-17-distribution-architecture-analysis-and-design.md new file mode 100644 index 00000000..87629634 --- /dev/null +++ b/.agents/docs/2026-08-17-distribution-architecture-analysis-and-design.md @@ -0,0 +1,1615 @@ +# mcpp 分发架构:全面分析与方案(2026-08-17) + +> 覆盖:源码分发 / 二进制分发 / 多平台打包 / 私有分发,以及 issue #433 +> (「预编译 .so + .ixx/.h/.cppm 接口」)。 +> +> 前半是**发现**(现状是什么、证据在哪、file:line 与实测),后半是**方案** +> (改什么、怎么算通过)。第 7 节是需要 review 的决策点。 +> +> **⇒ 方案已独立成文:`2026-08-17-library-distribution-design.md`** +> (落地形态、docs 计划、examples、CI 验证矩阵、分期)。**本文是发现,那份是方案。** +> +> 关联 issue:#433(本文起因)、#290(描述符构建规则不能按版本区分)、 +> #304(`runtime.library_dirs` 同时落在链接线上)、#276(嵌入式 SDK 集成)、 +> #416(纯 C 包被链进 libstdc++)。 + +--- + +## 0. 一页纸结论 + +**五条结论,按重要性排序:** + +1. **#433 想要的东西,在 Linux 上今天就能跑通 —— 我实测跑通了。** + 一个「只有模块接口 `.cppm` + 预编译 `libfoo.so`」的包,通过 `[runtime]` + 的 `libraries` / `link_library_dirs` / `runtime_search_dirs` 被消费者 + `import` 并链接,`std::string` 和异常都能跨边界。**零引擎改动。** + 见 §2。 + +2. **但它「能跑」不等于「能分发」,而且失败是静默的。** 同一次实测暴露三件事: + - 产出的 `.so` 里烧着**生产者机器的绝对 RUNPATH**(`/home/speak/.mcpp/...`), + 换台机器就是另一回事 —— 而 `mcpp pack` 的 RUNPATH/INTERP 重写**只服务可执行文件,不服务库**; + - 进程里同时有**两份 C++ 运行时**(exe 静态 libstdc++ + `.so` 动态 + `libstdc++.so.6`),没有任何一层告警; + - **接口与二进制之间没有任何绑定**。我把随包发的 `.cppm` 里 + `struct Point { int x; int y; }` 改成 `{ int y; int x; }`(mangling 不变), + **编译通过、链接通过、运行通过、打印出交换后的错数据**。全程零诊断。 + +3. **真正的结构性缺口不在「能不能链」,在四处**: + ① 描述符**强制要求 `sources`**(`xpkg.cppm:2059`),所以「无源包」这个种类不存在 + —— 生态里现有的做法是造一个假的 anchor `.c`(见 `compat.openblas`); + ② `kind = "shared"` **只有 Linux/ELF**(`plan.cppm:1006` 直接拒绝), + 即 `.dll` / `.dylib` 这两条腿**在生产侧根本不存在**; + ③ **没有兼容标签**(wheel tag 那种东西)—— `abi.cppm` 有五维模型但太粗 + (无编译器版本、无 stdlib 版本、无标准档位); + ④ **安装线协议没有 target 轴**(`package_fetcher.cppm:419` 只发 + `{"targets":[...]}`),所以交叉编译一定拿到宿主 arch 的载荷。 + +4. **能力其实已经攒够了,缺的是把它们接起来。** + `cache_key.cppm` 已经在算一个覆盖 toolchain × 语言 × profile × 身份 × + 自身配置 × 上游 Merkle 的**逐包 ABI 完备键**,并且全局构建缓存 + (`$MCPP_HOME/build-cache/v1/pkg//@//`)里**已经躺着 + BMI + obj**。二进制分发格式 ≈ **可搬运的构建缓存条目**。 + xim 的 XPackage Spec V2 也**已经有一等的 arch 轴**(per-arch resource map / + URL 模板 / per-arch sha256)。 + +5. **最短路径不是索引,是文件。** issue 作者原话是「类似 `pip install runtime.whl`」。 + `mcpp add ./runtime-0.1.0-.mpkg` 不需要索引、不需要网络、不需要鉴权、 + **不需要 xlings 改一行**,完全在 mcpp 自己手里。**建议它做 P0**,索引通路做 P1。 + +7. **包自带一份 `mcpp.toml` —— 这不是新机制,是 mcpp 已有且文档推荐的 Form A** + (`prepare.cppm:2764`:描述符没有 `mcpp` 字段时,glob 载荷里的 `mcpp.toml`)。 + 它一次消掉三样东西:**新描述符键**(⇒ 不需要版本 floor ⇒ 老客户端不会被砖)、 + **新消费代码路径**、以及 —— 通过「胖包 + `[target.'cfg(...)']` 在消费者构建期 + 选 arch」—— **整个 G3**(描述符 arch 轴 + 安装线 target 轴 + 动 xlings)。 + 实测边界:`[distribution]` 标量段今天被静默接受;但 `artifacts = [{…}]` + **硬失败**,产物清单必须借用已在白名单里的 `[[runtime.artifacts]]`。 + +6. **P0/P1 按「形态」切,不按「平台」切**(§4.5.1 / §5)。`kind = "lib"` **没有平台 + 限制**,静态形态今天三平台就能产出,而且**消掉了整整四类问题**:RUNPATH 重写、 + DLL 部署、双 C++ 运行时、加载器搜索闭包 —— 上面第 2 条里的三处静默失败, + 静态形态天然只剩「接口↔二进制绑定」那一处。所以 + **P0 = static × 三平台,P1 = shared × 三平台**;按平台切会让 Windows/macOS + 在 P0 结束时拿到一个空盒子。 + +--- + +## 1. 今天的分发架构:五条通路 + +mcpp 今天有五条互相独立的「东西怎么到达另一台机器」的通路。它们从未被放在一张表里 +比较过,而 #433 的困惑正来自这里:作者看到的是通路 A 和通路 B,而他要的是通路 C, +通路 C 今天只以「手法」的形式存在,没有名字。 + +### 1.1 通路 A —— 源码分发(库) + +``` +你的仓库 → git tag → GitHub 自动生成 tag tarball + → gitcode 镜像(逐字节相同,CN 区) + → mcpplibs/mcpp-index 的 pkgs//.lua(GLOBAL + CN URL + sha256) + → publish-artifact.yml 推一个内容哈希 artifact + → 消费者 bump 版本 +``` +(`docs/10-publishing-a-library.md`) + +- 生产侧:`mcpp publish` → `git archive` 出 tarball + sha256 + 生成 `xpkg.lua` + (`publish/pipeline.cppm:69`),然后**人工**开 PR 到索引。 +- 描述符两种形态:**Form A**(tarball 里自带 `mcpp.toml`)与 **Form B** + (描述符内联 `mcpp = { ... }` 块,`xpkg.cppm:synthesize_from_xpkg_lua`)。 +- 关键事实:`make_release_info` 把 **linux / macosx / windows 三个平台块填成同一个 + URL**(`publisher.cppm:295-302`)。源码 tarball 在三个平台上是同一份字节 —— + **这条通路天生没有多平台问题,因为它根本不区分平台。** + +### 1.2 通路 B —— 应用二进制分发(`mcpp pack`) + +四个 mode(`docs/02-pack-and-release.md`):`system` / `vendored`(默认) / +`self-contained` / `static`。两条产出族:ELF/Mach-O → `.tar.gz`(`lib/` + 重写 +RUNPATH);PE → `.zip`(DLL 与 `.exe` 平铺,因为 PE 没有 rpath)。 + +- **它的核心价值不是打包,是重写**:每个 mode 都会重写 `PT_INTERP` 与 + `DT_RUNPATH`,因为开发构建产物寻址的是**这台机器**的载荷目录。e2e 215 会扫遍 + bundle 里每个 ELF,发现 `$MCPP_HOME` 下的路径就失败。 +- **它的边界:只服务可执行程序。** `mcpp pack` 的输入是 `builtBinary` + (`pack.cppm:Plan::builtBinary`),整条流水线围绕「一个 exe + 它的闭包」。 + **没有任何 `mcpp` 命令会对一个库做同样的重写。** 这是 §2.2 那条 RUNPATH + 发现的根源。 + +### 1.3 通路 C —— 预编译库的「事实上」通路(anchor TU 手法) + +今天生态里确实有预编译二进制在分发,但它没有名字,是一组手法。样板是 +`compat.openblas`(`~/.mcpp/registry/data/mcpplibs/pkgs/c/compat.openblas.lua`): + +| 需要表达的事 | 今天怎么写 | +|---|---| +| 「我没有源码要编」 | **造一个假的 `mcpp_openblas_anchor.c`**,因为 `sources` 是强制的 | +| 头文件 | `include_dirs = { "include" }` | +| 链接预编译库 | `ldflags = { "-Llib", "-lopenblas" }`(`-L` 被 mcpp 重写成 `/lib`) | +| Windows 导入库 vs 静态库 | **per-OS 块**,`windows = { ldflags = { "-Llib", "-llibopenblas" } }` | +| 运行期 DLL | `windows = { runtime = { library_dirs = { "bin" } } }` → 拷到 `.exe` 旁 | +| 平台差异的入口 | linux/macosx 由 `install()` 钩子**写出** anchor;windows 用 `generated_files` | + +这套能工作,但它是**反向表达**:包的意图是「不要编译我,链接我」,而写法是 +「编译一个什么都不做的 `.c`,顺便偷偷加几个链接 flag」。后果: + +- 「这个包是预编译的」这件事**不可查询** —— 没有字段、没有 lint、没有诊断; +- 没有任何**兼容性检查**:一个用 gcc 13 编的 `libopenblas.a` 和一个 gcc 16 的 + 消费者之间,mcpp 无话可说(C 库侥幸没事,C++ 库会炸); +- `runtime.library_dirs` 这个名字**同时管链接和运行**,这就是 **#304**。 + +### 1.4 通路 D —— 私有分发 + +今天三种形态,全部是**源码**: + +| 形态 | 写法 | 鉴权 | 状态 | +|---|---|---|---| +| path 依赖 | `foo = { path = "../foo" }` | 无需 | ✅ 可用 | +| git 依赖 | `foo = { git = "...", rev/tag/branch = "..." }` | **环境里的 git 凭据** | ✅ 可用 | +| 私有索引 | `[indices] acme = { url = "git@..." }` 或 `{ path = "/srv/index" }` | **环境里的 git 凭据** | ✅ 可用 | + +缺口: +- `IndexSpec`(`pm/index_spec.cppm:14`)**没有任何鉴权字段** —— 全靠 ambient + git credential helper / SSH key。私有 HTTPS + token 只能把 token 写进 URL。 +- `IndexSpec::artifact`(内容哈希 artifact 通道)是**为公开 GitHub Actions 设计的**, + 私有场景没有对应机制。 +- **没有「离线包」入口**:没有 `mcpp add ./something.mpkg`。这正是 issue 作者 + 要的形状。 + +### 1.5 通路 E —— 工具链与运行时载荷(xim / xlings 层) + +mcpp 自己的 payload(gcc/llvm/glibc/ninja/…)走 xim。这条通路**已经有一等 arch 轴**: +XPackage Spec V2 的 per-arch resource map / URL 模板 + per-arch sha256 / +`xpm.source`,并且是 fail-closed 的(`xim-pkgindex/docs/V2/xpackage-spec.md`)。 + +但它有一个对二进制分发致命的性质:**arch 在安装时按宿主解析** +(spec 原文:"arch is resolved per-host at install time")。而 mcpp 调用它时 +只发 `{"targets":[...],"yes":true}`(`package_fetcher.cppm:419-431`)—— +**没有 os、没有 arch、没有 triple**。交叉编译时,拿到的是宿主 arch 的载荷。 + +对源码包无害(源码 tarball 与 arch 无关)。**对二进制包是致命的。** + +### 1.6 能力矩阵 + +| | A 源码库 | B 应用 pack | C 预编译库(手法) | D 私有 | E 载荷 | +|---|---|---|---|---|---| +| Linux | ✅ | ✅ | ⚠️ 手法 | ✅ | ✅ | +| macOS | ✅ | ✅ | ⚠️ 手法 | ✅ | ✅ | +| Windows | ✅ | ✅ | ⚠️ 手法 | ✅ | ✅ | +| arch 轴 | n/a | ✅(`--target`) | ❌ 描述符载荷无 arch | n/a | ✅ V2 | +| 交叉编译 | ✅ | ✅ | ❌ 安装线无 target | ✅ | ⚠️ 按包名绕开 | +| 生产 `.so` | n/a | n/a | **仅 Linux** | n/a | n/a | +| 生产 `.dll`/`.dylib` | n/a | n/a | ❌ **不存在** | n/a | n/a | +| ABI 兼容检查 | n/a | n/a | ❌ | ❌ | ⚠️ 五维粗粒度 | +| 接口↔二进制绑定 | n/a | n/a | ❌ **实测静默错数据** | ❌ | n/a | +| 鉴权 | 公开 | n/a | n/a | ⚠️ ambient git | 公开 | +| 离线包安装 | ❌ | n/a | ❌ | ❌ | n/a | + +--- + +## 2. 实测:#433 要的东西,今天在 Linux 上已经跑通 + +复现材料在 `scratchpad/bindist/`(附录 A 有完整脚本)。 + +### 2.1 步骤与结果 + +**① 生产者**(`provider/`):模块接口只有声明,实现在实现单元里。 + +```cpp +// src/runtimelib.cppm +export module runtimelib; +export namespace rt { int answer(); std::string name(); void boom(); } +// src/impl.cpp +module runtimelib; +namespace rt { int answer(){return 42;} ... } +``` +```toml +[targets.runtimelib] +kind = "shared" +``` +`mcpp build` → `bin/libruntimelib.so`,导出**恰好两个**符号: + +``` +T _ZGIW10runtimelib ← 模块初始化器 +T _ZN2rtW10runtimelib6answerEv ← rt::answer(),带 W10runtimelib 模块附着 +``` + +**② 「二进制包」**(`dist/`):只有接口源 + 预编译库 + 一个 `mcpp.toml`。 + +``` +dist/ +├── mcpp.toml +├── src/runtimelib.cppm ← 只有声明 +└── lib/libruntimelib.so ← 从 ① 拷来 +``` +```toml +[build] +sources = ["src/runtimelib.cppm"] +[targets.runtimelib] +kind = "lib" +[runtime] +libraries = ["runtimelib"] +link_library_dirs = ["lib"] +runtime_search_dirs = ["lib"] +``` + +**③ 消费者**(`app/`):`runtimelib = { path = "../dist" }`,`import runtimelib;` + +``` +$ mcpp run +answer=42 +name=from-the-prebuilt-so (len=20) +caught runtime_error: thrown inside the .so +``` + +**跨边界的 `std::string` 和 C++ 异常(含 RTTI 类型匹配)都正常。** 消费者只编译 +了那一个 `.cppm`(产出 BMI + 一个近乎空的 object),其余符号解析到 `.so`。 +`mcpp` 引擎**一行没改**。 + +> 顺带一个与本议题无关但会绊人的坑:消费者 TU 里 `import` 必须写在 `#include` +> **之后**。写在之前时 GCC 16.1 把后续 include 的声明卷进了奇怪的作用域,报 +> `In function 'int std::main()'` —— 报错点与原因完全不沾边。 + +### 2.2 三处它没有告诉你的事 + +**(a) 发出去的 `.so` 烧着生产者机器的绝对路径。** + +``` +$ readelf -d dist/lib/libruntimelib.so + (NEEDED) libstdc++.so.6 + (RUNPATH) /home/speak/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/lib64: + /home/speak/.mcpp/registry/data/xpkgs/xim-x-gcc/16.1.0/lib64: + /home/speak/.mcpp/registry/subos/default/lib +``` + +这正是 `docs/02-pack-and-release.md` 关于路线 A 讲的那件事(「烧进去的 +`PT_INTERP` 指的是**你**的机器」),只不过对象从可执行文件换成了库 —— +而**库这一侧没有 `mcpp pack`**。手工拷贝出去的 `.so` 是机器绑定的。 + +**(b) 一个进程里两份 C++ 运行时,零告警。** + +``` +app : NEEDED = libruntimelib.so, libm, libgcc_s, libc ← 没有 libstdc++ + nm -D | grep std | wc -l = 528 ← 静态 libstdc++ 进来了 +.so : NEEDED = libstdc++.so.6 ← 动态 +``` +这是 `distribution.cppm` 里 `Role::SharedLibrary` 那段注释描述的危险,方向反过来: +exe 的静态 libstdc++ 把符号以 GLOBAL 导出,`.so` 绑到了它上面。**这次侥幸对了** +—— 两边是同一个 gcc 16.1 的 libstdc++。生产者换个编译器版本,就是一个进程里 +两份不同实现的 `std::string`。 + +契约模型本身是对的(`Role::Distributable` 默认 SelfContained, +`Role::SharedLibrary` 默认 ToolchainCoupled),问题是 **contract 是根工程的设置, +预编译 `.so` 的 contract 在生产时就冻结了,消费者看不见也检查不到。** + +**(c) 接口与二进制之间没有任何绑定 —— 这是最严重的一条。** + +把随包发的 `.cppm` 改成结构体字段互换(Itanium 不 mangle 字段顺序,符号名不变): + +```cpp +// 生产者编进 .so 的: struct Point { int x; int y; }; origin() 返回 {111, 222} +// 随包发出的接口: struct Point { int y; int x; }; ← 只改了这一行 +``` +``` +$ mcpp run +x=222 y=111 (producer meant x=111 y=222) +``` + +**编译通过、链接通过、运行通过、数据是错的、全程零诊断。** 同样的实验用返回类型 +(`int` → `long long`)也一样静默通过。 + +这不是「mcpp 的 bug」——C++ 语言层面这就是 IFNDR。但它是**分发格式必须解决的问题**: +只要接口和二进制可以被分别替换,这个失败就是可达的,而且它跨越机器和时间 +(「上周谁把那个头改了」)。 + +### 2.4 实验二:自动生成 + 接口闭包 + 跨平台(2026-08-17,`scratchpad/lab/`) + +写了一个 176 行的 `mcpp pack ` 原型(`lab/mkdist.py`),对一个真实的多单元库 +(主接口 + 接口分区 + **实现分区** + 实现单元 + C API + 头文件)跑 +**x86_64-linux-gnu / x86_64-linux-musl / x86_64-windows-gnu** 三个 target。 +它推翻了我上一轮写进本文的**两条规则**,并找出**一个新缺陷**。 + +#### 2.4.1 包内 `mcpp.toml` 能自动生成 —— 原料今天全部已暴露 + +`mcpp build --print-fingerprint` 的 11 个字段里,tag 需要的六个全在: + +``` +[1] gcc [2] 16.1.0 [4] x86_64-linux-gnu +[5] libstdc++ 16.1.0 [6] c++23 [11] 0a0ba53e8ca69b41(runtime binding) +``` + +`abi_tag` 是它们的**纯投影**,不需要任何新推导 —— §4.3 的主张就地验证。 + +**⚠️ 但 [4] 是编译器自报的 triple,不是 mcpp 的规范 triple。** +原型第一版直接用 [4],windows 那条腿产出的 tag 是 +`x86_64-w64-mingw32-gcc16-…`,而同一份 `mcpp.toml` 里 `[target.'…']` 键写的是 +`x86_64-windows-gnu` —— **同一个决定,两个拼写**。真实实现必须用 +`triple.cppm` 的规范拼写(`cfgpred` 的注释也说 cfg 词汇 **IS** 规范 triple 词汇)。 + +#### 2.4.2 「接口怎么制定」= 模块闭包,不是 `.m.o`,也不是 grep + +工程结构: + +``` +src/mathkit.cppm export module mathkit; export import :api; +src/api.cppm export module mathkit:api; ← 接口分区 +src/secret.cppm module mathkit:secret; ← 实现分区(闭源逻辑) +src/impl.cpp module mathkit; import :secret; +``` + +| 做法 | 结果 | +|---|---| +| 只发 `mathkit.cppm` + `api.cppm` + `.a` | ✅ 消费者构建成功、`add(2,3)=5` | +| 主接口改成也 `import :secret;`,仍不发它 | ❌ **硬失败**:`mathkit:secret: error: failed to read compiled module` | + +**判据:发布集 = 从主接口单元出发、沿其 purview 内 `import` 的传递闭包。** +实现分区只被实现单元 import 时**不在闭包里,不发布,源码不泄露**。 + +**失败的不对称性(这才是必须算而不是猜的理由):** + +| | 后果 | +|---|---| +| 发少了 | **响** —— 编译期 `failed to read compiled module`,点名模块 | +| 发多了 | **哑** —— 静默把闭源实现分区的源码发出去 | + +**⚠️ `.m.o` 不是判据 —— 用它会泄露源码。** 实测:实现分区 `secret.cppm` +**照样产出 `secret.m.o`**(`.m.o` 的含义是「模块单元的对象」,不是「接口的对象」)。 +按 `.m.o` 挑要发布的源,就会把 `secret.cppm` 发出去。 + +#### 2.4.3 ⚠️ 同一个闭包有第二个用途 —— 我上一轮把它写错了 + +上一轮我写「打包时剔除归档里的接口对象」,规则给的是**「剔除所有 `.m.o`」**。 +原型照做,**三个 target 全部链接失败**: + +``` +libmathkit.a(impl.o): in function `mk::add@mathkit(int, int)': + undefined reference to `mk::secret_helper@mathkit()' +``` + +因为 `secret.m.o` 里是**真代码**。正确规则与 §2.4.2 是**同一个闭包**: + +> **剔除的是「已发布接口闭包里那些单元」的对象,不是所有 `.m.o`。** + +修正后三个 target 全部构建成功,归档里剩下 `secret.m.o` + `impl.o` + `capi.o`。 + +#### 2.4.4 平台差异 + +| 事实 | linux-gnu | linux-musl | windows-gnu(MinGW) | +|---|---|---|---| +| 静态库文件名 | `libmathkit.a` | `libmathkit.a` | **`libmathkit.a`** | +| mangling | `_ZN2mkW7mathkit3addEii` | 同 | **同**(Itanium) | +| `.m.o` 命名 | 一致 | 一致 | 一致 | +| 产物 | ELF | ELF | **PE32+ executable, 18 sections** | + +- **产物命名按 `env` 分,不按 OS 分**:MinGW 走 GNU 约定(`lib*.a`), + 只有 MSVC 才是 `*.lib` —— 所以布局里的 `lib/` 必须按**三元组**而不是按 OS 分目录。 +- **MinGW 的 mangling 与 ELF 相同**,所以 MinGW 产的库能被 MinGW 消费者链接; + MSVC 不同 —— 这就是 `cxxabi` 必须是 ABI 模型一维的原因(`abi.cppm:84`)。 + +#### 2.4.5 ⚠️ 新缺陷:裸三元组谓词在原生构建下不匹配 + +胖包的每条腿要一个 `[target..build] ldflags`。原型第一版用**裸三元组**, +结果:**显式 `--target` 三个全绿,裸 `mcpp build` 链接失败**。 + +最小探针(`lab/probe/`,同一台机器、同一个解析出的三元组): + +| `[target..build] cxxflags = ["-DX"]` | 裸 `mcpp build` | `--target x86_64-linux-gnu` | +|---|---|---| +| `'x86_64-linux-gnu'`(裸三元组) | **0 处命中** | 2 处命中 | +| `'cfg(linux)'` | 2 处命中 | 2 处命中 | + +**根因一行**(`src/build/prepare_inputs.cppm:139`): + +```cpp +if (triple.empty()) return false; // ← 裸三元组分支 +``` + +而同一文件的 `context_for()`(:49-53)在 `targetTriple` 为空时**回落到 +`triple::host_triple()`**。于是 `cfg(...)` 用宿主事实求值,裸三元组分支却拿着 +原始的空串直接返回 false —— **同一个决定,两处推导**。 +`types.cppm:665` 的注释明确承诺的是另一种行为: + +> *prepare_build evaluates it against the RESOLVED target (**host triple for a +> native build**, the --target triple for a cross build)* + +**危险形状:CI 传 `--target` 全绿,开发者本机日常构建静默失配。** +与本方案的关系:**胖包必须用 `cfg(...)`,不能用裸三元组**(见 §4.8)。 +修法看起来是一行(空串时与 `host_triple()` 比),但这是独立缺陷,应单独开 issue。 + +#### 2.4.6 全矩阵验收 + +胖包改用 `cfg(...)` 后,四种构建全过,且每个 target 的 `build.ninja` 里 +**只出现自己那条腿**: + +``` +(native, 无 --target) OK +x86_64-linux-gnu OK → fatpkg/lib/x86_64-linux-gnu +x86_64-linux-musl OK → fatpkg/lib/x86_64-linux-musl +x86_64-windows-gnu OK → fatpkg/lib/x86_64-windows-gnu (PE32+) +``` + + +### 2.5 实验三:两种接口模式 × 两种库形态(2026-08-17,`scratchpad/lab/`) + +问题:「动态库 + 接口文件」的结构应该是简单好描述的 —— 能不能同时自动支持 +`.cppm`(模块)与 `.h`(头文件)两种接口? + +**结论:能,而且结构确实简单。复杂度不在布局,全部集中在「模块接口」这一种模式上。** + +#### 2.5.1 支持矩阵 —— 同一个包、同一份 `mcpp.toml`,三种消费方式 + +一个生产者(`lab/parts`)同时提供两种接口: +`include/mathkit_c.h`(`extern "C"`)+ `interface/mathkit.cppm`(模块)。 + +| | 只 `#include` | 只 `import` | **两者同时** | +|---|---|---|---| +| **静态库** `.a` | ✅ `hdr: 5` | ✅ `mod: 5` | ✅ `both: c=5 mod=5` | +| **动态库** `.so` | ✅ `hdr: 5` | ✅ `mod: 5` | ✅ `both: c=5 mod=5` | + +**六格全过,零特殊处理。** 动态库那一列的消费者 ELF 里 +`NEEDED: libmathkit.so` 确实在,`runtime_search_dirs` 进了 RPATH。 +静态/动态之间,包描述符只差 `[runtime]` 要不要 `runtime_search_dirs`。 + +**纯头文件包**(连一个 `.cppm` 都没有的传统形态)也通过 —— `include/` + `lib/` + +`ldflags`,消费者 `#include` 即用。 + +#### 2.5.2 让两种模式自动共存的规则:只有两条,由目录决定 + +``` +pkg/ +├── mcpp.toml +├── include/ ← 文本接口:原样全发,消费者 #include,不产生任何对象 +├── interface/ ← 模块接口:必须算闭包,消费者编译它得到 BMI + object +├── lib// ← 链接面 +├── bin// ← 运行面(仅 PE) +└── LICENSE +``` + +| | `include/` | `interface/` | +|---|---|---| +| 是谁的输入 | **预处理器** | **编译器** | +| 消费者要编译吗 | 否 | **是** | +| 要算闭包吗 | 否(头的 `#include` 由预处理器解决,而且头本来就全发) | **是**(§2.4.2) | +| 要从归档剔除对象吗 | 否 | **是**(§2.4.3) | +| ABI 闸门强度 | `extern "C"` ⇒ **只约束 libc**(`abi.cppm:12-16`) | 全部维度 | + +**这正是「为什么感觉应该简单」的答案:传统 C/C++ 库(头 + `.so`/`.a`)真的简单 +—— 没有闭包、没有剔除、闸门也弱。全部复杂度属于模块接口那一种模式, +而它的复杂度是可自动化的(scanner 的模块图现成)。** + +两种模式**互不干扰**,可以同时存在:实测同一个包被三种方式消费,六格全过。 + +#### 2.5.3 ⚠️ `kind = "shared"` 在 musl 上不是「被拒绝」,是「链接期炸掉」 + +`plan.cppm:1002` 那道守卫的判据是 `os != "linux"`。**musl 是 linux,所以它过闸**, +然后死在链接器里: + +``` +$ mcpp build --target x86_64-linux-musl # [targets.x] kind = "shared" +crtbeginT.o: relocation R_X86_64_32 against hidden symbol `__TMC_END__' + can not be used when making a shared object +ld: failed to set dynamic section sizes: bad value +``` + +`crtbeginT.o` 是**静态链接**的启动文件(`T` 后缀),因为 musl target 蕴含 `-static` +—— `-static` 与 `-shared` 互相矛盾。三个 target 的真实状态: + +| target | `kind = "shared"` | 诊断质量 | +|---|---|---| +| `x86_64-linux-gnu` | ✅ | —— | +| `x86_64-linux-musl` | ❌ 链接失败 | **差** —— 消息里既没有 musl 也没有 shared | +| `x86_64-windows-gnu` | ❌ 守卫拒绝 | **好** —— 点名原因与出路 | + +**守卫的判据应该是「这个 target 是否动态链接」,而不是「os 是不是 linux」。** +这条与 G2 是同一处,但比 G2 原来的描述更糟:它是一个**过了闸再炸**的洞。 + +#### 2.5.4 ⚠️ `sources = []` 今天不生效 —— 与「不写」逐字节等价 + +G1 的精确判据。在包里放一个 `src/leftover.cpp`,然后: + +| `mcpp.toml` | `build.ninja` 里 `leftover` 的出现次数 | +|---|---| +| `sources = []` | **7** | +| (整行删掉) | **7** | + +两者**完全一样** —— `toml.cppm:1703` 的 `if (sources.empty()) → 填默认 glob` +把显式空吞掉了。对二进制包的后果:**包里任何遗留在 `src/` 下的文件都会被编进 +消费者的构建**,而且可能与预编译库里的符号重复定义。作者没有任何写法能说 +「什么都不要编」。 + +(我一开始误以为这条已经能用 —— 因为测试包里根本没有 `src/` 目录, +默认 glob 也匹配不到东西。**「两条路径给出同一答案」不等于「这条路径生效了」。**) + + +### 2.3 判据 + +> **能编译链接运行,不等于能分发。** 三条判据,一条不满足就不叫「支持二进制分发」: +> 1. 产物里不含生产机器的任何绝对路径(e2e 215 已经为 exe 定了这条,库要同一条); +> 2. 接口与二进制**不可分别替换** —— 要么一起来,要么拒绝; +> 3. 消费者的工具链与产物的 ABI **不匹配时必须拒绝**,而不是链上去再赌。 + +--- + +## 3. 结构性缺口 + +按「阻塞面 × 改动成本」排,G1–G3 是硬阻塞。 + +### G1 —— `sources` 强制:没有「无源包」这个种类 + +```cpp +// src/manifest/xpkg.cppm:2058 +// Validate minimum +if (m.modules.sources.empty()) { + return std::unexpected(ManifestError{ + "synthesised manifest missing sources (mcpp segment must declare `sources = { ... }`)", ...}); +} +``` + +对 `mcpp.toml` 一侧则是另一种问题:`sources` 缺省会被填成默认 glob +(`toml.cppm:1703`),所以「作者故意没有源」和「glob 一个都没匹配上」**不可区分** +—— 我在 §2 里那个 `dist/` 包之所以要写 `sources = ["src/runtimelib.cppm"]`, +是因为接口确实要编;一个纯 C 头文件 + `.so` 的包今天只能靠 anchor 手法。 + +**判据:「不存在」与「显式为空」必须可区分。** 仓库里已有这个模式 +(`XlingsConfig::subosDeclared`,`types.cppm:632`)。 + +**实测(§2.5.4):`sources = []` 与整行删掉在 `build.ninja` 里逐字节等价** +(同一个遗留 `src/leftover.cpp`,两种写法都是 7 处命中)。所以作者今天**没有任何 +写法**能表达「什么都不要编」—— 对二进制包,这意味着包里任何遗留在 `src/` 下的 +文件都会被编进消费者的构建,并可能与预编译库里的符号重复定义。 + +### G2 —— `kind = "shared"` 只有 Linux + +```cpp +// src/build/plan.cppm:1002 +if (!targetTriple.empty() && targetTriple.os != "linux") { + for (auto const& t : manifest.targets) { + if (t.kind != Target::SharedLibrary) continue; + return std::unexpected("shared libraries are only supported for Linux (ELF) targets today..."); + } +} +``` +注释写得很清楚:PE 消费者需要导入库、Mach-O 需要 install-name,**两者都没建模**, +所以宁可拒绝也不产出没人验证过的东西。每个 shared 相关 e2e 都写着 +`# requires: elf`。 + +**这条直接把 #433 的 `.dll` / `.dylib` 两条腿砍掉了。** 不是分发格式的问题, +是**生产侧根本产不出来**。 + +**⚠️ 而且守卫的判据是错的(§2.5.3)。** 它问的是 `os != "linux"`, +但 `x86_64-linux-musl` **是** linux —— 于是它过闸,然后死在链接器里: +`crtbeginT.o: relocation R_X86_64_32 against hidden symbol '__TMC_END__'` +(musl target 蕴含 `-static`,与 `-shared` 矛盾)。消息里既没有 musl 也没有 shared。 +**正确判据是「这个 target 是否动态链接」,不是「os 是不是 linux」。** + +好消息:相邻机械已经在了 —— `ArtifactNaming::sharedNeedsImportLib` 这个字段存在 +(`plan.cppm:467`),Windows 运行期 DLL 部署也已经有(e2e 84 / #299)。 + +### G3 —— 载荷侧的 arch 轴与 target 轴 + +分成两个子问题,状态完全不同: + +| 子问题 | 状态 | +|---|---| +| 描述符的**构建规则**能否按 arch 分叉 | ✅ **已经可以** —— `target_cfg = { ["cfg(arch=\"aarch64\")"] = {...} }`(`xpkg.cppm:1315`),按**解析后的 target** 求值 | +| 描述符的**载荷**能否按 arch 分叉 | ⚠️ xim V2 有(per-arch map / 模板 / sha256),但**按宿主 arch 解析** | +| mcpp → xlings 的安装调用能否带 target | ❌ **不能**,`make_targets_args` 只有 `{"targets":[...],"yes":true}` | +| 描述符的 `mcpp` 块 per-OS 分叉 | ✅ linux/macosx/windows,且已按 **TargetPlatform** 而非宿主(#254 已修) | + +另外 `target_cfg` 只承载 `BuildInputs`(cflags/cxxflags/ldflags/sources/defines/ +flags/include_dirs/include_dirs_after,`xpkg.cppm:1359-1377`),**不含 +`runtime` / LinkIntent** —— 所以「per-arch 的 `link_library_dirs`」写不出来。 +这是 #258 同一形状的债:条件通道自己维护了一份子集。今天可以用 +`ldflags = {"-Llib/aarch64", "-lfoo"}` 绕过。 + +### G4 —— 没有兼容标签 + +`toolchain/abi.cppm` 建模了五维(libc / cxxStdlib / arch / os / cxxAbi), +并且有 `abi:=` 的约束语言。对 **C 库**够用(它的注释原文就是 +"A C library only constrains libc")。对 **C++ 模块库不够**,缺: + +- 编译器**族与主版本**(mangling、libstdc++ 符号版本、模块实现细节) +- stdlib **版本**(`Toolchain::stdlibVersion` 有,但 `AbiProfile` 里没有) +- **C++ 标准档位**(`c++23` vs `c++26` 会改变 `std::` 的可见面与部分 ABI) +- **C++ 运行时契约**(`self-contained` / `toolchain-coupled` / `host-coupled`) + +而这些**全都已经在 `cache_key::BuildAxes` 里了**(`cache_key.cppm:76-95`)。 +不是没有,是没有对外的、可读的、可发布的投影。 + +### G5 —— 接口与二进制没有绑定 + +§2.2(c) 实测。没有 digest、没有 provenance、没有符号存在性检查。 + +### G6 —— 没有面向库的 pack + +`mcpp pack` 的输入是一个 exe。库的 RUNPATH 重写、`$ORIGIN` 化、 +第三方闭包收集、`HOST-REQUIREMENTS` 生成 —— 这些逻辑全在 +`pack.cppm` / `binfmt.cppm` / `host_requirements.cppm` 里,**只差一个入口**。 + +### G7 —— 私有分发没有鉴权轴、没有离线入口 + +见 §1.4。`IndexSpec` 无鉴权字段;没有 `mcpp add ./x.mpkg`。 + +### G8 —— `runtime.library_dirs` 的 link/runtime 混淆(#304,已有 issue) + +新键(`link_library_dirs` / `runtime_search_dirs`)已经把两件事分开了 +(`docs/05-mcpp-toml.md` §2.11 的表),legacy `library_dirs` 仍然两边都进。 +**二进制分发会把这个坑放大**:预编译包必然要声明库目录,而符号farm 式的包 +(`compat.vulkan-runtime`)会因此污染链接线。 + +### G10 —— 裸三元组谓词在原生构建下不匹配(新发现,应单独开 issue) + +`prepare_inputs.cppm:139` 的 `if (triple.empty()) return false;` 让 +`[target.''.build]` 在**没有 `--target`** 时永不命中,而同文件的 +`context_for()`(:49-53)对 `cfg(...)` **回落到 `host_triple()`` ` —— +同一个决定两处推导。`types.cppm:665` 的注释承诺的是回落那一种。 +**危险形状:CI 全绿、本机静默失配。** 详见 §2.4.5(含最小探针)。 +直接阻塞胖包的裸三元组写法。 + +### G9 —— 描述符构建规则不能按版本区分(#290,已有 issue) + +对二进制分发直接相关:同一个包的 `0.1.0` 和 `0.2.0` 可能有不同的 +库文件名 / soname / 依赖集。今天 `mcpp = {}` 块对所有 `xpm` 版本一视同仁。 + +--- + +## 4. 架构方案 + +### 4.1 先定一件事:分发层级是**消费端**选的 + +这是整个方案的形状来源。生产者**发布多个层级**,消费者**按自己的工具链挑一个**, +挑不到就降级到源码。生产者不能替消费者决定,因为「你的编译器是什么」只有消费端知道。 + +| Tier | 包里有什么 | 生效条件 | 失配时 | +|---|---|---|---| +| **S** source | 全部源码 | 永远 | —— | +| **I** interface+binary | 接口源(`.cppm`/`.h`)+ 预编译库 | **abi-tag 匹配** | 降级到 S;S 不存在则**明确拒绝并列出可用 tag** | +| **B** +BMI | 再加预编译 BMI | **build-key 精确匹配** | **静默**降级到 I | + +三条纪律: + +- **Tier B 只能是加速器**,永不成为正确性依赖。失配必须静默降级,不得报错。 +- **Tier I 失配必须响**。这是 §2.3 判据 3。 +- **「因为客户端太老而不可用」必须报告成「不可用」,不能报告成「不存在」** + —— 这条是 #349 索引 floor 那次的教训(被拼成「不存在」的客户端会自己驱动 + 重复刷新索引)。 + +### 4.2 核心原语一:`[distribution]` 段 —— 一条可查询的事实 + +> **相对初稿的修订。** 初稿提议 `[targets.] kind = "prebuilt"`。 +> 它有一个致命性质:**描述符里的新键不降级**(`docs/10` 点名的那一类), +> 老客户端会被砖。改成 `[distribution]` 段之后,老客户端读到的仍是 +> `sources` + `[runtime]`(**已发布能力**),而 `[distribution]` 被**静默跳过**。 +> **兼容性从「要版本 floor」变成「几乎免费」。** +> +> **⚠️ 实测钉死的两条边界(mcpp 2026.8.15.3):** +> 1. `[distribution]` 里放**标量 + 字符串数组**:**接受,且连警告都没有** ✅ +> 2. `artifacts = [{ … }]`(array-of-tables):**硬失败** ❌ +> ``` +> error: [[distribution.artifacts]] (array-of-tables) is not allowed for +> section 'distribution.artifacts'; array-of-tables syntax is only supported +> for [[build.flags]], [[features..flags]], [[runtime.requirements]], +> and [[runtime.artifacts]] +> ``` +> **所以产物清单不能自己造 —— 必须用已在白名单里的 `[[runtime.artifacts]]`** +> (它的字段恰好就是 `role`/`path`/`provenance`/`abi`/`digest`/ +> `host_fingerprint`,`docs/05-mcpp-toml.md` §2.11 已经文档化)。实测: +> `[distribution]` 标量段 + `[[runtime.artifacts]]` 一起,今天的 mcpp 直接通过。 +> +> **诚实的代价:静默跳过意味着老客户端拿到预编译包时「一道闸门都没有」**, +> 拿到的是今天的行为(能用、但不安全)。这是**降级**而不是变砖,方向是对的; +> 但它也意味着闸门只保护新客户端 —— 这一点必须写进发布说明,不能假装没有。 + +```toml +[build] +sources = ["interface/runtimelib.cppm"] # 只有接口;实现在预编译产物里 +# 或 sources = [] # 纯 C 头 + .a 的包:显式为空 ≠ 缺省 + +[distribution] +artifact_kind = "static" +abi_tag = "x86_64-linux-gnu-gcc16-libstdcxx16-c++23" +... +``` + +**两件事,不要合并:** + +| | 解决什么 | 为什么不能只要一个 | +|---|---|---| +| `sources = []` **显式为空** | 「不要填默认 glob」 | 今天缺省会被填成 `src/**`(`toml.cppm:1703`),于是「作者故意没有源」与「glob 一个都没匹配上」不可区分 | +| `[distribution]` 段 | 「这个包的产物不来自编译」——**一条可查询的事实** | 闸门、`mcpp why`、lint、以及 §4.5.0(d) 的 build 守卫都要读它;挂在 `[build]` 上会让普通源码包也能声明 `abi_tag`,那就成了一个可以说谎的地方 | + +**Appendix A(schema ownership)检验:** mcpp 定义**机制**(产物来自文件而非编译边、 +以及一组闸门),**词汇留在值里**(路径、tag 字符串、`static`/`shared`)。键封闭。✅ + +#### 4.2.1 `[pack]` 字段的取舍 + +一句话:**能从 mcpp.toml 别处推出来的,就不给字段。** +按这条逐条审计之后,`[pack]` **新增 0 个键** —— WHAT/HOW 由 `[targets.].kind` +回答(`mcpp pack [target]`),接口根由 `[lib]` 约定回答,头目录由 +`[build].include_dirs` 回答,平台由 `[package].platforms` 回答(升格为**断言**)。 +完整的推导审计、以及「什么不允许裁剪」的一致性论证在 **§4.6.1**。 + +### 4.3 核心原语二:两个兼容量,不是一个 + +**必须是两个,因为它们回答两个不同的问题。** + +| | `abi-tag` | `build-key` | +|---|---|---| +| 回答 | 「这份二进制能不能链进你的构建」 | 「这份 BMI 能不能直接用」 | +| 谁能算 | **生产者**(消费者存在之前就能枚举) | **只有消费者**(含依赖闭包 Merkle) | +| 粒度 | 粗、可读、可发布 | 精确、16 hex、不可读 | +| 用途 | 决定**下载哪个载荷** | 决定 **Tier B 命中** | +| 来源 | `AbiProfile` + `Toolchain` + `CppStandardConfig` | `cache_key::key_hex`(**已存在**) | + +**判据:如果试图只用一个,就会发现你无法发布 build-key** —— 它含依赖闭包, +每个消费者的图都不同,生产者得为每种图各发一份,不可能。 + +**`abi-tag` 的组成(6 段,全部来自已有字段):** + +``` +-----c++ + +x86_64-linux-gnu-gcc16-libstdcxx16-c++23 +aarch64-macos-none-llvm22-libcxx22-c++23 +x86_64-windows-msvc-msvc194-msvcstl194-c++23 +``` + +- `arch`/`os`/`env`:`triple.cppm` 已有的规范拼写。 + **⚠️ 不是 `--print-fingerprint` 的 [4]** —— 那是编译器自报的 + (`x86_64-w64-mingw32`),与 `[target.'…']` 键的拼写(`x86_64-windows-gnu`) + 不同,直接用会让同一个决定有两个拼写(§2.4.1 实测踩到过); +- `compiler`:`Toolchain::compiler_name()` + `version` 的主段; +- `stdlib`:`Toolchain::stdlibId` + `stdlibVersion` 的主段; +- `c++`:`CppStandardConfig::level`。 +- `cxxAbi` 不入 tag —— 它由 (os, compiler) 唯一确定,放进去是第二个答案。 + +**不入 tag、但必须单独声明并检查的两项:** + +| 字段 | 为什么不入 tag | 检查语义 | +|---|---|---| +| `cxx_runtime`(契约) | 它可以**协商**:toolchain-coupled 的 `.so` 是能被 self-contained 的 exe 消费的(§2.2b 实测),只是有危险 | 不匹配 → **警告并说清危险**,`--strict` 升级为错误 | +| `abi_surface = "c" \| "cxx"` | 纯 `extern "C"` 接口只约束 libc 这一维 —— `abi.cppm` 的注释原文就是这个规则 | `"c"` → 只比 arch/os/env,忽略 compiler/stdlib/std | + +`abi_surface = "c"` 这个逃生口很重要:它让**绝大多数传统 C 库**(zlib、openblas、 +ffmpeg)只需要一个 tag 就覆盖所有编译器,tag 组合数从 N×M 掉回 N。 + +### 4.4 核心原语三:接口↔二进制的绑定 + +三道闸,由弱到强,**全都要**: + +1. **interface digest** —— 包元数据里记随包接口文件的 sha256。消费者编译前重算, + 不符即拒。挡住「有人改了随包的头」。 +2. **符号存在性交叉检查** —— 编完接口后 mcpp 知道模块名 `M`,去预编译库里查 + `_ZGIW`(ELF/Mach-O)/ 导出表(PE)。挡住「配错了库」。便宜,值得做。 +3. **原子产出 + provenance** —— **接口副本与二进制必须由同一次 `mcpp pack ` + 产出**,元数据里记 `built_by` / `build_key` / `source_digest`。 + 手工拼装的包 mcpp 拒绝消费。 + +**必须诚实说清楚:三道闸都挡不住「结构体字段顺序变了但两边都自洽」**—— +只要接口和二进制**能被分别替换**,§2.2(c) 就是可达的。唯一充分的保护是 +**它们一起产出、一起分发、不可分别替换**。所以第 3 条才是根,前两条是防御。 + +### 4.5 包格式:**不是新格式 —— 是一个自带 `mcpp.toml` 的 Form A 包** + +> **本节相对初稿是重写。** 初稿提议一个新格式 `.mpkg` + 一份并列的 +> `MCPP-PACKAGE.toml`。**那是错的形状**,理由见 §4.5.0:mcpp 早就有 +> 「包自带 `mcpp.toml`」这条通路,而且它是文档推荐的那一种。 + +#### 4.5.0 为什么自带 `mcpp.toml` 是对的形状 + +**它已经是既有机制。** 索引描述符没有 `mcpp` 字段时,mcpp 在解开的载荷里 +glob `mcpp.toml` / `*/mcpp.toml` 并当作该依赖的 manifest 加载 +(`prepare.cppm:2764-2787`)。`docs/10-publishing-a-library.md` 原话: +*"A repo that ships its own `mcpp.toml` needs no `mcpp` field in the index entry."* +而 LinkIntent 是**逐包聚合**的(`plan.cppm:630-656`,路径按 `package.root` +转绝对),所以 path / git / 索引 tarball 三条路吃的是同一套 —— §2 的实测走的是 +path 依赖,索引 tarball 汇合到同一处。 + +**三个后果,第一个是决定性的:** + +**(a) 不需要新描述符键 ⇒ 不需要版本 floor ⇒ 老客户端不会被砖。** + +初稿的 `kind = "prebuilt"` 正好属于 `docs/10` 点名警告的那一类**不降级**的键 +(和 `module_extensions` 同类):老 mcpp 读不懂它,不是「警告后忽略」,而是硬失败 +或者更糟 —— 把 `interface/` 当普通源编译然后链不上。走 Form A 就完全不需要它: +老客户端读到的是 `sources = ["interface/…"]` + `[runtime] libraries / +link_library_dirs`,**全部是已发布能力** —— §2 的实测就是在 **2026.8.15.3** 上 +跑通的。**兼容性几乎免费。这是 Form A 相对新描述符键的决定性优势。** + +**(b) 胖包(fat package)可以绕开整个 G3。** + +包里带的是 `mcpp.toml`,于是它可以写 `[target.'cfg(...)'.build]` —— +而这个条件轴**按解析后的 target 求值**,也就是在消费者那边、在 `--target` +已知之后。所以一个包同时装 x86_64 / aarch64 / linux / windows 的库, +**选择发生在构建期**: + +- 不需要描述符 arch 轴; +- 不需要安装线 target 轴; +- **不需要动 xlings 一行。** + +这把「交叉编译 + 二进制包」从 P2(跨仓库)搬到了 **P0 就能表达**。 +代价是下载体积(可用「胖包默认 + 瘦包按 tag」两种发布方式并存来缓解)。 +今天的拼写限制:条件轴只承载 `BuildInputs`(**无 LinkIntent**),所以 per-arch +只能写 `ldflags = ["-Llib/", "-lfoo"]`,写不了 `link_library_dirs`。 +能用,但丑;干净的修法仍是让 LinkIntent 过条件轴(G3 收尾)。 + +**(c) 闸门字段必须放进 `mcpp.toml`,不能放旁路文件。** + +理由不是省一个文件,是 **path 依赖也需要闸门**。如果 abi-tag / interface digest +只存在于 `mcpp pack ` 写的旁路文件里,那么「把一个目录拷给同事」这条最常见的 +内部路径就没有任何检查 —— 而 §2 的实测证明这恰恰是人们会走的路。 +所以:**`MCPP-PACKAGE.toml` 取消,内容并入 `mcpp.toml` 的 `[distribution]` 段。** + +**(d) 必须加的守卫 —— 一个字面叫 `mcpp.toml` 的文件躺在解开的二进制包里, +会引诱人在里面 `mcpp build`。** + +今天它会**成功**:把 `interface/` 里只有声明的接口单元编出来,产出一个几乎空的 +库,而实现全在预编译产物里、根本没被链进去。典型的「看起来成功的失败」。 +**`[distribution]` 存在时,在该目录直接 `mcpp build` 必须拒绝**,并说清这是分发包 +不是源码树。 + +#### 4.5.1 布局 + +``` +runtimelib-0.1.0-x86_64-linux-gnu-gcc16-libstdcxx16-c++23.tar.gz (PE: .zip) +└── runtimelib-0.1.0/ + ├── mcpp.toml ← 生成物,含 [distribution] 段;消费端零新代码路径 + ├── interface/ ← 消费者要编译的 .cppm / .ixx + ├── include/ ← 非模块消费者的头 + ├── lib/ ← 链接面:.a / .so / .dylib / **.lib(PE 导入库)** + ├── bin/ ← 运行面(仅 PE):.dll,必须部署到 .exe 旁 + ├── bmi/ ← 可选 Tier B + ├── HOST-REQUIREMENTS ← 与 mcpp pack 同一份推导 + └── LICENSE / THIRD-PARTY +``` + +**归档格式与源码包同构**(tar.gz / zip),索引条目形状**一字不改** +(url + sha256 + 三平台块)。文件名带 tag 只是**人读的命名约定**,不是新格式 —— +`mcpp add ./` 靠 `[distribution]` 段识别,不靠扩展名。 + +**两条规则,由文件所在的目录决定 —— 这就是全部**(§2.5.2 实测): + +| | `include/` | `interface/` | +|---|---|---| +| 是谁的输入 | 预处理器 | **编译器** | +| 消费者要编译吗 | 否 | **是** | +| 要算闭包吗 | 否 | **是** | +| 要从归档剔除对象吗 | 否 | **是** | +| ABI 闸门 | `extern "C"` ⇒ 只约束 libc | 全部维度 | + +两种模式**互不干扰、可同时存在**:实测同一个包被「只 `#include`」/「只 `import`」/ +「两者同时」三种方式消费,× 静态库/动态库两种形态,**六格全过**。 +**传统 C/C++ 库(头 + `.so`/`.a`)因此真的简单** —— 没有闭包、没有剔除; +全部复杂度属于模块接口那一种模式,而它是可自动化的。 + +**`lib/` 与 `bin/` 分开是机制,不是风格。** + +| 类别 | ELF | Mach-O | PE | +|---|---|---|---| +| 链接面 | `lib/libfoo.so` | `lib/libfoo.dylib` | **`lib/foo.lib`(导入库)** | +| 运行面 | 同一个文件 | 同一个文件 | **`bin/foo.dll`(另一个文件)** | + +PE 上「链接的东西」和「运行的东西」是两个不同的文件,ELF/Mach-O 上是同一个。 +一个只分 `interface/` + `lib/` 两层的布局在 Windows 上表达不出这件事 —— 这正是 +`compat.openblas` 的 windows 块必须同时写 `ldflags = {"-Llib","-llibopenblas"}` +**和** `runtime = { library_dirs = { "bin" } }` 的原因。 + +#### 4.5.2 `artifact_kind = "static" | "shared"` —— 一个布局,两种形态 + +**不要为动态库单开一个命令。** 形态是布局里的一个字段,理由是静态那条腿 +**今天三平台就能走通,而且把一整类问题消掉了**(实测,见下)。 + +``` +$ mcpp build # kind = "lib",无平台限制 + → bin/libstatlib.a +$ ar t libstatlib.a → statlib.m.o impl.o +$ nm --defined-only + statlib.m.o: T _ZGIW7statlib ← 接口单元对象:只有模块初始化器 + impl.o: T _ZN2slW7statlib6answerEv ← 实现单元对象:真正的符号 +$ readelf -d libstatlib.a → 无 RUNPATH(归档没有动态段) +``` + +| | `static` | `shared` | +|---|---|---| +| 三平台产出 | ✅ **今天就有**(`kind="lib"` 无平台限制) | ❌ **仅 Linux**(G2) | +| RUNPATH 重写(§2.2a) | **不需要** —— 归档没有动态段 | 必须 | +| 双 C++ 运行时(§2.2b) | **不会发生** —— 由消费者自己的契约统一 | 会,需检查 | +| 运行期部署 | 无 | PE 需部署 `.dll` | +| 加载器搜索闭包 | 无 | 需要 | +| 代价 | 消费者产物体积;不能热替换 | —— | + +**对「闭源库内部分发」这个场景,`static` 往往是更好的默认。** + +**一处必须做成结构性保证、而不是巧合的事:打包时剔除已发布接口单元的对象。** +上面的实测显示归档成员切分是干净的 —— 消费者自编接口后会定义 `_ZGIW7statlib`, +于是 `statlib.m.o` 这个成员没有任何未定义符号需要它,**根本不会被拉进来**。 +但这依赖「归档成员只在解析未定义符号时才被拉取」这条链接器行为; +`--whole-archive`、或该成员里恰好还有别的被引用符号,都会让它被拉进来并与 +消费者自编的接口对象重复定义。打包器把它剔掉,这条风险就不存在了。 + +> **⚠️ 剔除集是「已发布闭包里那些单元的对象」,不是「所有 `.m.o`」。** +> 本文上一版写的是后者,**实测三个 target 全部链接失败**(§2.4.3): +> `.m.o` 的含义是「模块单元的对象」,实现分区照样是 `.m.o` 且里面是真代码。 +> 剔除集与发布集是**同一个闭包的两个用途**(§2.4.2)—— 这正是它们必须由 +> 同一次推导给出、而不是各算各的的理由。 + +#### 4.5.3 包内 `mcpp.toml`:既有的键 + 一个新段 + +**上半部全是今天就能跑的键**(§2 实测,mcpp 2026.8.15.3): + +```toml +# ══ 生成物。手工编辑会使 [distribution].interface_digest 失配并被拒绝。 ══ +[package] +namespace = "acme"; name = "runtimelib"; version = "0.1.0" + +[build] +sources = ["interface/runtimelib.cppm"] # 只有接口,消费者编译它得到 BMI + +[targets.runtimelib] +kind = "lib" + +[runtime] +libraries = ["runtimelib"] +link_library_dirs = ["lib"] +runtime_search_dirs = ["lib"] # shared 形态才需要 +# deploy_files = ["bin/runtimelib.dll"] # PE + +# 胖包:选择在消费者的构建期发生,按解析后的 target 求值 +[target.'cfg(all(linux, arch = "aarch64"))'.build] +ldflags = ["-Llib/aarch64-linux-gnu", "-lruntimelib"] +``` + +**下半部是闸门 + 证据。⚠️ 只能是标量与字符串数组 —— 产物清单必须借用已在 +array-of-tables 白名单里的 `[[runtime.artifacts]]`(见 §4.2 的实测边界):** + +```toml +[distribution] +schema = 1 +artifact_kind = "static" # static | shared +abi_tag = "x86_64-linux-gnu-gcc16-libstdcxx16-c++23" +abi_surface = "cxx" # cxx | c(纯 extern "C" 只约束 libc) +cxx_runtime = "toolchain-coupled" +modules = ["runtimelib"] +interface_digest = "sha256:…" # 覆盖 interface/ 下每个文件 + +# ── 以下是证据,不是旋钮。用户手写 = 错误,不是「覆盖」。 ── +built_by = "mcpp 2026.8.17.1" +build_key = "aeb4c4d29e437696" # cache_key::key_hex,Tier B 用 +source_digest = "sha256:…" + +# 产物清单 —— 复用既有的、已文档化的段(docs/05 §2.11),不新造。 +# 它的字段恰好就是需要的:role / path / provenance / abi / digest / host_fingerprint。 +[[runtime.artifacts]] +role = "static-library" +path = "lib/libruntimelib.a" +provenance = "mcpp-pack" +abi = "x86_64-linux-gnu-gcc16-libstdcxx16-c++23" +digest = "sha256:…" +``` + +**实测(mcpp 2026.8.15.3):上面这整份 —— `[distribution]` 标量段 + +`[[runtime.artifacts]]` —— 今天的 mcpp 原样接受并构建成功。** +(顺带一个可观察的证据:加上 `[[runtime.artifacts]]` 后 fingerprint 变了, +说明它确实被读进 manifest 并折进了构建身份,不是被丢掉。) + +**为什么证据字段也放这里、而不是旁路文件:**多一个文件就多一个能和主文件漂移的 +地方 —— 这是仓库反复出现的那条教训(`publisher.cppm:194-216`: +「THE SAME DERIVATION … 分开推导就是它们漂移的方式」)。代价是必须有一条 +**「用户手写了生成字段 = 错误」**的规则,而这条规则本来也需要 +(`built_by` 被人改成别的值,比没有它更糟)。 + +**一份推导,三个投影**(比初稿少一个 —— 包内文件与消费契约合并了): + +``` + ┌→ xpkg.lua 描述符(索引;Form A ⇒ 只有 url + sha256) +一次 resolve+build ──┼→ 包内 mcpp.toml(消费契约 + [distribution] 闸门/证据) + └→ HOST-REQUIREMENTS(bundle) → 消费者 mcpp.lock 条目 +``` + +### 4.6 生产侧:`mcpp pack ` + +```bash +mcpp pack # 唯一可打包目标;有歧义 → 报错并列出候选 +mcpp pack mathkit # [targets.mathkit].kind = "lib" → 静态库包 +mcpp pack mathkit-shared # kind = "shared" → 动态库包 +mcpp pack mathkit --target x86_64-linux-gnu \ + --target aarch64-linux-gnu # 胖包(--target 可重复) +mcpp pack mathkit --tier bmi # 可选 Tier B(默认 interface) +``` + +> **没有 `--lib`,也没有 `--artifact`。** 产出什么由目标的 `kind` 决定 —— +> §4.6.1(b):三个旋钮塌缩成零。 + +做的事(**全部复用 `mcpp pack` 已有机械**): +1. build 出库产物; +2. 拷贝接口源、**从归档中剔除接口单元的对象**(§4.5.2)、算 digest; +3. 生成包内 `mcpp.toml`(消费契约 + `[distribution]`)+ `HOST-REQUIREMENTS`; +4. 打包,**确定性归档**(PE 侧 `zip.cppm` 已经是无时间戳的了)。 + +`--artifact shared` 额外多两步,**这两步是 static 形态根本不需要的**: + +5. **RUNPATH/INTERP 重写** —— §2.2(a) 的解药,`pack.cppm` 已有,只差入口; +6. 收第三方闭包(ELF 走 `LD_TRACE_LOADED_OBJECTS`,PE 走导入表 —— `binfmt.cppm` 已有)。 + +**「三平台」拆成两件事,不要排在一起:** + +| | 打包器 / 布局 / 元数据 | 产出动态库本身 | +|---|---|---| +| 平台相关性 | **无关** —— 就是文件布局 + toml + digest | **强相关** | +| 现状 | 两条产出族已在(ELF/Mach-O→tar.gz、PE→zip),且 **PE 路径任何宿主都能跑**(读导入表,不执行产物) | **仅 Linux**(G2) | +| 工作量 | 小,可一次覆盖三平台 | PE 导入库 + Mach-O install_name,**真正的工作量在这里** | + +没有右列,Windows/macOS 的包是**空盒子**。这就是把 static 提到 P0 的理由: +它让 P0 结束时三平台都拿到能用的包。 + +**验收判据(直接复用 e2e 215 的形状):** 扫遍包里每个二进制, +出现 `$MCPP_HOME` 下的路径即失败。static 形态天然满足(归档无动态段), +但**判据照样要跑** —— 它是对 `include/`、`interface/`、元数据里残留绝对路径的守卫。 + +#### 4.6.1 `[pack]` 的架构 —— 推导审计的结果:一个新键都不加 + +> **本节是第二次重写。** 第一版提了 `default_kind` / `default_artifact` / +> `targets` / `[pack.interface]` / `[pack.headers]` 五组键。 +> 按「**能从 mcpp.toml 别处推出来的,就不给字段**」逐条审计之后,**全部删掉**。 +> 留下的是既有键 + 一个 CLI 位置参数。 + +##### (a) 推导审计 + +对每一个候选键问同一个问题:**这件事 mcpp.toml 别处已经说过了吗?** + +| 第一版的键 | 已经在哪说过了 | 结论 | +|---|---|---| +| `default_kind`(app/lib) | `[targets.].kind` | **删** | +| `default_artifact`(static/shared) | 同上(`"lib"` vs `"shared"`) | **删** | +| `targets = [三元组…]` | `[package].platforms` 问的是同一件事(OS 级) | **删**,改成**校验** | +| `[pack.interface] modules` | lib-root 约定 / `[lib].path` | **删** | +| `[pack.headers] dirs` | `[build].include_dirs` | **删** | +| `[pack.headers] exclude` | 不可推导 —— 但**不允许**(见 (c)) | **删** | +| `include` / `exclude`(extras) | 不可推导 | **保留既有键,语义一字不改** | +| `default_mode` | 不可推导 | **保留既有键** | + +**结果:`[pack]` 新增 0 个键。** + +##### (b) WHAT / HOW 根本不是 pack 的设置 —— 是 `[targets.].kind` + +`mcpp pack [target]`,与既有的 `mcpp run [target]` 同形。**目标的 `kind` 决定一切:** + +| `[targets.].kind` | `mcpp pack ` 产出 | `--mode` 适用吗 | +|---|---|---| +| `bin` | 应用 bundle(既有四档) | ✅ | +| `lib` | **静态库包** | ❌ 归档不携带任何东西,闭包深度无意义 | +| `shared` | **动态库包** | ✅ `lib/` 要不要带上第三方 `.so` | + +`--lib` 这个 flag 不需要了,`--artifact` 不需要了,`default_kind` / `default_artifact` +也不需要了 —— **三个旋钮塌缩成零,因为 `kind` 本来就是答案。** + +**同时发布静态与动态 = 声明两个目标**(实测可行): + +```toml +[targets.mathkit] kind = "lib" +[targets.mathkit-shared] kind = "shared" soname = "libmathkit.so.1" +``` +``` +bin/libmathkit.a +bin/libmathkit-shared.so +bin/libmathkit.so.1 -> libmathkit-shared.so ← soname 别名(既有机制) +``` + +**已知的 wart:`.so` 的文件名带了 `-shared`。** `soname` 给出了正确的运行期名, +打包器发布 soname 那个名字即可。长期干净的修法是让 `kind` 接受列表 +(`kind = ["lib", "shared"]`,Cargo `crate-type` 的先例)——那是一次独立的改动。 +**但绝不要用一个 pack 期的 `--artifact` 覆盖去补救**:那会立刻变成 `kind` 的第二个答案。 + +##### (c) 头与接口在 pack 期**不允许**裁剪 —— 一致性论证 + +这是唯一一组「不可推导、但仍然不给」的键。理由不是「用不上」,是**给了就破坏不变量**: + +> **源码分发的包把 `include_dirs` 的全部内容暴露给消费者**(usage requirements, +> `scanner.cppm:700-716`)。二进制包若裁掉一部分,**同一个库会因为分发形式不同 +> 而拥有不同的公开面** —— 破坏「分发形式不改变语义」。 + +而且「哪些头是公开的」**布局已经回答了**:`include/` 是公开的(它在消费者的 +include 路径上),`src/` 是私有的。**一个私有头放在 `include/` 下是工程布局的错误, +不是打包问题。** 给 pack 一个裁剪键 = 允许用打包配置去补救布局错误, +而补救的结果是两种分发形式不一致。 + +接口 `.cppm` 同理,而且更硬:§2.4.2 的不对称性(发少了响、发多了哑)。 + +##### (d) 既有键升格为**断言**,而不是新增选择器 + +`[package].platforms` 的词汇是**封闭的**(`linux | macos | windows`, +`prepare.cppm:1075-1085`,未知值告警/`--strict` 报错)。不要为了 pack 去扩它。 + +改成:**`mcpp pack` 把 `--target` 的集合与声明的 `platforms` 做覆盖比对, +缺一条腿就告警。** 与 `[modules] exports` 同一形状 —— **既有键做断言,不做选择器**。 +好处:不新增键,而且「0.1.0 声称支持 windows 却没打 windows 那条腿」变成可发现的。 + +##### (e) 最终形态 —— 全部是既有的键 + +```toml +[package] +platforms = ["linux", "windows"] # 既有:声明支持的平台。pack 用它做覆盖校验 + +[build] +include_dirs = ["include"] # 既有:公开头。整发,不可裁 + +[lib] +# path = "src/mathkit.cppm" # 既有:接口根(不写 = src/.cppm 约定) + # 模块闭包从这里出发 + +[targets.mathkit] +kind = "lib" # 既有:决定 `mcpp pack mathkit` 产出什么 + +[targets.mathkit-shared] +kind = "shared" # 既有 +soname = "libmathkit.so.1" # 既有:给出正确的运行期名 + +[pack] +default_mode = "vendored" # 既有键,语义不变(仅 bin / shared 适用) +include = ["share/**"] # 既有键,语义不变 —— 只作用于 extras +exclude = ["**/*.tmp"] # 既有键,语义不变 —— 只从 include 里剔除 +``` + +```bash +mcpp pack # 唯一可打包目标;有歧义 → 报错并列出候选 +mcpp pack mathkit # kind="lib" → 静态库包 +mcpp pack mathkit-shared # kind="shared" → 动态库包 +mcpp pack mathkit --target x86_64-linux-gnu \ + --target aarch64-linux-gnu # 胖包 +``` + +**新增 manifest 键:0。新增 CLI:一个位置参数 +`--target` 可重复。** +(`mcpp pack` 今天只有 `--mode` / `--target` / `--format` / `-o`,没有位置参数 —— +`cli.cppm` 的 `cl::App("pack")`。) + +##### (f) 成文规则:什么不允许、什么不推荐 + +| 类别 | 规则 | +|---|---| +| **不允许** | ① 任何已被别处回答的问题(产物形态 / 接口根 / 头目录);② 任何会让**源码分发与二进制分发语义不同**的裁剪(头、接口) | +| **保留但不推荐** | `[pack].include/exclude` —— 只用于 **extras**(LICENSE / docs / 数据)。**不要拿它裁头或接口:裁不到,而且今天会静默无效**(`types.cppm:743` 的语义是 *drop from `include`*) | +| **必须写** | 无。一个库工程不写任何 `[pack]` 也能打出正确的包 | + +##### (g) 唯一保留的纪律:三态 + +删掉了所有新键,但「不写 = 默认」这条原则本身要求一件事: + +> 每个键必须区分 **不写**(默认)/ **写了有值**(覆盖)/ **写了但为空**(显式什么都不要)。 + +**实测 `sources = []` 与整行删掉在 `build.ninja` 里逐字节等价**(§2.5.4)—— +「不写=默认」在没有三态时会退化成「无法表达空」。这条对 G1 是硬要求 +(二进制包必须能说「什么都不要编」),对未来任何新键也是。 +仓库已有正确的模式:`XlingsConfig::subosDeclared`(`types.cppm:632`)—— +**一个 `Declared` 布尔,而不是靠容器的 `empty()`**。 + +##### (h) 跨平台收口(不变) + +- `lib//` **按三元组分,不按 OS 分** —— MinGW 与 MSVC 同为 windows, + 一个产 `lib*.a` 一个产 `*.lib`(§2.4.4)。 +- PE 的链接面与运行面是两个文件 ⇒ `lib//` 与 `bin//`。 +- per-target `ldflags` 必须生成 `cfg(...)` 谓词,**不能生成裸三元组**(§2.4.5)。 +- `kind = "shared"` × target 的合法性要在**计划阶段**校验:musl 是静态链接的, + 今天会一路走到链接器才炸(§2.5.3)。 +- 打包器**绝不 glob 产物**,用刚跑那次构建的 `target///bin/…`(附录 A ⑬)。 + + +### 4.7 消费侧:解析、闸门、降级 + +**依赖形态(三种,同一个下游路径):** + +> `.mpkg` 只是**人读的命名约定**(文件名里带 tag),不是新格式 —— 里面就是一个 +> 根目录带 `mcpp.toml` 的 tar.gz / zip。识别靠 `[distribution]` 段,不靠扩展名。 + +```toml +# ① 离线文件 —— P0,不需要索引、网络、鉴权、xlings 改动 +runtimelib = { package = "vendor/runtimelib-0.1.0-x86_64-linux-gnu-gcc16-libstdcxx16-c++23.mpkg" } + +# ② 索引(公开或私有)—— P1 +runtimelib = "0.1.0" + +# ③ CLI 直装 +$ mcpp add ./runtimelib-0.1.0-.mpkg +``` + +**闸门表(顺序即诊断顺序):** + +| 检查 | 失配 | +|---|---| +| arch / os / env | **拒绝** —— 载荷本身就不对 | +| `abi.surface == "c"` | 只查上一行,以下全跳过 | +| compiler 族 / 主版本 | **拒绝**;`--allow-abi-drift` 强制,并打印它保护的是什么 | +| stdlib id / 主版本 | **拒绝** | +| C++ 标准档位 | 消费者档位 < 生产者 → **拒绝**(接口可能用到更新的语法);> → 放行 | +| `cxx_runtime` 契约 | **警告** + 说清双运行时危险;`--strict` 升级为错误 | +| interface digest | **拒绝** | +| 模块符号存在性 | **拒绝** | +| build-key(Tier B) | **静默**降级到 Tier I | + +**没有匹配 tag 时的诊断形状**(这条比机制本身还重要): + +``` +error: acme.runtimelib@0.1.0 has no prebuilt artifact for this toolchain + your toolchain : x86_64-linux-gnu-gcc16-libstdcxx16-c++23 + published tags : x86_64-linux-gnu-gcc15-libstdcxx15-c++23 + aarch64-linux-gnu-gcc15-libstdcxx15-c++23 + note: this package ships no source tier, so there is nothing to fall back to. + fix : ask the publisher for a gcc16 build, or pin [toolchain] to gcc@15. +``` + +**「不可用」不能被拼成「不存在」。** 一个被拼成「找不到包」的失败会让客户端 +自己驱动重复刷新索引 —— #349 的教训。 + +### 4.8 分发侧:索引与安装线 + +> **相对初稿是重写。** 初稿在描述符里加 `kind = "prebuilt"` + `abi_tags`, +> 并因此要求一个 mcpp 版本 floor。**走 Form A 之后这一整块消失了。** + +**首选:胖包 + Form A —— 描述符一字不改。** + +```lua +-- 与一个源码包的描述符完全同构:没有 mcpp = {} 块,只有 url + sha256。 +-- 包内自带 mcpp.toml,里面的 [target.'cfg(...)'] 在消费者构建期选 arch/OS。 +xpm = { + linux = { ["0.1.0"] = { url = { GLOBAL = "…", CN = "…" }, sha256 = "…" } }, + macosx = { ["0.1.0"] = { url = { … }, sha256 = "…" } }, + windows = { ["0.1.0"] = { url = { … }, sha256 = "…" } }, +} +``` + +- **没有新描述符键 ⇒ 没有版本 floor ⇒ 老客户端不会被砖**(§4.2 的实测边界); +- **⚠️ 每条腿的谓词必须是 `cfg(...)`,不能是裸三元组** —— 裸三元组在**原生构建下 + 不匹配**(§2.4.5,根因 `prepare_inputs.cppm:139`),失败形状是 + 「CI 传 `--target` 全绿、开发者本机静默失配」。实测可用的写法: + ```toml + [target.'cfg(all(linux, not(env = "musl")))'.build] + ldflags = ["-Llib/x86_64-linux-gnu", "-lmathkit"] + [target.'cfg(all(linux, env = "musl"))'.build] + ldflags = ["-Llib/x86_64-linux-musl", "-lmathkit"] + [target.'cfg(windows)'.build] + ldflags = ["-Llib/x86_64-windows-gnu", "-lmathkit"] + ``` + 四种构建(native / gnu / musl / windows)全过,且每个 target 的 `build.ninja` + 里只出现自己那条腿(§2.4.6); +- **arch 选择在消费者的构建期**,`cfg()` 按解析后的 target 求值 ⇒ + **交叉编译天然正确,不需要安装线的 target 轴**; +- 镜像/CN 分流/sha256/artifact 通道**全部沿用源码包那一套**。 + +**可选优化(P2+):瘦包,按 tag 分资产。** 只有当胖包体积成为真问题时才做: + +```lua +xpm = { + linux = { + ["0.1.0"] = { + x86_64 = { url = "…-x86_64-…-gcc16-libstdcxx16-c++23.tar.gz", sha256 = "…" }, + aarch64 = { url = "…-aarch64-…", sha256 = "…" }, + }, + }, +} +``` +这用的是 **xim V2 已有的 per-arch resource map**,不是新语法。但它把 arch 选择 +挪回**安装期**,于是重新撞上「安装线没有 target 轴」: + +``` +install_packages {"targets":[...], "yes":true} + → {"targets":[...], "yes":true, "target":{"os":"linux","arch":"aarch64"}} +``` +这是**跨仓库改动**(xlings)。两个不改 xlings 的退路,都不推荐但要写下来: + +| 退路 | 代价 | +|---|---| +| 把 arch 编进包名(`aarch64-runtimelib`) | 违反 SPEC-001 的身份模型;#290 的「同一个库不同版本」问题再犯一次 | +| mcpp 自己下二进制载荷,绕过 xlings | 镜像/CN 分流/校验/断点全要重做一遍;`fetcher/progress.cppm` 只是起点 | + +**决策建议:胖包做默认。** 它让「交叉编译 + 索引二进制包」在 **P1 就可用**, +而不是等 P2 的跨仓库协调 —— 瘦包只是体积优化,不是能力前提。 + +### 4.9 私有分发:三种形态 + +| 形态 | 机制 | 需要新增 | 阶段 | +|---|---|---|---| +| **离线包** | `mcpp add ./x.mpkg` / `{ package = "..." }` | 一个依赖形态(格式已有:自带 `mcpp.toml` 的 tar.gz) | **P0** | +| **私有索引(git)** | `[indices] acme = { url = "git@..." }` | 文档 + `.mpkg` URL 支持;鉴权仍走 ambient git 凭据 | P1 | +| **私有 artifact 源** | `IndexSpec::artifact` | `IndexSpec` 加鉴权(header / netrc / helper) | P2 | + +**关于鉴权的立场建议:不要发明 mcpp 自己的凭据存储。** 走两条既有轨道: +① git 的 credential helper / SSH(索引侧,今天已经在用); +② 一个 `[indices.] auth = { header_env = "ACME_TOKEN" }` 形状 —— **值从环境变量读, +永不落盘**。理由:mcpp 的 manifest 是要进版本库的,任何能写 token 的字段都会 +被写进版本库。 + +**关于「团队内部统一编译器」——issue 作者说「对于 mcpp 来说统一编译器和版本不是难事」, +这句话是对的,而且这正是 mcpp 相对 CMake/vcpkg 的结构性优势:** +`[toolchain] default = "gcc@16.1.0"` 是可以进版本库的一行,payload 由 xim 保证 +逐字节相同。所以团队内部的 tag 组合数常常是 **1**。这让 Tier I 在私有场景里 +极其实用 —— 也让 Tier B(BMI)在私有场景里第一次变得**可能**(见 §6)。 + +### 4.10 非 mcpp 消费者(issue 场景 4) + +`mcpp pack ` 顺带产出: + +- **`.pc`(pkg-config)** —— 便宜,覆盖传统 `#include` + `-lfoo` 消费者; +- **CMake package config** —— 同上; +- **模块接口的互操作:诚实地说,没有好路。** CMake 的 C++20 modules 支持要求 + 消费方自己扫描并编译 `.cppm`,能做但很脆。**建议:对非 mcpp 消费者, + `mcpp pack ` 额外产出一份「非模块外观」(头文件 + `extern "C"` 或普通 + C++ 声明),由包作者显式声明,而不是自动生成。** 自动从模块接口生成头文件 + 是一个独立的、很大的题目,不该混进这个方案。 + +--- + +## 5. 分期与验收判据 + +> **P0/P1 的切分线是「形态」,不是「平台」。** 初稿按平台切(P0 只做 Linux), +> 那是错的:它让 Windows/macOS 用户在 P0 结束时拿到一个**空盒子**。 +> 按形态切之后,P0 三平台都产出能用的包,P1 才去解锁最难的动态形态。 + +### P0 —— `static` 形态,三平台一次做完(不依赖 xlings、不依赖 G2) + +1. `[distribution]` 段 + `sources = []` 的显式表达(G1、§4.2), + 以及 §4.5.0(d) 的「分发包目录里不许直接 build」守卫 +2. 包布局:根目录 `mcpp.toml` + `[distribution]` 段 + `[[runtime.artifacts]]`, + `lib/` 与 `bin/` 分离(§4.5)。**归档格式与源码包同构,索引条目一字不改。** +3. `mcpp pack `(位置参数;`kind = "lib"` ⇒ 静态库包)——**三平台**, + 含「从归档剔除已发布闭包的对象」(§4.5.2) +4. `mcpp add ./x.mpkg` + `{ package = "..." }` 依赖形态 +5. abi-tag 计算 + 闸门表(仅 arch/os/env/compiler/stdlib/std)(G4) +6. interface digest + 模块符号存在性检查(G5) + +**为什么 static 能在 P0 覆盖三平台:**`kind = "lib"` 没有平台限制(实测产出 +`bin/libstatlib.a`,`ar t` 显示接口对象与实现对象分离,`readelf -d` 无 RUNPATH), +所以 G2、RUNPATH 重写、闭包收集、DLL 部署**这一期全部不需要**。 + +**验收:** +- [ ] e2e:**三平台**各能 `mcpp pack ` 出一个包 并被消费者 `import` + 链接 +- [ ] e2e:`.mpkg` 里不含任何 `$MCPP_HOME` 路径(照抄 e2e 215;含元数据与 `interface/`) +- [ ] e2e:**篡改随包接口 → 构建必须失败**(直接把 §2.2(c) 那个 struct 互换 + case 变成回归测试 —— 它今天是静默通过的,这是最有价值的一条) +- [ ] e2e:abi-tag 不匹配 → 拒绝,且诊断里**列出可用 tag** +- [ ] e2e:同一个 `.mpkg` 在**清空 build cache 的第二台 MCPP_HOME** 上可消费 + (防止「只在写它的机器上能用」) +- [ ] unit:打包器确实剔除了接口单元对象(`ar t` / PE 等价物断言), + 而不是依赖「归档成员只在解析未定义符号时被拉取」这条巧合 +- [ ] `mcpp why` 能说出「这个依赖是 prebuilt / 形态是什么 / tag 是什么 / 为什么选了它」 + +### P1 —— `shared` 形态解锁三平台 + 索引通路 + +7. PE 导入库(`--out-implib` / `/IMPLIB:`)+ Mach-O `-install_name @rpath/…`, + 解除 `plan.cppm:1002` 的拒绝(G2) +8. `mcpp pack `(`kind = "shared"`):RUNPATH/INTERP 重写 + 闭包收集 + `bin/` 部署面 +9. **胖包**走 Form A 上索引:描述符与源码包同构(url + sha256), + `[target.'cfg(...)']` 在消费者构建期选 arch/OS —— **无新描述符键、无版本 floor** +10. 私有索引发布的文档与端到端验证 + +**验收:** +- [ ] 三平台各有一个 shared 库 e2e(今天全部 `# requires: elf`) +- [ ] Windows:消费者链 `lib/foo.lib`、`bin/foo.dll` 部署到 `.exe` 旁、**直接跑 `.exe`** + (不能用 `mcpp run`,它会塞 PATH 掩盖部署问题 —— 这是既有教训) +- [ ] shared 形态的 `.mpkg` 在第二台机器上可用(§2.2a 的回归守卫) +- [ ] `cxx_runtime` 契约不匹配时**有告警**(§2.2b 今天是静默的) +- [ ] **老 mcpp(2026.8.15.3)拿到这个包能构建成功** —— Form A 的兼容性主张必须 + 有一条对着**已发布二进制**跑的测试,不能只在新 mcpp 上验证 +- [ ] 胖包 + `--target aarch64-linux-gnu` 选到正确的那条腿(交叉编译回归) + +### P2 —— 交叉编译 + 私有鉴权 + +10. 瘦包(按 tag 分资产)+ `install_packages` 加 target 轴(跨仓库,与 xlings 同步) + —— **仅当胖包体积成为真问题时才做**,它不是能力前提 +11. `IndexSpec` 鉴权(值从环境变量读) +12. `target_cfg` 承载 LinkIntent(顺手修 #258 同形状的债) + +### P3 —— Tier B(BMI)与生态收尾 + +13. Tier B:导出/导入构建缓存条目,**必须**先做跨机器可行性实验(见 §6) +14. `.pc` / CMake config 产出 +15. 修 #304(`library_dirs` 的 link/runtime 分离收口)、#290(按版本区分构建规则) + +--- + +## 6. 明确不做 / 需要先证伪的事 + +**(a) 不要自动从模块接口生成 C 头文件。** 独立的大题目,混进来会把这个方案拖死。 + +**(b) 不要为二进制包发明第二套身份模型。** `(namespace, name)` + SPEC-001 不变, +tag 是**载荷选择器**,不是身份的一部分。把 arch 编进包名是明确的错误形状(§4.8)。 + +**(c) Tier B 不能凭推理设计进去 —— 必须先做实验。** 已知的三个坑: +- GCC 把**时间戳**写进 BMI(实测过),所以「BMI 逐字节相同」不能作为判据; +- GCC 把**源码路径**写进 BMI,跨机器路径不同; +- clang `--precompile` 发的是 **full BMI**(体积 16 倍且会让下游 TU 编错), + 两阶段必须 `-Xclang -emit-reduced-module-interface`。 + +**判据:在两台不同 `$MCPP_HOME`、不同用户名、不同路径的机器上,同一个 +build-key 的 BMI 能被对方直接使用并产出可运行程序。** 做不到就不要做 Tier B, +它是纯加速,不值得为它引入正确性风险。 + +**(d) 不要把 `cxx_runtime` 契约折进 abi-tag。** 它可以协商,tag 不可以。 +折进去会让 tag 组合数翻三倍,而且会把一个「警告」变成「找不到载荷」。 + +**(e) 不要指望闸门能挡住 ODR/布局漂移。** §4.4 已经说了。**唯一充分的保护是 +原子产出。** 任何「我们检查得够多了所以可以允许手工拼包」的说法都是错的。 + +--- + +## 7. 需要 review 的决策点 + +| # | 决策 | 我的建议 | 反方理由 | +|---|---|---|---| +| **D1** | P0 走**离线 `.mpkg` 文件**,还是直接做索引通路? | **离线文件**。不碰 xlings、不碰网络、不碰鉴权,完全在 mcpp 手里,而且正好是 issue 作者要的形状(`pip install x.whl`) | 索引通路才是「生态」,文件通路会不会变成永久的旁路 | +| **D2** | `kind = "prebuilt"` **新增目标种类**,还是只让 `sources = []` 合法? | **两个都要**,语义不同(§4.2) | 只加一个键更省;但 tag/digest 需要一个不说谎的挂点 | +| **D3** | abi-tag 的**粒度**:主版本 还是 完整版本? | **主版本**(`gcc16`)。完整版本会让 tag 数量爆炸,而 gcc 的 mangling/ABI 在主版本内稳定 | 主版本内也可能有 ABI 变化;可用 `--allow-abi-drift` 兜 | +| **D4** | `abi_surface = "c"` 逃生口要不要? | **要**。它让传统 C 库的 tag 数从 N×M 掉回 N,而且 `abi.cppm` 已经建模了这个规则 | 会被误用在实际有 C++ 接口的包上 → 需要 lint | +| **D5** | P0/P1 按**平台**切,还是按**形态**切? | **按形态**(修订自初稿)。P0 = `static`,三平台一次做完(`kind="lib"` 无平台限制,实测已验证);P1 = `shared`,解锁 PE 导入库 / Mach-O install_name | issue 明确写了 `.so/.dll/.dylib`,先给 static 会被读成「答非所问」—— 需要在回复里说清 static 消掉了哪三类问题 | +| **D9** | `artifact_kind` 是**布局里的字段**,还是**两个命令**? | **一个字段、一个命令、一个布局**。动态是静态的超集(多两步:RUNPATH 重写 + 闭包收集) | 两个命令更容易分别演进;但会产出两套布局与两套元数据,正是「一个决策两处推导」 | +| **D22** | `[pack]` 到底加几个键? | **0 个**(§4.6.1 推导审计)。WHAT/HOW → `[targets.].kind` + `mcpp pack [target]`;接口根 → `[lib]`;头目录 → `[build].include_dirs`;平台 → `[package].platforms` 升格为断言 | 同时发布静态+动态要声明两个目标,`.so` 文件名带后缀(soname 别名给出正确运行期名)。干净修法是 `kind` 接受列表(Cargo `crate-type`),独立改动 | +| **D23** | 头 / 接口允不允许在 pack 期裁剪? | **不允许**。源码分发把 `include_dirs` 全量暴露给消费者;二进制包裁掉一部分 ⇒ **同一个库因分发形式不同而公开面不同**。而且「哪些头公开」布局已经答了(`include/` vs `src/`)—— 私有头放在 `include/` 下是布局错误,不是打包问题 | 有作者确实想 curate;但那应该改布局,不是加打包旋钮 | +| **D18**(已被 D22 取代) | `[pack]` 要不要能「自定义接口文件」? | **部分要**(完整设计见 §4.6.1):WHAT/HOW 三个标量键(`default_kind` / `default_artifact` / `default_mode`)+ `targets` 列表;CONTENT 按**四个集合各给各的键**(`[pack.interface] modules`、`[pack.headers] dirs/exclude`、既有 `include/exclude` 保留给 extras)。**不加 `.cppm` 文件清单** | 作者会希望有「万能逃生口」;真要给,必须做成 `scan_overrides` 那种**断言+校验**,不是选择器 | +| **D20** | CONTENT 用**一对全局 `include`/`exclude`**,还是**每个集合一个键**? | **每个集合一个键**。四个集合的裁剪风险完全不同(interface 不可裁 / headers 可裁但必须 `-MM` 校验 / artifacts 不可裁 / extras 自由),共用一对键会让「删 README」和「删公开头依赖的私有头」看起来是同一个操作 | 键更多;但既有 `include`/`exclude` 语义**一字不改**(它们本来就只作用于 extras),所以不是破坏性变更 | +| **D21** | 「不写 = 默认」要不要配套**三态**(缺席/有值/显式空)? | **要**。实测 `sources = []` 与整行删掉逐字节等价(§2.5.4)——「不写=默认」的原则在没有三态时会退化成「无法表达空」。用 `XlingsConfig::subosDeclared` 那个模式 | 每个键多一个 `Declared` 布尔;但这是仓库已有的、被验证过的形状 | +| **D19** | 接口闭包建在 M1 文本扫描器还是 P1689? | **P1689**。实测 M1 不把 `module X:part;` 建模为 provider(`scanner.cppm:641-650`),导致「接口够到实现分区」的告警产不出来 | P1689 要跑编译器,更慢;但只在 `pack --lib` 时跑一次 | +| **D16** | 两种接口模式(`include/` 文本 / `interface/` 编译)用**目录**区分,还是用 manifest 里的键区分? | **目录**。规则由文件所在位置决定,不需要第二处声明,也不会漂移;实测两种模式互不干扰、可同时存在 | 目录名成为约定的一部分;作者若把 `.h` 放进 `interface/` 会被当成要编译的东西 —— 需要 lint | +| **D17** | `kind="shared"` 那道守卫的判据改成「是否动态链接」,单独开 issue 还是并进 P1? | **并进 P1**。它就是 G2 的一部分,而且不修的话 musl 用户拿到的是一条读不懂的链接器错误 | 也可以先只补诊断(便宜),真正支持留到 P1 | +| **D14** | §2.4.5 的裸三元组缺陷,单独开 issue 还是并进本方案? | **单独开**。它与二进制分发无关(任何用 `[target.''.build]` 的工程都中招),修法看起来是一行,但需要自己的回归测试 | 并进来能少一个 PR;但会把一个通用缺陷埋进一个大特性里 | +| **D15** | 接口发布集用**闭包**,还是让作者在 manifest 里手写清单? | **闭包**(mcpp 的 scanner 已有模块图,是免费的)。手写清单会漂移,而漂移的方向是**静默泄露源码** | 闭包对作者不可见;需要 `mcpp pack ` 打印「将发布 / 不发布」两张清单,并对「接口够到实现分区」告警 | +| **D11** | 包内自带 `mcpp.toml`(Form A),还是并列一份 `MCPP-PACKAGE.toml`? | **自带 `mcpp.toml`**(采纳,已重写 §4.5)。它消掉新描述符键 / 新消费路径 / 版本 floor,并让 path 依赖也吃到闸门 | 一个字面叫 `mcpp.toml` 的文件在二进制包里会引诱 `mcpp build` —— 需要 §4.5.0(d) 的守卫,否则是「看起来成功的失败」 | +| **D12** | 闸门+证据放同一段,还是拆两个文件? | **同一段**(`[distribution]`)。多一个文件就多一个能漂移的地方;代价是需要一条「用户手写生成字段 = 错误」的规则,而这条规则本来也要有 | 生成字段与用户字段混在一段里,靠规则而非结构区分 | +| **D13** | 胖包(一包多 arch,构建期选)做默认,还是瘦包(按 tag 分资产)? | **胖包**。它让交叉编译在 P1 就正确,**完全不需要动 xlings**;瘦包是 P2 的体积优化,不是能力前提 | 大型闭源 runtime × 4 tag 的体积;可两种并存 | +| **D10** | `static` 做默认形态? | **是**。对闭源内部分发,它消掉 RUNPATH 重写、DLL 部署、双 C++ 运行时、加载器闭包四类问题 | 大型 runtime 库可能就是要动态(热替换、体积);默认可被 `[pack] default_artifact` 覆盖 | +| **D6** | 安装线 target 轴(跨仓库)排 P2,可接受吗? | **可接受**,因为 P0/P1 用离线文件与显式 tag 绕开了它 | 交叉编译 + 索引二进制包在 P2 之前不可用 | +| **D7** | 鉴权:环境变量 header,还是接入 git credential helper? | **两条都走**:索引 clone 用 git 凭据(今天已经如此),artifact/URL 用 `header_env` | 有人会希望 mcpp 自己存 token —— 建议明确拒绝 | +| **D8** | §2.2(c) 那个静默错数据,要不要**先单独开 issue 并立刻加回归测试**? | **要**,不等整个方案。它今天就可达(path 依赖 + `[runtime] libraries` 是已发布能力) | 它需要 digest 机制才能真正修;但先把测试写成「预期失败」也有价值 | + +--- + +## 附录 A. 复现脚本 + +材料在 `scratchpad/bindist/`,三个工程: + +```bash +# ① 生产者:模块接口只有声明,实现在实现单元 +provider/src/runtimelib.cppm export module runtimelib; export namespace rt { ... } +provider/src/impl.cpp module runtimelib; namespace rt { ... } +provider/mcpp.toml [targets.runtimelib] kind = "shared" +mcpp build → bin/libruntimelib.so + +# ② 「二进制包」:接口源 + 预编译库 + mcpp.toml +dist/src/runtimelib.cppm (从 ① 拷贝) +dist/lib/libruntimelib.so (从 ① 拷贝) +dist/mcpp.toml [runtime] libraries / link_library_dirs / runtime_search_dirs + +# ③ 消费者 +app/mcpp.toml runtimelib = { path = "../dist" } +app/src/main.cpp #include <...> 然后 import runtimelib; +mcpp run → answer=42 / name=... / caught runtime_error + +# ④ skew 实验(本方案要挡的那个) +# 只把 dist/src/runtimelib.cppm 里的 struct Point { int x; int y; } +# 改成 struct Point { int y; int x; } +mcpp run → x=222 y=111 ← 编译链接运行全过,数据是错的,零诊断 + +# ⑤ 静态形态(scratchpad/statictest/):同样的两个源,[targets.x] kind = "lib" +mcpp build → bin/libstatlib.a +ar t libstatlib.a → statlib.m.o impl.o +nm --defined-only statlib.m.o: T _ZGIW7statlib ← 只有模块初始化器 + impl.o: T _ZN2slW7statlib6answerEv ← 真正的符号 +readelf -d libstatlib.a → (无动态段 ⇒ 无 RUNPATH) + +# ⑥ 兼容性边界(用已发布的 mcpp 2026.8.15.3 跑,不是用新构建) +# 在 statictest/mcpp.toml 末尾追加: +[distribution] schema/artifact_kind/abi_tag/abi_surface/interface_digest/modules + → mcpp build 成功,连警告都没有 ✅ +artifacts = [{ path = "…", role = "…" }] (array-of-tables 于新段) + → 硬失败:"array-of-tables syntax is only supported + for [[build.flags]], [[features..flags]], + [[runtime.requirements]], and [[runtime.artifacts]]" ❌ +[distribution] 标量段 + [[runtime.artifacts]](既有白名单段) + → 成功,且 fingerprint 变化(证明它被读进 manifest) ✅ +``` + + +### 实验二(§2.4):`scratchpad/lab/` + +``` +lab/mkdist.py 176 行的 `mcpp pack ` 原型(采 tag / 算闭包 / 剔对象 / 生成 toml) +lab/parts/ 生产者:主接口 + 接口分区 + 实现分区 + 实现单元 + C API + 头 +lab/fatpkg/ 产出的胖包:interface/ include/ lib// mcpp.toml +lab/consumer/ 消费者(path 依赖 fatpkg) +lab/probe/ §2.4.5 的最小探针:裸三元组 vs cfg(linux) + +# ⑦ 三条被实验推翻/确立的规则 +python3 mkdist.py parts fatpkg x86_64-linux-gnu x86_64-linux-musl x86_64-windows-gnu + 接口闭包 (2 个): mathkit.cppm, api.cppm + 未发布 : secret.cppm ← 实现分区不外发(但它也有 .m.o) + 剔除归档成员: ['api.m.o', 'mathkit.m.o'] ← 不是所有 .m.o(第一版剔全部 → 三平台链接全挂) + +# ⑧ 全矩阵(胖包用 cfg(...) 谓词) +cd consumer && mcpp build [--target …] + (native) OK x86_64-linux-gnu OK x86_64-linux-musl OK x86_64-windows-gnu OK(PE32+) + build.ninja 中各自只出现自己那条腿的 fatpkg/lib/ + +# ⑨ 最小探针:[target..build] cxxflags = ["-DX"] 的命中次数 + 裸 mcpp build --target x86_64-linux-gnu + 'x86_64-linux-gnu'(裸三元组) 0 2 ← 缺陷 + 'cfg(linux)' 2 2 +``` + +### 实验三(§2.5):两种接口模式 × 两种库形态 + +``` +lab/parts/ 同时提供 include/mathkit_c.h(extern "C") 与 interface/*.cppm(模块) +lab/fatpkg/ 静态库包 lab/sopkg/ 动态库包 lab/hdrpkg/ 纯头文件包(无 .cppm) +lab/c_hdr/ 只 #include lab/c_mod/ 只 import lab/c_both/ 两者同时 + +# ⑩ 六格矩阵 —— 同一个包,三种消费方式 × 两种库形态 + 只 #include 只 import 两者同时 + 静态库 .a hdr: 5 mod: 5 both: c=5 mod=5 + 动态库 .so hdr: 5 mod: 5 both: c=5 mod=5 + 消费者 ELF: NEEDED libmathkit.so + runtime_search_dirs 进 RPATH ✅ + +# ⑪ musl + kind="shared":过了守卫,死在链接器 +mcpp build --target x86_64-linux-musl + crtbeginT.o: relocation R_X86_64_32 against hidden symbol `__TMC_END__' + can not be used when making a shared object + (守卫判据是 os != "linux";musl 是 linux ⇒ 过闸。真正的判据应是「是否动态链接」) + +# ⑫ sources = [] 不生效 —— 与「不写」逐字节等价 + 包里放一个遗留的 src/leftover.cpp: + sources = [] → build.ninja 里 leftover 出现 7 次 + (整行删掉) → build.ninja 里 leftover 出现 7 次 ← 完全一样 + ⚠️ 我一开始误以为它能用,因为测试包没有 src/ 目录 —— 默认 glob 也匹配不到东西。 + 「两条路径给出同一答案」不等于「这条路径生效了」。 + +# ⑬ 我自己踩的坑(仓库已知形状):mkdist 用 rglob 取产物 → 挑到陈旧 fingerprint 目录 + target/x86_64-linux-gnu/0f8ab572…/ 缺 capi.o ← rglob 取到的 + target/x86_64-linux-gnu/d02fd93d…/ 有 capi.o ← 刚构建出来的 + ⇒ 打包器绝不能 glob 产物,必须用它刚跑那次构建的 target///bin/… 路径。 +``` + +环境:mcpp 2026.8.15.3,gcc@16.1.0,x86_64-linux-gnu; +交叉 target 的载荷:`xim-x-musl-gcc/16.1.0`、`xim-x-mingw-cross-gcc/16.1.0`。 + +## 附录 B. 证据索引 + +| 事实 | 位置 | +|---|---| +| 描述符强制 `sources` | `src/manifest/xpkg.cppm:2058-2063` | +| 描述符 `mcpp` 段封闭键表(无 arch) | `src/manifest/xpkg.cppm:228-234` | +| 描述符 `target_cfg`(**已有** arch 条件构建输入) | `src/manifest/xpkg.cppm:1315-1400` | +| `target_cfg` 只承载 BuildInputs(无 LinkIntent) | `src/manifest/xpkg.cppm:1359-1377` | +| 描述符 `runtime` 子键(LinkIntent) | `src/manifest/xpkg.cppm:1925-1966` | +| `kind = "shared"` 仅 Linux | `src/build/plan.cppm:1002-1017` | +| `kind = "lib"` **无**平台限制,产出 `bin/lib` | `src/build/plan.cppm:411-423`(`target_output`) | +| **Form A:载荷自带 `mcpp.toml` 时 glob 加载它** | `src/build/prepare.cppm:2764-2787`(及 `2470` 的存在性探测) | +| LinkIntent **逐包**聚合(path/git/索引同一条路) | `src/build/plan.cppm:630-656` | +| legacy `library_dirs` 已**不**进 `linkLibraryDirs`(#304 的一半) | `src/build/plan.cppm:653-656` | +| array-of-tables 白名单(闸门段不能自造产物清单) | 实测,附录 A ⑥ | +| **裸三元组谓词在原生构建下不匹配**(新缺陷) | `src/build/prepare_inputs.cppm:139` vs `:49-53`;承诺见 `src/manifest/types.cppm:665` | +| cfg 词汇 = 规范 triple 词汇(os/arch/family/env + 别名) | `src/build/prepare_inputs.cppm:39-148` | +| MSVC 与 Itanium 的 cxxabi 分叉 | `src/toolchain/abi.cppm:84` | +| 接口闭包 / `.m.o` 不是判据 / 剔除集 | 实测,附录 A ⑦ | +| `[modules] exports` 是**完备性断言**(写错硬失败),不是选择器 | `src/modgraph/validate.cppm:93-117` | +| 既有 `[pack]` 键(新键的先例) | `src/manifest/types.cppm:736-745`;解析 `src/manifest/toml.cppm:1337-1347` | +| **`[pack].exclude` 只从 `include` 里剔除**(裁不到 headers) | `src/manifest/types.cppm:743` 注释原文 | +| `[pack.]` 子表先例 | `docs/02-pack-and-release.md` §Configuration(`[pack.bundle-project]`) | +| 三态模式(缺席 vs 显式空) | `src/manifest/types.cppm:632`(`subosDeclared`) | +| 「断言 + 校验」的先例(`scan_overrides`) | `src/manifest/types.cppm:62-70` | +| **M1 扫描器不把实现分区建模为 provider** | `src/modgraph/scanner.cppm:641-650`;告警 `:984` | +| P1689 扫描后端(闭包应建在它上面) | `src/modgraph/scanner.cppm:121-124` | +| 两种接口模式 × 两种库形态 六格全过 | 实测,附录 A ⑩ | +| `kind="shared"` 在 musl 上过闸后链接失败 | 守卫 `src/build/plan.cppm:1002`;实测 附录 A ⑪ | +| `sources = []` 被默认 glob 吞掉 | `src/manifest/toml.cppm:1703`;实测 附录 A ⑫ | +| 静态归档:接口对象与实现对象分离、无 RUNPATH | 实测,附录 A ⑤ | +| PE 需导入库(字段已存在) | `src/build/plan.cppm:452-479` | +| 平台轴只有 OS,无 arch | `src/platform/axis.cppm:63-87` | +| 安装线协议无 target/arch | `src/pm/package_fetcher.cppm:419-431` | +| 五维 ABI 模型 + `abi:=` | `src/toolchain/abi.cppm:31-145` | +| C 库只约束 libc(逃生口的依据) | `src/toolchain/abi.cppm:12-16` | +| ABI 完备的逐包 key(tag 的原料) | `src/build/cache_key.cppm:20-46, 76-133` | +| 构建缓存布局(BMI+obj 已在盘上) | `src/bmi_cache.cppm:1-45` | +| C++ 运行时契约三层模型 | `src/build/distribution.cppm:1-120` | +| SharedLibrary 双运行时危险 | `src/build/distribution.cppm:51-64`(role 注释)、`154-180`(`default_contract`) | +| 三平台同一份源码 tarball | `src/pm/publisher.cppm:289-303` | +| 「一份推导,多个投影」的既有纪律 | `src/pm/publisher.cppm:194-216` | +| 私有索引无鉴权字段 | `src/pm/index_spec.cppm:14-57` | +| lock 不 pin 索引依赖、不记 ABI | `mcpp.lock` 头部注释 / `src/pm/lock_io.cppm:29-36` | +| pack 的输入是一个 exe | `src/pack/pack.cppm:Plan::builtBinary` | +| anchor TU 手法样板 | `~/.mcpp/registry/data/mcpplibs/pkgs/c/compat.openblas.lua` | +| xim V2 的 arch 轴(按宿主解析) | `~/.mcpp/registry/data/xim-pkgindex/docs/V2/xpackage-spec.md` §"三种新版本条目形状" | +| LinkIntent 的 link/runtime 分离表 | `docs/05-mcpp-toml.md` §2.11 | +| 新键需要版本 floor 且必须降级 | `docs/10-publishing-a-library.md` §"Manifest keys that need a version floor" | +| schema 准入原则 | `docs/05-mcpp-toml.md` Appendix A | diff --git a/.agents/docs/2026-08-17-library-distribution-design.md b/.agents/docs/2026-08-17-library-distribution-design.md new file mode 100644 index 00000000..94444bce --- /dev/null +++ b/.agents/docs/2026-08-17-library-distribution-design.md @@ -0,0 +1,915 @@ +# 库分发:`mcpp pack ` 与二进制包(2026-08-17) + +> **这份是方案。** 发现(现状、实测、file:line)在 +> `2026-08-17-distribution-architecture-analysis-and-design.md`;那份文档的 +> §2(五轮实测)是本方案每一条判据的证据来源,本文只在需要时回指。 +> +> **实施状态(2026-08-17):P0 已实现并开 PR #451(未合入)。** +> 落地与设计的三处差异,以及实现过程中被实测推翻的四条,记在 §11。 +> +> 起因:issue #433「预编译 .so + .ixx/.h/.cppm 接口」。 +> 讨论过程中我有**四处设计被自己的实测推翻**,都记在 §9,因为**错的那版看起来同样合理**。 + +--- + +## 0. 一页纸 + +**模型三句话:** + +1. **二进制包不是新格式** —— 它是一个**自带 `mcpp.toml` 的普通 tarball**,走 mcpp 已有的 + Form A 通路(`prepare.cppm:2764`)。索引条目与源码包**一字不差**。 +2. **产出什么由 `[targets.].kind` 决定** —— `mcpp pack `。 + 没有 `--lib`,没有 `--artifact`。**新增 manifest 段 0 个、键 0 个** —— + 生成的包就是一个**普通的 `mcpp.toml`**(§2.4)。 +3. **能算出来的东西不给字段** —— 接口发布集是**模块闭包**,公开头是 `include_dirs` 全量, + 两者都**不允许**在 pack 期裁剪(裁了会让源码分发与二进制分发语义不同)。 + +**四条不可让步的判据:** + +| # | 判据 | 违反会怎样(实测) | +|---|---|---| +| **J1** | 接口与二进制**原子产出、不可分别替换** | 随包 `.cppm` 改一行结构体字段序 ⇒ 编译链接运行全过、**数据是错的、零诊断** | +| **J2** | 产物里**不含生产机器的绝对路径** | `.so` 烧着 `/home/…/.mcpp/…` 的 RUNPATH,换机器即废 | +| **J3** | 发布集 = **从公开根算的模块闭包**,不是 `.m.o` | 实现分区照样产 `.m.o` ⇒ 按 `.m.o` 选会**泄露闭源源码** | +| **J4** | per-target 的 `ldflags` 用 `cfg(...)` **不用裸三元组** | 裸三元组在原生构建下不匹配 ⇒ **CI 全绿、本机静默失配** | + +--- + +## 1. 模型 + +### 1.1 两种接口模式,两条规则,由目录决定 + +``` +pkg/ +├── mcpp.toml ← 生成物:一个普通的 mcpp.toml(§2.4),没有新段 +├── include/ ← 文本接口:原样全发,消费者 #include,不产生任何对象 +├── interface/ ← 模块接口:算闭包,消费者编译它得到 BMI + object +├── lib// ← 链接面:.a / .so / .dylib / .lib(PE 导入库) +├── bin// ← 运行面(仅 PE):.dll,部署到 .exe 旁 +├── HOST-REQUIREMENTS ← 与 mcpp pack 同一份推导(仅当有内容要说) +└── LICENSE / THIRD-PARTY +``` + +| | `include/` | `interface/` | +|---|---|---| +| 是谁的输入 | 预处理器 | **编译器** | +| 消费者要编译吗 | 否 | **是** | +| 要算闭包吗 | 否 | **是**(J3) | +| 要从归档剔对象吗 | 否 | **是** | +| ABI 闸门 | `extern "C"` ⇒ tag 只有三段(只约束 libc) | tag 全段 | + +**两种模式互不干扰、可同时存在** —— 实测:同一个包被「只 `#include`」/「只 `import`」/ +「两者同时」三种方式消费 × 静态/动态两种形态,**六格全过**。 + +**`lib/` 与 `bin/` 分开是机制不是风格**:PE 上链接的文件(`.lib` 导入库)与运行的文件 +(`.dll`)是两个;ELF/Mach-O 是同一个。 +**按三元组分目录不按 OS 分**:MinGW 与 MSVC 同为 windows,一个产 `lib*.a` 一个产 `*.lib`。 + +### 1.2 两个兼容量,不是一个 + +| | `abi_tag` | `build_key` | +|---|---|---| +| 回答 | 「这份二进制能不能链进你的构建」 | 「这份 BMI 能不能直接用」 | +| 谁能算 | **生产者**(消费者存在之前就能枚举) | **只有消费者**(含依赖闭包 Merkle) | +| 来源 | `mcpp build --print-fingerprint` 的 [1][2][4][5][6] 的**纯投影** | `cache_key::key_hex`(已存在) | + +``` +-----c++ + +x86_64-linux-gnu-gcc16-libstdcxx16-c++23 +aarch64-macos-none-llvm22-libcxx22-c++23 +x86_64-windows-msvc-msvc194-msvcstl194-c++23 +``` + +⚠️ **`arch-os-env` 必须用 `triple.cppm` 的规范拼写,不是 fingerprint 的 [4]** —— +[4] 是编译器自报的(`x86_64-w64-mingw32`),与 `[target.'…']` 键的拼写 +(`x86_64-windows-gnu`)不同,直接用会让同一个决定有两个拼写。 + +**纯 `extern "C"` 库发一个更短的 tag** —— `x86_64-linux-gnu`,没有 +compiler/stdlib/std 段。闸门按段比对 tag 里**有**的东西,而 `abi_check` +(`abi.cppm:157`)早就是「未指定的维度 = 不关心」。**tag 的形状就是 surface**, +不需要额外的开关;C 库的 tag 组合数因此天然从 N×M 掉回 N。 + +### 1.3 三层分发,层级由消费端选 + +| Tier | 包里有什么 | 生效条件 | 失配时 | +|---|---|---|---| +| **S** source | 全部源码 | 永远 | —— | +| **I** interface+binary | 接口 + 预编译库 | `abi_tag` 匹配 | 降级到 S;无 S 则**明确拒绝并列出可用 tag** | +| **B** +BMI | 再加预编译 BMI | `build_key` 精确匹配 | **静默**降级到 I | + +Tier B 必须先做跨机器可行性实验才能实施(GCC 把时间戳与源码路径写进 BMI), +**它只能是加速器,永不成为正确性依赖**。 + +--- + +## 2. 生产者侧 + +### 2.1 命令 + +```bash +mcpp pack # 唯一可打包目标;有歧义 → 报错并列出候选 +mcpp pack mathkit # kind = "lib" → 静态库包 +mcpp pack mathkit-shared # kind = "shared" → 动态库包 +mcpp pack myapp # kind = "bin" → 应用 bundle(既有四档 --mode) +mcpp pack mathkit --target x86_64-linux-gnu \ + --target aarch64-linux-gnu # 胖包(--target 可重复) +``` + +**新增 CLI:一个位置参数(与 `mcpp run [target]` 同形)+ `--target` 可重复。** +**新增 manifest 键:0 个。** + +| `[targets.].kind` | 产出 | `--mode` 适用 | +|---|---|---| +| `bin` | 应用 bundle | ✅ 既有四档 | +| `lib` | 静态库包 | ✅ **`system`(默认)/ `vendored`** —— 见下 | +| `shared` | 动态库包 | ✅ `system` / `vendored` / `self-contained` | + +#### 2.1.1 ⚠️ 依赖跟不跟着走,静态库同样要选(修正) + +本文前一版写「静态库的 `--mode` 不适用,归档不携带任何东西」。**那句是错的。** +「归档不携带依赖的代码」恰恰**是**问题所在:消费者必须自己把 `zlib` 拉进来 —— +除非包把它一起带上。这就是 `--mode` 问的那件事,只是机制随形态不同: + +| `--mode` | 库包里有什么 | 消费者要什么 | 适合 | +|---|---|---|---| +| **`system`**(默认) | 只有本库 + `[dependencies]` 声明 | 自己解析依赖(公开索引 / 私有索引) | 依赖都是公开包 | +| **`vendored`** | 本库 + **依赖的产物一起进 `lib//`**,包内不声明 `[dependencies]` | 什么都不要 | 内网 / 离线 / 依赖本身也是闭源 | +| `self-contained` | 仅 `shared`:再带上 libc 一侧的闭包 | 什么都不要 | 跨发行版 | + +**与应用的 `--mode` 是同一个问题的同一批答案**:闭包跟不跟着走。机制不同 +(应用问的是运行期 `.so`,静态库包问的是构建期可链接的产物),但问题相同, +所以复用既有词汇而不是发明第二套。 + +`vendored` 形态下,依赖产物在 `[[runtime.artifacts]]` 里各占一条, +`provenance` 记它**来自哪个包**(`"vendored compat.zlib@1.3.2 by mcpp-pack 2026.8.17.1"`), +生成的 `ldflags` 按拓扑序把它们排在本库之后。 + +**分期**:`system` 是 P0(当前实现自然就是它),`vendored` 是 P1。 + +### 2.2 生产者工程示例(全部是既有的键) + +```toml +# examples/05-lib-distribution/producer/mcpp.toml +[package] +name = "mathkit" +version = "0.1.0" +description = "Demo: shipping a prebuilt library with both header and module interfaces" +license = "Apache-2.0" +platforms = ["linux", "windows"] # 既有键。pack 用它做覆盖校验(缺腿告警) + +[build] +sources = ["src/*.cppm", "src/*.cpp", "src/*.c"] +include_dirs = ["include"] # 公开头。整发,不可裁 + +# [lib] path 不写 ⇒ src/mathkit.cppm 约定 ⇒ 模块闭包从这里出发 + +[targets.mathkit] +kind = "lib" # → mcpp pack mathkit 产出静态库包 + +[targets.mathkit-shared] +kind = "shared" # → mcpp pack mathkit-shared 产出动态库包 +soname = "libmathkit.so.1" # 给出正确的运行期名 + +[pack] +include = ["share/**"] # 既有键 —— 只作用于 extras +``` + +``` +examples/05-lib-distribution/producer/ +├── mcpp.toml +├── README.md +├── include/mathkit_c.h # extern "C" 头接口 +└── src/ + ├── mathkit.cppm # export module mathkit; export import :api; ← 闭包根 + ├── api.cppm # export module mathkit:api; ← 闭包内 + ├── secret.cppm # module mathkit:secret; ← 实现分区,**不发布** + ├── impl.cpp # module mathkit; import :secret; + └── capi.c # C API 实现 +``` + +### 2.3 `mcpp pack ` 做什么 + +``` +1. build 出产物 ⚠️ 用这次构建的 target///bin/… 路径, + 绝不 glob(会挑到陈旧的 fingerprint 目录) +2. 算接口闭包(从 lib-root 出发) ⚠️ 走 P1689,不走 M1 文本扫描器(§9-D) + → 拷进 interface/ + → 打印「将发布 / 不发布」两张清单 + → 闭包里出现实现分区 ⇒ 告警「它的源码会被发布」 +3. 拷 include_dirs 全量 → include/ +4. 从归档剔除**已发布闭包那些单元**的对象 ⚠️ 不是「所有 .m.o」(J3 同一个闭包的第二个用途) +5. artifact_kind = shared 时额外: + · RUNPATH / INTERP 重写($ORIGIN 化) ← J2 + · 第三方闭包收集(ELF: LD_TRACE;PE: 导入表) + · PE: 导入库进 lib/,DLL 进 bin/ +6. 生成包内 mcpp.toml(§2.4)+ HOST-REQUIREMENTS +7. 确定性归档(PE → .zip,其余 → .tar.gz) +``` + +**计划阶段必须校验的组合**(不能留到链接期;`--mode` 与形态的组合见 §2.1.1): + +| target | `kind = "lib"` | `kind = "shared"` | +|---|---|---| +| `x86_64-linux-gnu` | ✅ | ✅ | +| `x86_64-linux-musl` | ✅ | ❌ **musl 蕴含 `-static`,产不出共享库** | +| `x86_64-windows-gnu` / `-msvc` | ✅ | ❌ P1 解锁(PE 导入库) | +| `*-macos` | ✅ | ❌ P1 解锁(install_name) | + +### 2.4 包内 `mcpp.toml` —— 一个普通的 mcpp.toml,没有新段 + +> **本节是第三次重写。** 前两版分别提了 `MCPP-PACKAGE.toml` 和 `[distribution]` 段。 +> 逐条追问「这件事别处已经能说了吗」之后,**两者都被删掉**:每一条事实都有既有字段。 + +#### 2.4.1 每条事实的归属 + +| 曾经想新增的 | 归属(全部既有) | +|---|---| +| `artifact_kind`(static/shared) | `[[runtime.artifacts]].role` = `static-library` / `shared-library` | +| `abi_tags`(包级列表) | `[[runtime.artifacts]].abi` —— **每条腿一个,比包级列表更准** | +| `abi_surface`(c/cxx) | **tag 自身的形状**(见 2.4.2) | +| `modules` | `[modules] exports`(既有的完备性断言) | +| `cxx_runtime` | `[build] cxx_runtime`(既有) | +| `interface_digest` | `[[runtime.artifacts]]` 里 `role = "interface"` 的条目,**逐文件一条** | +| `built_by` | `provenance = "mcpp-pack "` | +| `build_key` | `host_fingerprint`(docs/05 §2.11 原文:*optional evidence*) | +| 「这是分发包」的标记 | `provenance` 以 `mcpp-pack` 开头 —— **可推导** | +| `schema` | 不需要:没有新 schema 要版本化 | + +**实测**(mcpp 2026.8.15.3):`role = "interface"`、任意 `abi` 串、`digest`、 +`provenance` 全部原样进 `resolution.json`,并挂上既有的 `identity` 判定 +(路径不存在 ⇒ `missing`)。**兼容性比新段更好** —— 新段会被老客户端静默跳过 +(等于没有记录),而 `[[runtime.artifacts]]` 是老客户端**已经在读**的段。 + +#### 2.4.2 `abi_surface` 消失了:tag 的**形状**就是 surface + +`abi.cppm:157` 的 `abi_check` 早就是「**未指定的维度 = 不关心**」。所以不需要一个 +布尔来说「我只约束 libc」—— **发一个短 tag 就是在说这件事**: + +``` +纯 extern "C" 库 abi = "x86_64-linux-gnu" ← 只有三段 +C++ 模块库 abi = "x86_64-linux-gnu-gcc16-libstdcxx16-c++23" ← 全段 +``` + +闸门按**段**比对 tag 里有的东西。C 库的 tag 组合数因此天然从 N×M 掉回 N, +不需要任何开关。 + +#### 2.4.3 生成的包内 `mcpp.toml` —— 描述只有三件事 + +**一个二进制包的描述应当和源码包一样、甚至更简单。核心就三样:接口 / 库 / 依赖。** +其余的都不是「描述」,是**证据**,而证据挂在既有的 `[[runtime.artifacts]]` 上, +每条腿一条 + 接口一条,不是每个文件一条。 + +```toml +# ══ 由 `mcpp pack mathkit` 生成。手工编辑会使 interface 的 digest 失配并被拒绝。 ══ +[package] +namespace = "acme" +name = "mathkit" +version = "0.1.0" + +# ── ① 接口 ──────────────────────────────────────────────────────── +[build] +sources = ["interface/mathkit.cppm", "interface/api.cppm"] # 模块接口:消费者编译它 +include_dirs = ["include"] # 头接口:消费者 #include +cxx_runtime = "self-contained" + +[modules] +exports = ["mathkit"] + +[targets.mathkit] +kind = "lib" + +# ── ② 库(每条腿一段;必须是 cfg(...),不能是裸三元组 —— J4)────────── +[target.'cfg(all(linux, not(env = "musl")))'.build] +ldflags = ["-Llib/x86_64-linux-gnu", "-lmathkit"] + +[target.'cfg(all(linux, env = "musl"))'.build] +ldflags = ["-Llib/x86_64-linux-musl", "-lmathkit"] + +[target.'cfg(windows)'.build] +ldflags = ["-Llib/x86_64-windows-gnu", "-lmathkit"] + +# ── ③ 依赖(从生产者工程原样带过来)──────────────────────────────── +[dependencies.compat] +zlib = "1.3.2" + +# ── 证据:每条腿一条 + 接口一条 ──────────────────────────────────── +[[runtime.artifacts]] +role = "static-library" +path = "lib/x86_64-linux-gnu/libmathkit.a" +provenance = "mcpp-pack 2026.8.17.1" # 前缀即「这是分发包」的标记 +abi = "x86_64-linux-gnu-gcc16-libstdcxx16-c++23" +digest = "sha256:…" +host_fingerprint = "aeb4c4d29e437696" # build_key,Tier B 用 + +[[runtime.artifacts]] +role = "interface" +path = "interface" # 目录,一条即可 —— 不是每个文件一条 +provenance = "mcpp-pack 2026.8.17.1" +digest = "sha256:…" # 对有序文件集的摘要 +``` + +**⚠️ ③ 依赖是必须的,而且容易漏。** 一个静态库的 `.a` **不携带**它的第三方依赖 —— +消费者链接时必须自己把 `zlib` 拉进来。所以 `mcpp pack` 要把生产者的 +`[dependencies]` 原样写进包(**排除 dev-dependencies 与 path 依赖** —— +后者是本地的,发出去解析不了)。这条规则 `emit_xpkg` 已经在用 +(`publisher.cppm:186-192`),**同一份推导,第二个投影**。 + +`shared` 形态另外多一段: + +```toml +[runtime] +runtime_search_dirs = ["lib/x86_64-linux-gnu"] # 进消费者的 RPATH +# deploy_files = ["bin/x86_64-windows-gnu/mathkit.dll"] # PE:部署到 .exe 旁 +``` + +#### 2.4.4 为什么接口 digest 只有一条 + +**它防的是「解开之后有人改了随包的接口」**,不是「生产者一开始就发错了配对」—— +后者只有原子产出能防(§0 J1)。索引通路上,整包已经有 sha256;真正裸奔的是 +**path 依赖 / 已解开的 store**,而那里一条目录级 digest 就够: +诊断说「`interface/` 与打包时不一致,拒绝构建」已经是可行动的。 +逐文件 digest 能多说一句「是哪个文件」,代价是描述里多出 N 行 —— 不值得。 + +### 2.5 分发包目录里不许直接 build + +判据:**任一 `[[runtime.artifacts]]` 的 `provenance` 以 `mcpp-pack` 开头**。 +此时在解开的包目录里直接 `mcpp build` **必须拒绝**,并说清这是分发包不是源码树。 +今天它会**成功** —— 把 `interface/` 里只有声明的接口单元编出来,产出一个几乎空的库, +实现全在预编译产物里没被链进来。典型的「看起来成功的失败」。 + +--- + +## 3. 消费者侧 + +### 3.1 三种依赖形态,同一条下游路径 + +```toml +# ① 离线文件 —— P0。不需要索引、网络、鉴权,不需要动 xlings +mathkit = { package = "vendor/mathkit-0.1.0-x86_64-linux-gnu-gcc16-libstdcxx16-c++23.tar.gz" } + +# ② 索引(公开或私有)—— P1。描述符与源码包同构:只有 url + sha256 +mathkit = "0.1.0" + +# ③ 本地目录(内部团队最常走的一条) +mathkit = { path = "../mathkit-dist" } +``` + +```bash +mcpp add ./mathkit-0.1.0-.tar.gz +``` + +**⚠️ 闸门必须对 ③ 也生效** —— 这就是闸门字段要放进包内 `mcpp.toml` 而不是旁路文件的理由。 + +### 3.2 闸门表(顺序即诊断顺序) + +| 检查 | 失配 | +|---|---| +| arch / os / env | **拒绝** —— 载荷本身不对 | +| `abi_surface == "c"` | 上一行之后全部跳过 | +| compiler 族 / 主版本 | **拒绝**;`--allow-abi-drift` 强制并打印它保护的是什么 | +| stdlib id / 主版本 | **拒绝** | +| C++ 标准档位 | 消费者 < 生产者 → **拒绝**;> → 放行 | +| `cxx_runtime` 契约 | **警告** + 说清双运行时危险;`--strict` 升级为错误 | +| `interface_digest` | **拒绝** | +| 模块符号存在性(ELF `_ZGIW` / PE 导出表) | **拒绝** | +| `build_key`(Tier B) | **静默**降级到 Tier I | + +### 3.3 没有匹配 tag 时的诊断 + +``` +error: acme.mathkit@0.1.0 has no prebuilt artifact for this toolchain + your toolchain : x86_64-linux-gnu-gcc16-libstdcxx16-c++23 + published tags : x86_64-linux-gnu-gcc15-libstdcxx15-c++23 + aarch64-linux-gnu-gcc15-libstdcxx15-c++23 + note: this package ships no source tier, so there is nothing to fall back to. + fix : ask the publisher for a gcc16 build, or pin [toolchain] to gcc@15. +``` + +**「不可用」不能被拼成「不存在」** —— 被拼成「找不到包」的失败会让客户端自己驱动 +重复刷新索引(#349 的教训)。 + +### 3.4 兼容性:老客户端拿到这个包会怎样 + +**能构建,只是没有闸门。** 实测(mcpp 2026.8.15.3): +包内 mcpp.toml 用的 `sources` / `[build]` / `[modules] exports` / +`[target.'cfg(…)']` / `[[runtime.artifacts]]` **全部是已发布能力** —— 老客户端 +逐字读得懂,只是不会**执行闸门**(它不知道 `provenance = "mcpp-pack"` 意味着要校验 +digest 与 abi tag)。这是**降级**而不是变砖 —— +但**闸门只保护新客户端,必须写进发布说明**。 + +这也是不新增段的第二个好处:一个**新**段会被老客户端静默跳过,连记录都没有; +而 `[[runtime.artifacts]]` 是老客户端**已经在读、并且会写进 `resolution.json`** 的段, +所以即使闸门不执行,证据仍然落盘、仍然可审计。 + +--- + +## 4. `docs/` 文档计划 + +| 文件 | 动作 | 内容 | +|---|---|---| +| `docs/02-pack-and-release.md` | **改** | 标题改为「打包:应用与库」。新增「§库分发」:`mcpp pack ` 的 kind 表、两种接口模式的目录规则、包布局、跨平台合法组合表。既有的应用四档 `--mode` 一字不改 | +| `docs/12-binary-distribution.md` | **新增** | 库作者的完整链路:写工程 → `mcpp pack` → 检查两张清单 → 分发(文件/私有索引/公开索引)→ 消费者怎么用。含 §2.2 与 §3.1 的完整示例 | +| `docs/05-mcpp-toml.md` | **改** | ① `[pack]` 一节补「只作用于 extras,裁不到头与接口」;② §2.11 补 `role = "interface"` 与 `provenance = "mcpp-pack"` 的约定用法(生成物,不是手写配置);③ Appendix A 补一条准入判据:「能从别处推出来的不给字段」 | +| `docs/10-publishing-a-library.md` | **改** | 新增「发布二进制包」小节:与源码包**同构**的索引条目、`platforms` 覆盖校验、老客户端降级说明 | +| `docs/03-toolchains.md` | **改** | `abi_tag` 的六个成分与 `--print-fingerprint` 的对应关系 | +| `docs/zh/*` | **同步** | 上述五处的中文版 | + +--- + +## 5. `examples/` 具体示例 + +沿用既有编号与「每目录一个 README.md」的约定。 + +### `examples/05-lib-distribution/producer/` —— 生产者(库作者) + +结构见 §2.2。README 要点: + +- 一个工程**同时**提供头接口与模块接口,消费者可以只用其中一种; +- `secret.cppm` 是实现分区,**不会被发布** —— 跑 `mcpp pack mathkit` 看两张清单; +- 两个目标(`lib` + `shared`)如何同时发布,以及 `soname` 给出的正确运行期名; +- **反面演示**:把 `src/mathkit.cppm` 改成 `import :secret;`,再 pack, + 观察闭包里多出 `secret.cppm` 并触发告警。 + +### `examples/05-lib-distribution/consumer/` —— 消费者 + +``` +examples/05-lib-distribution/consumer/ +├── mcpp.toml # mathkit = { path = "../producer/target/dist/mathkit-0.1.0-" } +├── README.md +└── src/ + ├── main_header.cpp # 只 #include + ├── main_module.cpp # 只 import mathkit; + └── main_both.cpp # 两者同时 +``` + +README 要点:三种消费方式 × 静态/动态两种包 = 六格,全部可跑; +以及**篡改 `interface/` 后必须被拒绝**的演示。 + +### `examples/07-lib-dist-fat/` —— 胖包与交叉 + +一个 `mcpp.toml` + 一条命令产出三条腿,消费者用 `--target` 选。README 要点: + +- 为什么每条腿的 `ldflags` 是 `cfg(...)` 而不是裸三元组(J4,附最小探针); +- `lib/` 为什么按三元组分目录而不按 OS 分。 + +--- + +## 6. CI:验证矩阵就是 e2e 集合本身 + +> **相对初稿的修订。** 初稿提议新建 `pack-dist-matrix.yml`。**不需要。** +> `tests/e2e/run_all.sh` 已经有能力探测(`elf` / `gcc` / `mingw-cross` / +> `fresh-sandbox` / …)并按 `# requires:` 分流,而 `ci-linux-e2e.yml` 已经在跑 +> 整个 `tests/e2e/`。再建一条并行流水线就是**同一个决策的第二处推导**。 +> +> 矩阵是 e2e 集合 + 每个测试头部那行 `# requires:`。 + +### 6.1 落地的测试与它们钉住的判据 + +| e2e | `# requires:` | 钉住 | +|---|---|---| +| **242** `pack_library_interface_and_headers` | `gcc` | 两种接口模式共存;包布局;两张清单都被打印 | +| **243** `pack_library_interface_closure` | `gcc` | **J3** —— 实现分区源码不外发 **且** 它的对象留在归档里 | +| **244** `pack_library_gate` | `gcc` | **J1** 接口篡改被拒 + tag 失配被拒(且列出可用 tag)+ 包内 build 被拒 | +| **245** `pack_library_fat_target_selection` | `gcc` | **J4** —— 胖包每条腿只被自己的 target 看到,**含原生构建** | +| **246** `explicit_empty_sources` | `gcc` | §8-B2 —— `sources = []` 与「不写」可区分 | +| **247** `bare_triple_conditional_native` | `gcc` | §8-B1 —— 裸三元组谓词在原生构建下命中 | +| **248** `pack_library_fat_pe_leg` | `gcc mingw-cross` | 跨 OS 边界的腿(PE);tag 用规范三元组而非编译器自报 | + +### 6.2 ⚠️ 一处差点造出来的假绿 + +245 最初写成 `# requires: gcc mingw-cross`(gnu + musl + windows 三条腿)。 +**`ci-linux-e2e.yml` 只预热 gcc 与 musl,不装 mingw-cross** —— +那条测试会在每一次普通 CI 上**静默跳过**,于是胖包这一整套机制 +(以及 J4)在绿色的套件里**从未被验证过**。 + +拆法:**核心机制用 CI 一定有的工具链**(245:gnu + musl,`requires: gcc`), +**只把新增的二进制格式覆盖单列**(248:PE,`requires: mingw-cross`)。 +判据:**一个测试的 `# requires:` 必须是它所验证机制的真实下限,不是它能跑的上限。** + +### 6.3 单元测试 + +| 文件 | 覆盖 | +|---|---| +| `tests/unit/test_pack_abi_tag.cpp` | tag 的投影 / 规范三元组 / C 表面短 tag / 从尾解析 / 档位下限 / 一次报全部失配(15 例) | +| `tests/unit/test_pack_interface.cpp` | 闭包 / 剔除集不是「所有 `.m.o`」/ 依赖模块不越界 / 未解析分区报错(8 例) | + +### 6.4 仍需在 CI 上补的(P1) + +- macOS 与 Windows 的 e2e 分片会自动跑 242/243/244/246/247(它们只 `requires: gcc`), + 但**尚未在这两个平台上人工确认过**; +- `kind = "shared"` 的库包 e2e(P1,随 PE 导入库 / Mach-O install_name 一起); +- `--target` 覆盖与 `[package].platforms` 的比对告警(P1)。 + +## 7. 分期 + +### P0 —— `static` 形态,三平台一次做完(不依赖 xlings、不依赖 shared) + +1. `sources = []` 的三态(§8-B2)+ §2.5 的 build 守卫(判据 = `provenance` 前缀) +2. 包布局 + 生成包内 `mcpp.toml` +3. `mcpp pack ` 位置参数 + `--target` 可重复;`kind = "lib"` ⇒ 静态库包 +4. 接口闭包(P1689)+ 两张清单 + 实现分区告警 +5. 从归档剔除已发布闭包的对象 +6. `abi_tag` 计算 + 闸门表 + `interface_digest` + 模块符号存在性 +7. `mcpp add ./x.tar.gz` + `{ package = "…" }` 依赖形态 +8. e2e 242 / 244 / 245 / 246 / 248 / 250 / 251;examples 05 + 06 + +**为什么 static 能在 P0 覆盖三平台**:`kind = "lib"` 无平台限制, +所以 G2、RUNPATH 重写、闭包收集、DLL 部署这一期全部不需要。 + +### P1 —— `shared` 形态解锁三平台 + 索引通路 + +9. PE 导入库(`--out-implib` / `/IMPLIB:`)+ Mach-O `-install_name @rpath/…` +10. `kind = "shared"` 守卫的判据改成「该 target 是否动态链接」(musl 走计划期拒绝) +11. `shared` 形态的 pack:RUNPATH 重写 + 闭包收集 + `bin/` 部署面 +12. 胖包上索引(描述符与源码包同构)+ `platforms` 覆盖校验 +13. e2e 243 / 247 / 249;examples 07;`pack-dist-matrix.yml` + +### P2 —— 交叉与私有鉴权 + +14. 瘦包(按 tag 分资产)+ `install_packages` 加 target 轴(跨仓库,与 xlings 同步) +15. `IndexSpec` 鉴权(值从环境变量读,永不落盘) +16. `target_cfg` / `[target.'cfg(…)']` 承载 LinkIntent(顺手修 #258 同形状的债) + +### P3 —— Tier B 与生态收尾 + +17. Tier B:**先做跨机器可行性实验**,做不到就不做 +18. `.pc` / CMake config 产出 +19. #304(`library_dirs` 的 link/runtime 分离收口)、#290(按版本区分构建规则) + +--- + +## 8. 需要先单独修的既有缺陷(不属于本方案,但阻塞它) + +| # | 缺陷 | 位置 | 对本方案的影响 | +|---|---|---|---| +| **B1** | `[target.'<三元组>'.build]` 在原生构建下**永不命中** | `prepare_inputs.cppm:139` `if (triple.empty()) return false;`,而同文件 `context_for()` 对 `cfg()` 回落到 `host_triple()`;`types.cppm:665` 的注释承诺的是回落 | 直接阻塞胖包的裸三元组写法(J4)。**任何用该写法的工程都中招**,形状是「CI 全绿、本机静默失配」 | +| **B2** | `sources = []` 与整行删掉逐字节等价 | `toml.cppm:1703` 的默认 glob 吞掉显式空 | 二进制包无法表达「什么都不要编」;遗留在 `src/` 的文件会被编进消费者的构建 | +| **B3** | M1 扫描器不把实现分区建模为 provider | `scanner.cppm:641-650` 对非 `export` 的 `module X;` 从不设 `u.provides` | 「接口够到实现分区」的告警产不出来 ⇒ 闭包必须走 P1689 | +| **B4** | `kind = "shared"` 的守卫判据是 `os != "linux"` | `plan.cppm:1002` | musl 过闸后死在 `crtbeginT.o`,消息里既没有 musl 也没有 shared | + +B1 / B2 建议**先单独开 issue 并各带一条回归测试**,不要埋进这个大特性里。 + +--- + +## 9. 四处被自己的实测推翻的设计(不要重新提出来) + +| # | 我写过的 | 被什么推翻 | 正确的 | +|---|---|---|---| +| **A** | 新增描述符键 `kind = "prebuilt"` + 版本 floor | 它属于「不降级」的那一类,老客户端会被砖 | 走 **Form A**(包自带 `mcpp.toml`)⇒ 无新描述符键、无 floor、兼容几乎免费 | +| **B** | 打包时「剔除所有 `.m.o`」 | **三个 target 全部链接失败**(`undefined reference to mk::secret_helper@mathkit()`)—— 实现分区照样是 `.m.o` 且里面是真代码 | 剔除集 = **已发布闭包里那些单元**的对象;与发布集是**同一个闭包的两个用途** | +| **C** | 胖包每条腿用裸三元组 `[target.'x86_64-linux-gnu'.build]` | 显式 `--target` 三个全绿,**裸 `mcpp build` 链接失败**;最小探针:裸三元组 0 命中 / `cfg(linux)` 2 命中 | 一律用 `cfg(...)`(J4);并把根因(B1)单独开 issue | +| **D** | `[pack]` 加五组键(`default_kind` / `default_artifact` / `targets` / `[pack.interface]` / `[pack.headers]`) | 逐条问「别处已经说过了吗」之后全部落空 | **新增 0 个键**:kind → `[targets.].kind`;接口根 → `[lib]`;头目录 → `[build].include_dirs`;平台 → `[package].platforms` 升格为断言 | + +外加一处我自己踩的坑:原型用 `rglob` 找产物,**挑到了陈旧的 fingerprint 目录** +(少一个 `capi.o`,症状是消费者 undefined reference,看起来完全像 mcpp 的问题)。 +**打包器绝不能 glob 产物** —— 这与仓库既有的「`ls | head -1` 会自查到旧二进制」是同一形状。 + +--- + +## 10. 证据索引 + +全部实测记录在 `2026-08-17-distribution-architecture-analysis-and-design.md`: + +| 判据 | 那份文档的位置 | +|---|---| +| J1(接口↔二进制无绑定,静默错数据) | §2.2(c) | +| J2(`.so` 烧生产机器 RUNPATH) | §2.2(a) | +| J3(闭包 vs `.m.o`;剔除集) | §2.4.2 / §2.4.3 | +| J4(裸三元组不匹配 + 最小探针) | §2.4.5 | +| 六格矩阵(两种接口 × 两种形态) | §2.5.1 | +| 两条目录规则 | §2.5.2 | +| musl + shared 链接期炸 | §2.5.3 | +| `sources = []` 不生效 | §2.5.4 | +| 新段的兼容边界 + array-of-tables 白名单(**为什么最终不新增段**) | §4.2 | +| `[[runtime.artifacts]]` 能承载 role/abi/digest/provenance + identity 判定 | 本文 §2.4.1 实测 | +| `[pack]` 推导审计 | §4.6.1 | +| 复现脚本(`scratchpad/lab/`) | 附录 A | +| file:line 索引 | 附录 B | + + +--- + +## 11. 实施记录(PR #451,2026-08-17) + +### 11.1 落地与设计的差异 + +| 设计说的 | 实际做的 | 为什么 | +|---|---|---| +| `[distribution]` 段(4 个字段) | **一个字段都没加** | 追问「这件事别处能说吗」之后,`role`/`abi`/`digest`/`provenance`/`host_fingerprint` 全在 `[[runtime.artifacts]]` 上;`provenance` 前缀就是「这是分发包」的标记 | +| `abi_surface = "c"` 开关 | **tag 的形状本身** | `abi_check` 早就是「未指定 = 不关心」,短 tag 就是那句声明 | +| 新建 `pack-dist-matrix.yml` | **复用既有 e2e 通路** | `run_all.sh` 已有能力探测与 `# requires:` 分流,再建一条是同一决策的第二处推导 | +| `shared` 排 P1 | **Linux/ELF 已可用** | 实测发现只差一件事:包里要同时带链接名与 SONAME | + +### 11.2 实现过程中被实测推翻的四条 + +**(a) `mcpp.pack.library` 里叫 `Error`。** `mcpp.pack` 已经有一个 +`mcpp::pack::Error`,而一个名字只能归属一个模块。**GCC 接受了,clang 直接拒绝** +(*cannot be attached to other modules*)—— Windows 与 macOS 全红、Linux 全绿, +这正是三平台矩阵存在的理由。 + +**(b) `mcpp pack` 在 workspace 根上不能用了。** 路由要在构建前读 manifest, +而 workspace 根**没有自己的目标**(虚拟 workspace 连 `[package]` 都没有), +于是新路由读到空列表、判定「无可打包」。**用上一版发布的二进制跑 +`examples/04-workspace` 对照才发现** —— e2e 249 就是这次对照的固化。 + +**(c) 位置参数没传给应用通路。** 两个 `bin` 目标的工程接受 `mcpp pack app2` +却打包 app1 —— **命令成功、答案错误**。e2e 250 两个方向都钉。 + +**(d) 动态库包只带了构建出来的文件名。** `-lmathkit-shared` 链的是 +`libmathkit-shared.so`,而对象记的是 `SONAME libmathkit.so.1` —— 两个不同的文件名。 +包链得上、**起不来**。是 **mcpp 自己的运行期闭包检查**报出来的 +(`libmathkit.so.1 not found on the search path this artifact will actually use`), +而不是加载器错误。e2e 251 用 `run` 而不是 `build`:这里链接过了什么都不证明。 + +**(e) 「新测试在 CI 里跑了吗」必须单独查一遍 —— 我自己造了第二次假绿。** +十个新 e2e 一开始全写 `# requires: gcc`,而那个能力**按设计只在 Linux 成立** +(macOS 的 g++ 是 Apple Clang,Windows 的也不是 mcpp 兼容的 GCC)。于是它们 +**在 macOS 与 Windows 上全部跳过、套件报绿**,而文档写着「静态库包:所有 target」。 +判据不是「套件绿了」,是「这条测试**跑了**吗」——`grep 'SKIP:'` 三分钟就能查。 + +放开之后 CI 给出了真实答案,**这才是这次放开的价值**: +- Windows(MSVC-ABI clang)那条腿上,`pack` 内部的库构建以一句 + `error: build failed` 失败(本机无法复现该路径); +- macOS 上 `ar` 解析到一个报「未安装」的 xlings shim,闭包测试查不了归档。 + +**处理方式是记成明确的限制,不是塞回去。** 打包测试重新 `# requires: gcc` +并在头部写明范围;**命令本身**(路由 / 未知名字 / workspace 根)保持三平台运行 +(249、250);`docs/12` 与其中文版加了「验证到哪一步」一节。 +**「所有 target 都支持」与「只在 Linux 验证过」是两句话,文档现在两句都说。** + +外加一条 e2e 卫生:六个 fixture 把包路径按 **shell 拼写**写进了 mcpp.toml。 +`00_fixture_path_hygiene.sh` 在 macOS 那条腿上抓到 —— 规则是 Windows 的 +(MSYS 只转 argv 不转文件内容),而 lint 在所有平台跑,正是为了让 Linux 上的 +reviewer 先于 Windows CI 发现。 + +### 11.2b 「全面可用」是怎么做到的(不是靠放宽 requires) + +放开可移植测试之后 CI 报出两条真实失败,**追下去发现根因在扫描器,不在打包**: + +`module M:part;`(实现分区)与 `module M;`(实现单元)共用一个拼写、是两种声明, +而扫描器把前者记成**「requires `M:part`、provides 空」** —— 一个文件 requires +自己的名字。于是图里**没有**「import 分区的单元 → 定义分区的单元」这条边, +构建顺序无约束:GCC 与 macOS clang 靠各自的依赖扫描兜住, +**Windows clang 以 `failed to read compiled module` 失败**。 +同一处第二半:`import :part;` 的解析读 `u.provides`,而实现单元没有 provides ⇒ +`import :secret;` 停在字面 `:secret`。两平台都刷的那条 +`module 'M:part' imported but not provided in this build` **就是病因,读起来像提示**。 + +**实现分区在此之前 mcpp 里任何地方都没有测试覆盖** —— 库分发的 e2e 是第一个用它的。 +修好之后: + +| e2e | linux | macOS | windows | 跑在哪 | +|---|---|---|---|---| +| 242 布局 + 两种接口模式 | ✅ | ✅ | ✅ | e2e 分片 | +| 243 闭包 + 剔除集 + 分区告警 | ✅ | ✅ | ✅ | e2e 分片 | +| 244 三条拒绝 | ✅ | ✅ | ✅ | e2e 分片 | +| 246 `sources = []` | ✅ | ✅ | ✅ | e2e 分片 | +| 247 裸三元组条件 | ✅ | ✅ | ✅ | e2e 分片 | +| 249 workspace 根仍能打包 | ✅ | ✅ | ✅ | e2e 分片 | +| 250 `pack ` 打的是那个 | ✅ | ✅ | ✅ | e2e 分片 | +| 251 动态库:产出正确 **或** 明确拒绝 | ✅ 产出 | ✅ 拒绝 | ✅ 拒绝 | e2e 分片 | +| 248 跨 OS 边界的胖包(PE) | ✅ | — | — | **`cross-build-test.yml` 的 mingw job** | +| 245 胖包(含原生构建) | ✅ | skip | skip | e2e 分片 | + +**只剩 245 一条在 Linux 之外跳过,而它是真限制**:那条测试要**两个都能构建的 +target**(`x86_64-linux-gnu` + `x86_64-linux-musl`),而 macOS / Windows 的 CI 上 +不存在第二个现成可用的 target。它验的机制(每条腿一个 `cfg()`、原生构建也命中) +本身与平台无关,已在 Linux 上覆盖。 + +**另外四条原本的 skip 理由,查下来三条是我偷懒:** + +| 原理由 | 真相 | +|---|---| +| 246「读编译器特定的 flag 拼写」 | **错的** —— 它 grep 的是**文件名**。`# requires: gcc` 是复制粘贴来的 | +| 247「读编译器特定的 flag 拼写」 | **半对** —— 它 grep 了 `-D` 前缀,而 MSVC 是 `/D`(`dialect.cppm:200`)。改成 grep **宏名**即可移植 | +| 248「需要 mingw-cross,没有 job 装它」 | **可修** —— `cross-build-test.yml` 的 mingw job **已经**在显式点名跑那些「Linux 分片因缺 mingw-cross 而跳过」的 e2e(102/198/240),照办即可 | +| 251「`shared` 仅 ELF」 | **理由成立,但只测了一侧** —— 补成两侧断言:ELF 上产出且带两个名字,非 ELF 上**必须拒绝且说明原因**。只测能用的那侧分不清「门被处理」与「没有门」 | + +副作用:一条**与打包无关**的既有缺陷被修好了 —— 用实现分区的工程现在在 Windows 上 +能构建了。 + +**判据(第二次得到验证):一条测试的 `# requires:` 必须是它所验证机制的真实下限。** +写下它的时候很难分辨「真限制」与「我没想清楚」,所以事后必须**逐条追问理由**; +四条里三条经不起追问。 + +### 11.3 我在验证里自己踩的坑 + +**`ls -t target/*/bin/mcpp | head -1` 挑到了陈旧/别的工具链的 fingerprint 目录 —— +三次。** 一次让 B1 的修复看起来没生效,一次让全部新 e2e 报段错误(实际是拿了 +clang 构建的二进制,而 clang 构建的 mcpp 在本机会段错误)。 +**这与打包器「绝不 glob 产物」是同一条判据**,只是发生在验证侧。 + +### 11.4 P0 交付清单 + +| | | +|---|---| +| 新模块 | `src/pack/{abi_tag,digest,interface,library,library_pipeline,manifest_emit,prebuilt,route}.cppm` | +| 改动 | `prepare_inputs`(B1)、`toml`+`types`(B2)、`source_kind`(`object_filename_for` 归位)、`prepare`(图 + 两道闸门)、`plan`、`cli` | +| 单测 | `test_pack_abi_tag`(15)、`test_pack_interface`(8) | +| e2e | 242–251(10 个) | +| 文档 | `docs/12-binary-distribution.md` + zh;`docs/02`/`05`/README 索引 | +| 示例 | `examples/05-lib-distribution/producer`、`examples/05-lib-distribution/consumer` | + + +--- + +## 12. 深度 review(2026-08-17,PR #451 CI 全绿之后) + +### 12.1 245 / 248 在其他平台「没有」的真实原因 + +**权威答案是 `host_can_serve`(`registry.cppm:542-566`),不是测试的能力表。** + +| target | linux host | macOS host | windows host | +|---|---|---|---| +| `-linux-gnu` | ✅ | ❌ | ❌ | +| `*-linux-musl` | ✅ 任意 arch | ❌ | ✅ 仅 host arch | +| `*-windows-gnu`(mingw) | ✅ | ❌ | ✅ | +| `*-windows-msvc` | ❌ | ❌ | ✅ | +| `*-macos` | ❌ | ✅ | ❌ | + +- **macOS 只能服务一个 target。** 胖包至少要两条腿 ⇒ 在 macOS 上 + **结构上不可能**,不是测试没写。代码自己的注释: + *"macOS has no Linux-targeting payload at all."* +- **Windows 能服务三个**(msvc / mingw / linux-musl)⇒ **245 与 248 的等价用例在 + Windows 上是可行的,只是没做。** 这是**覆盖缺口**,不是产品缺口,而且它有独立价值: + `mathkit.lib`(MSVC)+ `libmathkit.a`(MinGW)同处一包,是「`lib/` 必须按三元组分」 + 最强的证据。代价是要在 Windows 的 e2e job 里装 `xim:mingw-gcc`(e2e 97 已经在 + 另一个 job 里装它)。 +- **248 需要一个能产 PE 的工具链**:Linux 上是 `mingw-cross`(现在跑在 + `cross-build-test.yml` 的 mingw job),Windows 上是原生 mingw/msvc(同上缺口)。 + +### 12.2 架构:依赖方向是可陈述的不变量 + +`src/pack/` 现在是两层,**必须保持**: + +``` +叶层(prepare 可以 import 它们): abi_tag digest interface manifest_emit prebuilt route library +编排层(它 import prepare): library_pipeline +``` + +实测的 import 图里,**只有 `library_pipeline` 碰 `mcpp.build.*`**;而 +`prepare` 只 import `pack.abi_tag` 与 `pack.prebuilt`(两个叶子)。 +**不变量:除 `library_pipeline` 外,`src/pack/` 下不得 import `mcpp.build.*`** —— +否则 prepare ↔ pack 成环。 + +三处收敛(都是把「同一个决定的第二处推导」删掉,不是加抽象): +- `object_filename_for` 从 plan.cppm 搬进 `mcpp.source_kind` —— 策略与格式化同处; +- 「怎么删归档成员」进 `dialect.cppm` 的 `archiveRemoveArg` —— 与 `archiveCmd` 同处, + packer 不再默认 `ar` 语法(**这条是 review 发现的,不是测试发现的**); +- 解析后的三元组进 `cfgpred::Ctx` —— 删掉裸三元组分支那个第二答题者。 + +### 12.3 兼容性:两处**故意的**行为变化 + +| 变化 | 谁会受影响 | +|---|---| +| `sources = []` 从「等于不写」变成「什么都不编」 | 真写了它又依赖默认 glob 的工程。此前**无法表达**「什么都不编」,所以这种写法只可能是误解 | +| 两个文件声明同一个分区 ⇒ 拒绝并点名两个文件 | 本来就 ill-formed 的程序。**但它是一条新的失败路径** | + +扫描器的改动**只会增加图上的边**,不会减少 —— 边缺失只可能允许错误的顺序, +所以不存在「依赖那条缺失边」的工程。 + +老客户端兼容:**两侧都验了**(静态:生成的 manifest 段 ⊆ 既有词汇,三平台跑; +真实:2026.8.15.3 消费并运行成功,本机)。⚠️ CI 里真实那半跑不了,因为 +`$MCPP_BOOT` 是 xvm shim(在 e2e 改过的环境里报「未安装」),这一点写在 252 的头部。 + +### 12.4 仍然开着的五项(合入前请裁决) + +| # | 项 | 我的建议 | +|---|---|---| +| **O1** | **扫描器改动是全 PR 里影响面最大的一块,而它与打包无关** | **拆成独立 PR 先合**,这样它能被独立 revert;库分发那半 rebase 在它上面 | +| **O2** | `scan_overrides` 声明的实现分区会被**误判成接口**(`providesInterface` 只在文本扫描路径设 false,`scan_overrides`/P1689 默认 true)⇒ 它的源码会被发布 | 窄洞,而且 `scan_overrides` 的 schema **没有**表达「是否 export」的位置。先记录;要修就是给它加一个键(那会是本方案唯一的新 manifest 键) | +| **O3** | MSVC 的 `/REMOVE:` 路径**未测试**(mcpp 的 Windows CI 用 clang 的 llvm-ar,走 GNU 形式) | 失败会带命令原文报错,不会静默;要真测需要 `msvc@system` 的 job | +| **O4** | Windows 上的胖包(msvc + mingw)未覆盖 | 见 §12.1;要在 Windows e2e job 装 `xim:mingw-gcc` | +| **O5** | 设计 §2.2 承诺的「`--target` 集合与 `[package].platforms` 覆盖比对告警」**没有实现** | 我漏了。它便宜(一次集合比对 + 一条 warning),但属于「发布纪律」而不是正确性,可以随后补 | + + +--- + +## 13. 第二轮:把 §12 的五项做完(2026-08-18,同一个 PR #451) + +裁决是「统一在单 PR 451 上实现最大兼容和支持度」,所以 O1(拆 PR)**不做**, +其余四项全部落地,并顺带补上了两个在做的过程中被逼出来的真缺陷。 + +### 13.1 §12 五项的结局 + +| # | 项 | 结局 | +|---|---|---| +| O1 | 把扫描器改动拆成独立 PR | **不做** —— 单 PR 是裁决。代价照旧:revert 粒度与库分发绑在一起 | +| O2 | `scan_overrides` 声明的实现分区被误判成接口 | **已修,而且没加 manifest 键**(见 §13.2) | +| O3 | MSVC `/REMOVE:` 未测试 | **两端都测了**:`archive_remove_command` 抽出成缝 + 单测钉命令,e2e 255 用真 `lib.exe` | +| O4 | Windows 胖包未覆盖 | **e2e 256**(msvc 腿 + mingw 腿同处一包),Windows e2e job 增加 `xim:mingw-gcc` 安装步与 `mingw` 能力 | +| O5 | `[package].platforms` 覆盖告警未实现 | **已实现**(见 §13.3) | + +### 13.2 O2 的解法:三态,而且零新键 + +`SourceUnit::providesInterface` 从 `bool` 改成 `std::optional`。 +三条建图路径各说自己真的知道的事: + +| 路径 | 它知道什么 | +|---|---| +| 文本扫描器 | 读到了关键字 ⇒ 显式 true / false | +| P1689 读取器 | 编译器的答案,**包括它的沉默**(`is-interface` 是可选键) | +| `scan_overrides` | **什么都不知道** ⇒ 留空。schema 里没有地方说 export | + +**判据:`true` 是那个「不产生任何警告」的值。** 字段原来的注释把默认 `true` +叫「保守方向,因为这个标志只会产生一条警告」—— 说反了,所以用 +`[scan_overrides]` 声明的实现分区被一声不响地发布,而那正是整个闭包设计要防的事。 + +两个细节让告警可用: +- **只问分区。** `module M;` 不 provide 任何东西,所以能 provide 裸 `M` 的只有 + `export module M;` —— 在那里问会对每个用了 override 的包的每个主接口都告警; +- **未知与已知说不同的话。** 一条是「你正在发布你的实现」,另一条是 + 「mcpp 判不出你是不是在发布」。 + +**P1689 那一半是真修复而不是变通**:编译器早就报了 `is-interface`, +mcpp 解析进了一个从来没人读的字段。 + +e2e 253 **两侧都钉**:同一个 fixture 打两次(扫描 / override),两次必须给出 +**不同的句子** —— 只断言 override 会告警,分不清「mcpp 建模了三态」与 +「mcpp 对每个发布的分区都告警」。**控制探针验证过**:把 override 那一处恢复成 +`true`(其余修复保留),253 在正好那条断言上失败。 + +### 13.3 O5 的解法:判据是 `host_can_serve`,难点是「别嚷嚷」 + +四种比较只有两种能打印: + +| 情况 | 处理 | +|---|---| +| 打了却没声明 | 永远可行动 —— manifest 否认了一个包明明能服务的平台 | +| 声明了却没打,**且本宿主能构建它** | 可行动 | +| 声明了却没打,本宿主构建不了 | **不说话** | + +第三行才是这个检查可用的原因:正常流程是 CI 每平台各跑一次 `mcpp pack`, +Linux runner 不产 macOS 腿不是遗漏、是每一次。**永远触发的告警会把真正该看的那条盖掉。** +判据用 `host_can_serve` —— 与 `--target` 接受什么是同一个函数,所以告警只可能 +点出作者此刻做得到的事。e2e 254 **把沉默也断言了**。 + +### 13.4 顺带被逼出来的两个真缺陷 + +**(a) `kind = "shared"` 此前只在 ELF 上可用,其余一律拒绝 —— 这一条是能力增加。** + +⚠️ **我在第一版提交信息里把这道守卫说成「在原生构建上失效」,那是错的, +review 时才自己抓出来。** 它写的是 `!targetTriple.empty() && os != "linux"`, +我据此推断「原生构建 targetTriple 为空 ⇒ 守卫不触发」。**实测否掉了这个推断**: +`tc.targetTriple` 由编译器的 `-dumpmachine` 填(`detect.cppm:88`),原生构建上非空 —— +`resolution.json` 里记的就是 `x86_64-linux-gnu`;而 +`parse("x86_64-pc-windows-msvc")` 会跳过 vendor 段并成功。所以原生 macOS / 原生 Windows +**本来就被拦住**,不存在那个洞。**判据:结构上「可能为空」不等于运行时真的为空 —— +要么读运行时产物(resolution.json),要么别把它写成实测。** + +放开之后,真正缺的东西各不相同,而且**都不是 flag 拼写**: + +| 格式 | 缺什么 | 现在 | +|---|---|---| +| PE | **导入库** —— 只写了 `.dll`,消费者直接链 `.dll`,mingw 的 ld 容忍、别人都不容忍 | 链接边把导入库声明成**隐式输出**;包里两个都带;`-static` 会让 ld 拒绝导入库(报 `have you installed the static version…`,既没点 DLL 也没点 `-static`),所以那条 `-l` 前先 `-Wl,-Bdynamic` | +| Mach-O | **install name** —— 只在声明了 `soname` 时才发;**放开 macOS 的那一刻**,每个没写 soname 的 `.dylib` 都会把构建目录烙进去 | 无条件 `@rpath/`;而且改成**按 target 判定**(原来是宿主的 `#if defined(__APPLE__)` —— 在旧的拒绝之下不可达,放开之后会发错) | +| PE/MSVC | **符号导出**(不是链接器 —— `link /DLL /IMPLIB:` 一直都在) | 仍然拒绝,但换成真理由,并点名 MinGW 这条路 | + +⚠️ **「能用的那种情况把坏掉的那种遮住了」是这一整块的形状。** +mingw 的 ld 容忍链 `.dll`,所以「PE 共享库是两个文件」这件事从来没被暴露。 + +**(b) `--target` 接受了这台机器产不出来的 target,并悄悄按宿主构建。** +实测(Linux): + +``` +$ mcpp build --target x86_64-windows-msvc + Resolved gcc@16.1.0 → x86_64-windows-msvc → …/xim-x-gcc/16.1.0/bin/g++ + Finished dev [unoptimized + debuginfo] in 0.07s +$ ls target/ + x86_64-linux-gnu/ ← 一个 ELF,被当成 Windows 构建交付 +``` + +**词表的 tier 说的是「mcpp 支持这个 target」,从来没说「这台机器能产出它」。** +`host_can_serve` 此前**只用于 `toolchain list` 的展示**,构建路径根本没问它。 +现在 `prepare.cppm` 在紧邻那条「typo 绝不能悄悄回落到宿主工具链(最坏的失败模式)」 +的检查之后问它,并把**这台宿主能构建的清单**列进错误里。逃生口保留: +显式 `[target.X] toolchain = "…"` 表示交叉链是作者自己提供的。 + +**(c) 消费一个 `shared` 的分发包直接失败**:`ninja: multiple rules generate +bin/libmathkit.dll` —— 依赖循环为一个**已经构建好**的库创建了 link unit。 +就算不撞名也是错的:分发包的 `sources` 只有它发布的接口,重链会静默产出一个 +**缺掉发布方保留的每个实现单元**的库。 + +### 13.5 覆盖矩阵(第二轮之后) + +| e2e | linux | macOS | windows | 备注 | +|---|---|---|---|---| +| 242/243/244/246/247/249/250/251/253/254 | ✅ | ✅ | ✅ | 无能力门 | +| 245 胖包(gnu+musl) | ✅ | 结构性不可能 | — | `# requires: gcc musl`(补上了 musl) | +| 256 胖包(msvc+mingw) | — | 结构性不可能 | ✅ | `# requires: msvc mingw`;job 里装 `xim:mingw-gcc` | +| 248 跨 OS 胖包(PE 腿) | ✅ | — | — | mingw job | +| 255 `lib.exe /REMOVE:` | — | — | ✅ | `# requires: msvc` | +| 257 PE 共享库(产出+打包+跑) | ✅ wine | — | — | `# requires: mingw-cross wine` | +| 258 MSVC 拒绝 `shared` | — | — | ✅ | `# requires: msvc`;在 Linux 上**看不到**(target 门先答) | +| 259 Mach-O 共享库可重定位 | — | ✅ | — | `# requires: macos`;**先删掉发布方的构建树**再消费 | + +**macOS 那两格是产品事实不是测试缺口**:它只能服务一个 target。 + +### 13.6 第二轮仍然开着的 + +| # | 项 | 说明 | +|---|---|---| +| **P1** | 打包库**不能被原生 `cl.exe` 消费** | 生成的 manifest 用 `-L`/`-l`(GNU 拼写)。**改成写文件路径也不行**:ninja 的链接命令 cwd 是输出目录,只有 include 家族前缀会被相对包根绝对化,无前缀 token 会去错地方找(`ld: cannot find lib/…/libmathkit.a`),而绝对路径就不再可重定位。**实测**:旧版 mcpp 读到 `[target.'cfg(…)'.runtime]` **不报错、静默忽略** ⇒ 把某条腿的链接 flag 挪到方言中立通道,会让**旧客户端一个 flag 都拿不到**。所以补齐它要么**两种拼写都带**(新客户端把库链两遍),要么给这类包**版本下限** —— 不是工作量问题,是与「零新键 + 老客户端可用」这两条约束直接冲突 | +| **P2** | MSVC 的 `kind = "shared"` | 要生成 `.def`(对对象做符号扫描)—— 那是一个构建图节点,不是 flag | +| **P3** | 扫描器改动的 revert 粒度 | O1 的代价,按裁决接受 | diff --git a/.agents/docs/2026-08-17-windows-three-axes-final-report.md b/.agents/docs/2026-08-17-windows-three-axes-final-report.md new file mode 100644 index 00000000..acac27f6 --- /dev/null +++ b/.agents/docs/2026-08-17-windows-three-axes-final-report.md @@ -0,0 +1,288 @@ +# Windows 三条轴:落地报告(mcpp 2026.8.17.1) + +> 设计:`2026-08-16-windows-toolchain-three-axes-design.md`(§6.5 记录逐条状态) +> 实现:mcpp #448(单 PR,含设计文档本身) +> 前一轮:`2026-08-16-msvc-ecosystem-final-report.md` + +--- + +## 0. 一句话 + +上一轮给**编译器**装上了版本轴;这一轮把它编译时用的**头文件**和产物加载的 +**运行时**也各自变成了**声明出来、解析一次**的值 —— 并且顺手让 `mcpp pack` +不再需要**运行产物**才能知道产物依赖什么。 + +--- + +## 1. 三条轴,分别落在哪 + +| 轴 | 之前 | 现在 | +|---|---|---| +| **来源** 编译器哪来的 | ✅ 已建模,代价摊在 ~30 处分支 | 解析一次(`Origin`),三处重复各消掉一份 | +| **SDK** 用哪套头/导入库 | ❌ **靠搜**,两种来源同一条链 | **按来源绑定**;受管 toolset 忽略环境并说出来 | +| **运行时分发** 产物带不带 vcruntime | ❌ 对 MSVC **一律拒绝** | PE 上 `toolchain-coupled` 真正成立 | +| **打包**(轴三的下游) | ❌ Windows 硬拒绝;跨不了 OS/架构 | 静态读导入表,任何宿主都能给 PE 打 zip | + +### §2 SDK:绑定而不是搜索 + +`find_windows_sdk()` 过去按 `WindowsSdkDir` → 兄弟 store → 常规路径扫,**两种来源 +走同一条链**。于是被 pin 住的 `msvc@` 是一个**环境可以悄悄改写**的 pin, +两台机器可以用两套 SDK 编同一份 manifest,而日志里没有一行提到 SDK。这不是假设: +这一轮之前追的那个 LNK1104,就是一个只解包了一半的 payload 因为版本号更高赢了这场 +扫描。 + +``` +msvc@ → 随该 toolset 装进 store 的 windows-sdk payload。 + WindowsSdkDir / WindowsSdkVersion 被忽略 —— 并且打印 note。 +msvc@system → 维持今天的搜索链。机器上的东西只能靠找,而在那里 + "明确声明"应当压过"扫描"。 +``` + +旁边没有 SDK payload 的受管 toolset **仍然**退回机器的 SDK —— 能用 > 失败 —— +并且**说出来**,因为那次构建已经不可复现,而除此之外没有东西会记录它。 + +### §2.3 `ucrt@`:填一个从字段诞生起就留着的槽 + +`RuntimeBinding::runtimeId` 的注释从一开始就写着 +*"(`glibc@…`, `macos_sdk@…`, `ucrt@…`)"*,而仓库里从来没有一处写过 `ucrt@`。 +于是 SDK 版本进不了 `runtimeContractHash`,**两套 SDK 共用一个构建缓存键**。 + +**它和 `glibc@` 不同构,而且这一点写在会被读到的地方**: + +| | `glibc@2.39` | `ucrt@10.0.26100.0` | +|---|---|---| +| 绑的是 | 一个 **payload**,头和 `.so` 都在里面 | — | +| 能真绑上去吗 | ✅ patchelf 让产物真跑在那份上 | ❌ `ucrtbase.dll` 是 OS 组件,换不掉也不该发 | +| 于是标识的含义 | **运行时绑定** | **兼容性下限声明** | + +所以它**不**投影进 `libc` —— 否则私有 libc 那套机制会去找一个本就不该存在的 +payload。各处 `starts_with("glibc@")` 改成 `runtime_provider()` 分派:另一个 +provider 读起来是"这里没有规则",而不是"没有身份"。 + +**§2.4(manifest 的 SDK 版本键)明确不做** —— 它会和 `_WIN32_WINNT` 并排,看起来 +可以互相替代,实际管的是不同的事。 + +### §3.3 PE 上的 `toolchain-coupled` 现在有意义 + +原来的拒绝语是 MSVC 运行时"随 OS/redistributable 分发,而不是随工具链"。 +对 `ucrtbase.dll` 是对的;对 `vcruntime140.dll` / `msvcp140.dll` 是**错的** —— +它们躺在每个 toolset 的 `VC\Redist\MSVC\…` 里,正是 gcc 和 `libstdc++.so` 的关系。 + +于是它拿同一个契约。PE 没有 rpath,所以**机制**是拷到产物旁而不是加一条搜索路径 —— +同一个契约,不同的机制,这正是三层模型存在的理由。 + +`/MT` 仍然是降级,而且是**真正的矛盾**:静态 CRT 根本没有 DLL 可以耦合,消息会说清 +是哪一边赢了。DLL 集合来自 `vc_redist_dir()` —— 排除 `debug_nonredist\`(不可再分发) +的**唯一**判据。在这里再写一条按名字的规则,可能和它不一致,而在**这件事**上不一致 +是许可问题,不是 bug。 + +### §4 打包:读导入表,别运行产物 + +`mcpp pack` 用 `#if defined(_WIN32)` 拒绝 Windows,理由写的是工具是 POSIX-only。 +那是症状。闭包来自 + +``` +LD_TRACE_LOADED_OBJECTS=1 '' +``` + +—— 它**要把产物跑起来**,所以既跨不了 OS,也跨不了**架构**。移植 `tar` 没有用,而 +2026-05 那份设计提的每一个工具(dumpbin、`ImageNtHeader`、`Compress-Archive`)都会 +把障碍**下移一层**,因为它们都只存在于问题已经消失的那个平台上。 + +- `mcpp.pack.binfmt` —— ELF `DT_NEEDED`(经段表翻译);PE 导入表**和延迟导入表** + (少一个延迟导入不会在启动时失败,而是在第一次调用它时失败,更难查) +- `mcpp.pack.zip` —— mcpp 自己写压缩包,理由相同:没有哪个 zip 工具在每个宿主上都 + 存在。条目 **stored** 不压缩,这是真实代价和诚实的取舍:DEFLATE 编码器是这里唯一 + 可能产出**解出来是错的**而不是响亮失败的部分,而 mcpp 没有 zlib 可借。**确定性**: + 不读任何时间戳,所以公布校验和才有意义 +- **§4.3 契约终于到达打包这一步**:以前它止步于编译/链接旗标,决定"哪些文件真的跟着 + 走"的那一步看不见承诺过什么 —— ELF 上 `ldd` 闭包**碰巧**一致,PE 上没有任何东西一致 + +**明确没做的一半,连同代价**:ELF 闭包**仍然**运行产物。`ldd` 交回的是**已解析的 +路径**,`DT_NEEDED` 只有名字,把名字变成路径要重新实现 loader 的搜索顺序 +(`$ORIGIN`、`DT_RPATH` → `LD_LIBRARY_PATH` → `DT_RUNPATH` → `ld.so.cache`、hwcaps)。 +在一条**已经正确、有 e2e 覆盖**的路径上重写它,风险大于收益 —— 于是**跨架构的 ELF +打包仍然不支持**,这正是 §4.1 指出的第二个限制。 + +### §1 收拢来源轴,而不是推广它 + +- **`gcc@system` / `llvm@system` 在被读到的地方就拒绝**,并同时给出两种可能的本意。 + 它们过去能解析,然后在别处以 `xim:gcc@system` → "no such package" 失败,把读者 + 引向一个根本不会存在的版本。`msvc@system` 是对**一个平台**的让步,不是别的族缺失 + 的能力;不带族的 `system` 逃生口原样保留 +- **spec 过去在相隔十几行的地方被解析了两遍**,各自下结论。现在一次,`origin_of()` 分派 +- `resolve_managed_msvc()` 取代两份手写的"受管 toolset 在哪、为什么不能用 fetcher 的 + `root`" —— 而理由只写在其中一份里 +- `needs_linux_sysroot_payloads()` 取代同一条规则的两种拼写,其中一份的注释声称它们 + 互为镜像。**它们不是** —— 少了 PE 那一项。今天不可达,这正是它活下来的原因 +- 工具链解析顺序**被写了两遍**,一处说 3 步一处说 4 步,合起来点到 9 个真实输入里的 + 5 个,还互相矛盾。现在一张表,按 `TcOrigin` 的枚举名写,不会悄悄失配 + +--- + +## 2. 验证 + +### 2.1 单元测试:31 个新增,两个**故意不能被"CI 绿了"满足** + +- `WindowsSdkDirCannotOverrideAPinnedToolsetsSdk` —— 设计 §6 的验收判据写成单元测试。 + 把 `WindowsSdkDir` 指到别处,payload 的 SDK 必须仍然赢,**并且** note 必须说出变量 + 被忽略了。一个被**静默**忽略的覆盖,和一个从未设置过的覆盖无法区分 +- `NothingElseAsksForStagedRuntimeFiles` —— 那个 deploy 标志会到达一个拷贝步骤,所以 + 一个多余的 `true` 会在一个根本没有这种东西的平台上往产物目录里放 DLL。扫过 + (format × stdlib × contract × /MT × explicit) 的每一格 + +### 2.2 e2e 240:**在 Linux 上**给 PE 打包 + +放在 mingw-cross job 里,因为**在 Windows 上跑它什么也证明不了**。它构建一个真正的 +交叉 PE,放一个该 EXE 会导入的 DLL 的替身,打包,然后由 **Python** 独立验证压缩包: + +- `msvcrt.dll` 在 —— **正面**那一半。一个什么都没读到的解析器**产不出**它 +- `kernel32.dll` 不在 —— **反面**那一半。单独看,一个什么都没读到的解析器也能通过; + 两条合起来才是决定性的 +- `--mode system` 不放任何 DLL;`toolchain-coupled` + `--mode system` 被拒绝,消息里 + 同时有契约名和出路 + +### 2.2b e2e 241:`ucrt@` 身份**真的到达了一次真实构建** + +单元测试钉住的是哈希函数(两个 SDK 版本 → 两个哈希)。它看不见的是:这个值在 +**真实的 Windows 构建**上到底有没有被填进去 —— 而 Windows CI 绿了并不能区分 +"身份填好了"和"代码路径跑了但什么都没产出":一个空字符串会毫无怨言地流过其中 +每一个 job。 + +所以 241 读 `resolution.json`,断言**值**本身:`runtime_id` 以 `ucrt@` 开头、 +`contract_hash` 非空(一个不参与任何事的身份就是装饰)、并且它**没有**被投影进 +`libc`(那个字段指的是私有 libc **payload**,ucrt 没有对应物)。 + +它显式 pin 了 `msvc@system`:Windows 的默认工具链是 clang targeting MSVC ABI, +那条路径上 SDK 是 clang 自己找的、mcpp 确实无可声明 —— 用默认工具链写这条测试, +会断言一个空身份然后**因为错误的理由通过**。 + +### 2.3 本机真实验证 + +在这台 Linux 上,`mcpp pack --target x86_64-windows-gnu` 产出的 zip 被 +`python3 -m zipfile` 和 `unzip -t` 双双接受,内含 exe 和解析出来的 DLL,没有别的。 +`--format dir`、`--mode self-contained`、`--mode static --target …` 逐一验过。 + +### 2.4 CI + +mcpp #448:**19 项全绿**(Linux / macOS ARM64 / Windows × build+unit+e2e+toolchains、 +hermetic 容器、四条 cross-build、xlings 集成)。 + +### 2.5 生态真实验证:**发布出去的那个二进制**,在沙盒里 + +不是本地构建产物,而是 `xlings install mcpp@2026.8.17.1` 从索引装下来的那一个; +不是这台开发机,而是 `xlings subos new` 出来的**全新 SubOS**,并且每一条都跑在 +`xlings subos use --sandbox --cmd "…"` 里面。 + +| 验证 | 结果 | +|---|---| +| 从索引安装 | ✓ `xim:mcpp@2026.8.17.1` | +| `new` → `build` → `run` | ✓ `Hello from ecoproj!`(import std + C++23) | +| `mcpp test` | ✓ 1 passed | +| `mcpp pack`(ELF,未改动的路径) | ✓ `ecoproj-0.1.0-x86_64-linux-gnu.tar.gz` | +| **`mcpp pack --target x86_64-windows-gnu`** | ✓ 在这个 **Linux 沙盒**里产出 `ecowin-0.1.0-x86_64-w64-mingw32.zip`,`zipfile.testzip()` 通过 | +| `mcpp self doctor` | ✓ **all checks passed** | +| `gcc@system` | ✓ 被拒绝,消息里同时给出 pin 与 PATH 逃生口两种写法 | +| 裸 `system` 逃生口 | ✓ 仍解析到宿主 gcc 13.3.0,然后因为**不相干且正确**的理由失败(那个 gcc 没有 `import std`)—— 这正是"没有被破坏"的样子 | + +**发布链路**同样逐段核过:四平台产物齐全 → 镜像 `all assets mirrored + verified on +2 host(s) in 488s` → 用**真实 GET**(不是 `curl -I`,它在 gitcode 上会骗人)复核四个 +归档与上游**逐字节同尺寸** → 索引 PR openxlings/xim-pkgindex#643 合入 → +`xlings update` 后可安装。 + +> 上一轮预留的「本地 `gtc` 补 gitcode 资源」这次**没有用到**:镜像那一条腿自己过了。 +> 授权仍在,只是没有需要修的东西。 + +--- + +## 3. 路上撞到的四件事(都不在计划里) + +### 3.1 clang 在**两个目标上同时**出问题 + +同一份新代码,gcc 到处都绿,而 clang: + +| | 现象 | +|---|---| +| Windows | clang 20.1.7(MSVC ABI)**编译** mcpp.pack 时段错误,0xC0000005,无诊断,五个 job 同时红 | +| macOS ARM64 | `test_pack_binfmt` 在**运行**时 SIGSEGV | + +**解析器不是问题,而且这是量出来的**:同一份代码在 clang 22.1.8 + libc++ 下 +ASan+UBSan 干净,在 x86_64 Linux 上**作为 clang 模块**编译并运行正确(-O0/-O2)。 + +处理方式沿用本仓库已有的判例(`hostflags.cppm` 的开头注释:往一个模块的匿名命名空间里 +加一个**没被使用**的函数,会让**相邻**函数被误编译;结论是"机制未知,复现稳定,便宜的 +反应是别往那个命名空间里加东西")。于是移除形状、保留行为:跨模块的作用域枚举做导出 +结构体的默认成员、ranges 投影取导入类型的成员、跨模块边界的 `std::span`、模块 purview +里的函数模板 —— 全部换成更简单的等价写法。**哪一个是原因并未确定,注释里就是这么写的**, +而不是编造一个结论。 + +macOS 那一半后来被**拆分测试**定位到了:崩在 `ThePeFixtureItselfIsWellFormed` —— +**完全不碰任何模块**的测试夹具代码。把夹具改写成最朴素的形式(不用 span 参数、不用捕获 +可变字符串的 lambda)之后消失。夹具现在带一个 `MCPP_TEST_TRACE=1` 才开的分步 trace: +如果它再来,一轮 CI 就能定位,而不是这次的四轮。 + +### 3.2 自审出来的一个崩溃(在已合入、已全绿之后) + +合入之后重读 `binfmt.cppm`,发现 `identify()` —— 注释写着"never throws" —— +在一种输入上会**终止进程**: + +```cpp +if (b.substr(*lfanew, 4) == "PE\0\0") // *lfanew 直接读自文件 +``` + +`std::string_view::substr` 在 `pos > size()` 时抛 `std::out_of_range`。 + +**触发面要说准,不能夸大**:`identify()` 拿到的不是任意文件,而是**链接产物**, +所以现实中的触发路径是一次**被截断/写坏的链接输出**(磁盘满、链接被 kill), +而不是"用户随便丢了个文件进来"。这一条的分量不在于它多常见,而在于:这个模块 +写明了自己"对垃圾输入是全函数",而它不是 —— 一个自称从不抛异常的函数,在一种 +输入上终止了进程。 + +不是推理出来的,是量出来的:`std::string_view{256 字节}.substr(0xFFFFFFFF, 4)` +在 libc++ 下抛 `string_view::substr`。 + +模块里其它每一处读取都走了带边界检查的访问器,只有这两处比较是例外 —— 一个自称 +"对垃圾输入是全函数"的模块,就是这样不再是的。已改为 `has_at()`,并补了回归测试。 +原来的截断测试为什么没抓到:它构造的 `e_lfanew` 只落在**正好等于**文件末尾的位置, +那里 `substr` 是良定义的、返回空。 + +### 3.3 一处文档自相矛盾 + +`docs/03-toolchains.md` 的 MinGW 段说 `[build] linkage` 这个键不存在、会被静默忽略; +两百行之后的 MSVC 段**恰好**把它当成选 `/MT` 的写法展示。是照着文档写了一遍、看着 mcpp +打印 `unsupported key 'linkage' (ignored)` 才发现的 —— 而跟着这一页做的用户,得到的也是 +这个,只是没人告诉他为什么什么都没变。两个语种都已修正。 + +### 3.4 `--mode static` 会覆盖用户点名的 target + +强制 musl 的那次重新 prepare 忽略了 `opts.targetTriple`,于是 +`--mode static --target x86_64-windows-gnu` 悄悄变成一次 **Linux** 构建。在 PE 打包 +还不存在时这是看不见的 —— 根本没有 Windows 包可以让人发现它缺了 —— 而现在它是个错误答案。 + +--- + +## 4. 留下的账(都写清楚了,没有藏) + +| # | 事项 | 为什么这次不做 | +|---|---|---| +| 1 | **macOS 上的 `mcpp pack` 会执行用户的产物** | `ldd_parse` 在 macOS 上等于直接跑二进制(`LD_TRACE_LOADED_OBJECTS` 在那里不是环境变量)。两个 pack e2e 都 `requires: pack patchelf elf`,即 **macOS 上完全没有覆盖**,改一条没有测试的路径无法验证。`binfmt` 已经识别 Mach-O,补 `LC_LOAD_DYLIB` 读取是自然的下一步 | +| 2 | **跨架构 ELF 打包**仍不支持 | 见 §4「明确没做的一半」 | +| 3 | `[pack] include` / `exclude` 被解析、存进 Plan,**从未被消费** | 早于本轮;文档把它们当作已有功能。属于 pack 的另一条轴,不在三条轴的范围内 | +| 4 | Windows CI 仍用 `7z a -tzip` 手工打包 release | 那是一个自带 `registry/` 的定制布局,不是 pack 的输出。现在 `mcpp pack` 能产 zip 了,迁移是可能的,但那是发布流程的改动 | +| 5 | **`mcpp new` + `mcpp add compat.gtest@1.15.2` 开箱即坏** —— 脚手架的 `test_smoke.cpp` 自带 `main()`,而 gtest 依赖会把 `gtest_main.o` 链进每个测试目标,`multiple definition of 'main'`。生态验证时撞到的 | **先于本轮**,已用 2026.8.16.3 在沙盒里复现过同样的失败,所以不是本次回归。它属于依赖/测试目标的链接策略,和这三条轴无关;放在这里是因为它是一条**开箱即坏**的路径,值得单独一轮 | +| 6 | **release 的 `SHA256SUMS` 只覆盖 4 个平台里的 1 个** —— 里面只有 `linux-x86_64` 的两行(带版本名 + 无版本别名),aarch64 / macOS / Windows 都不在。核对发布产物时撞到的 | **先于本轮**:v2026.8.16.3 与 v2026.8.15.1 同样如此。机制很清楚 —— `build-release`(linux x86_64)最先跑并写下这个文件,另外三个平台各自上传自己的 `.sha256` 边车,但没有人往 `SHA256SUMS` 里追加。**能校验**(边车在),只是那个名字承诺了它没做到的事。属于发布流水线,不属于这三条轴 | +| 7 | Windows 宿主 → Linux target 时 `dist::Format` 仍解析为 PE | 早于本轮。本轮的 triple 判据**只新增答案**(说不出 OS 的 triple 走原推导),刻意没有动这一条:它会改变一个正在通过的 CI job 的旗标,而对这三条轴没有好处 | + +--- + +## 5. 迁移(用户视角) + +| 改动 | 用户可见格式 | 迁移 | +|---|---|---| +| `ucrt@` 进 `runtimeContractHash` | ⚠️ 缓存键变 | **Windows 构建缓存重建一次**,和任何 contract 变更同类 | +| 受管 toolset 绑定 SDK | 行为,非格式 | pinned toolset 上 `WindowsSdkDir` 从"生效"变成"忽略 + 报告" | +| 拒绝 `gcc@system` | ⚠️ 之前"未实现",现在显式错误 | 消息给出两种替代写法 | +| PE `toolchain-coupled` / PE pack | 新增能力 | 之前是 degraded / 硬错误 | +| `--mode static --target ` | ⚠️ 之前静默变成 Linux 构建 | 现在按 target 走 | + +其余全部是内部改动。 diff --git a/.agents/docs/2026-08-18-freestanding-baremetal-analysis.md b/.agents/docs/2026-08-18-freestanding-baremetal-analysis.md new file mode 100644 index 00000000..44002f27 --- /dev/null +++ b/.agents/docs/2026-08-18-freestanding-baremetal-analysis.md @@ -0,0 +1,1177 @@ +# C++ 裸机 / freestanding:深度调研与 mcpp 路线分析(2026-08-18) + +- Date: 2026-08-18 +- Status: **调研报告 + 路线分析,待 review**(不是实施方案,不含工期) +- 起因: [issue #403](https://github.com/mcpp-community/mcpp/issues/403) — RFC:mcpp 对裸机/freestanding target 的支持(riscv64-none-elf 类,toy_kernel 用例)。issue 已 CLOSED,维护者标注「不期望近期实现,供后续设计参考」。 +- 关联决策: `.agents/docs/2026-07-24-embedded-platform-support-design.md` **决策 #15**(裸机 / freestanding-modules / ESP32:出范围 / 推迟)。本文重新打开该决策所依据的两条前提,并用实测检验它们。 +- 关联: #276(hosted 嵌入式 Linux SDK,另一条线) +- 结构:**实测证据(前置)→ 生态对照 → 标准现状 → 问题分解 → 路线 → 设计侧 → 使用侧 → 陷阱 → 待决策点** +- **⇒ 方案已独立成文:`.agents/docs/2026-08-19-freestanding-baremetal-design.md`**(五层架构、契约层「声明/证据/兜底」、使用侧最小案例、分期与验收判据、D1–D16 回执)。**本文是发现,那份是方案。** + +--- + +## 0. TL;DR + +七条结论,每条都有第 1 节的实测编号背书。 + +1. **mcpp 今天对裸机不是「不支持」,是「静默构建成宿主二进制并报告成功」。**(E1) + `mcpp build --target riscv64-none-elf` 配合错误信息自己推荐的逃生舱 `[target.riscv64-none-elf]`,输出 `Finished dev … in 0.04s`,产物是 `target/x86_64-linux-gnu/…/toy_kernel`,一个 **x86-64 glibc 动态 PIE**。生成的 `build.ninja` 里 `--target` 出现 **0 次**。**这条与路线选择无关,任何路线下都必须先修。** + +2. **裸机能力在 mcpp 现有载荷里已经物理存在。**(E2) + 用 mcpp 自带的 `llvm@22.1.8`(clang + `ld.lld` + `llvm-objcopy`),**零新增工具链**,从一个 **C++20 模块接口单元**编出并链成了完整的 RISC-V 64 裸机内核 ELF + 136 字节裸镜像,`llvm-nm -u` 无未定义符号。缺的不是编译器,**缺的全部是 mcpp 的 target / 链接 / 运行模型**。 + +3. **唯一真实的载荷缺口很小:compiler-rt builtins 只有 x86_64。**(E3) + `lib/clang/22/lib/` 下只有 `x86_64-unknown-linux-gnu` 一个目录。⚠️ 而且这个缺口**在 hello-kernel 上看不见**——我们的测试内核 0 个未定义符号,只有做 64 位除法/软浮点的真实内核才会在链接期炸。这是一个天然的假绿点。 + +4. **`import std` 在裸机上不可用,而且是结构性的,不是配置问题。**(E4) + GCC 16.1 的 `bits/std.cc`(4502 行)里 `HOSTED|freestanding` 命中 **0 次**,开头就是 `#include ` 外加 ``、``;libc++ 的 `std.cppm`(280 行)只有 **1** 处配置守卫。WG21 侧 SG14 在 2025-05 才在讨论「`std.freestanding` 值不值得做」(P3693R0),没有标准。**结论:mcpp 只能明确关断 + 给诊断,不能「降级」。** + +5. **但「有 std 子集可用」不只是近,是已经跑通了。**(E5 / X1–X10) + 接上真实 picolibc 的头 + 一份 freestanding `__config_site`,libc++ 的 **110 个 `std/*.inc` 对应头里 103 个可编**;⚠️ 做了宿主对照组后确认:**失败的 7 个在 x86_64 宿主上同样失败**(libc++ 尚未实现的 ``/``/`` 等)——**freestanding 这一层的编译期损失是 0**。 + 进一步:我据此拼出 `mcpplibs.std.freestanding` 模块并**真的把它链进了裸机内核**——`import` 之后用 `std::array` / `std::ranges::sort`(带投影)/ `std::optional` / `std::atomic` / `std::span` / `std::string_view`,**零未定义符号,text 12435 字节**。`std::thread` / `std::mutex` 在这套配置下**干净地不存在**(编译期报错,不是静默桩)。 + +6. **子集包不用手写导出表,libc++ 已经把它拆好了。**(X3) + `std.cppm` 的实体是 **110 个 `share/libc++/v1/std/
.inc`**,每个就是那个头的 `export namespace std { using std::X; }`。子集模块 = **机械挑选 `.inc`**,导出表由上游维护。`mcpplibs.std.freestanding` 的源文件只有 **208 行**,而且是生成的。 + +7. **正确的架构切分是「目标规格是数据,板级支持是包,std 子集也是包」。** + `-march=rv64gc -mabi=lp64d -mcmodel=medany` 是**目标属性**——必须对每个 TU 和每个依赖完全一致,否则 ABI 撕裂。issue #403 里把它们塞进 `[build].cxxflags` 之所以是错的,不只是「宿主解析错」,而是**依赖拿不到它们**。这正是 Rust 用 target-spec JSON 而不是 `Cargo.toml` flags 的原因。而链接脚本 + 启动代码 + 运行命令属于**板子**,应该是索引里的一个普通包(对标 `riscv-rt` / `cortex-m-rt`),不是引擎里的板子数据库。 + +8. **推荐路线 = B(最小可落地缝),方向留给 C;而 C 里最贵的那块(std 子集)已被 X 系列证明是小的。** + A=明确不做、B=开 target/链接/runner 三条缝 + BSP 走包、C=全栈裸机发行版。**无论选哪条,第 1 条的静默失败都必须修**——那是当下唯一的「已发布能力可达的错误行为」。 + +--- + +## 1. 本机实测:证据先行 + +环境:Linux x86_64 / mcpp 二进制 `2026.8.15.3`(仓库 HEAD 为 `f0de3ef`)/ 载荷 `llvm@22.1.8`、`gcc@16.1.0` / 宿主另有 Ubuntu `gcc-riscv64-unknown-elf 13.2.0`。复现命令见附录。 + +### 1.1 E1 ⚠️ 今天的行为不是拒绝,是静默错构 + +```console +$ mcpp build --target riscv64-none-elf +error: unknown target 'riscv64-none-elf' + known targets: `mcpp toolchain list`; a custom triple needs an + explicit [target.riscv64-none-elf] section in mcpp.toml +``` + +按这条错误信息**自己的指引**加上逃生舱: + +```toml +[target.riscv64-none-elf] +toolchain = "llvm@22.1.8" +``` + +```console +$ mcpp build --target riscv64-none-elf + Resolving toolchain + Resolved llvm@22.1.8 → …/xim-x-llvm/22.1.8/bin/clang++ + Finished dev [unoptimized + debuginfo] in 0.04s + +$ file target/x86_64-linux-gnu/*/bin/toy_kernel +… ELF 64-bit LSB pie executable, x86-64, …, dynamically linked, + interpreter …/xim-x-glibc/2.44/lib64/ld-linux-x86-64.so.2 … + +$ grep -c -- --target target/x86_64-linux-gnu/*/build.ninja +0 +``` + +**机制**(已核到 file:line,不是推断): + +| 位置 | 行为 | +|---|---| +| `src/toolchain/triple.cppm:299` | `if (k == "unknown" \|\| k == "pc" \|\| k == "w64" \|\| k == "none") continue;` —— **`none` 被当作 vendor 段跳过**,与裸机 OS 段直接撞名 | +| `src/toolchain/triple.cppm:320` | 剩下的 `elf` 段不认识 → `parse()` 返回 `nullopt`,即「这根本不是一个 triple」 | +| `src/build/prepare.cppm:1264` | `if (!known && !hasExplicitSection)` 才报错 —— **有 `[target.X]` 段就放行** | +| `src/build/prepare.cppm:1301` | `host_can_serve` 守卫的条件是 `known && …` —— **不认识的 triple 走不到这条守卫** | +| `src/build/prepare.cppm:1458-1462` | 代码注释直书:「Escape-hatch triples outside the language don't parse and **leave the spec on the host target**」 | + +⚠️ 最值得注意的是:**同一份文件在 1281–1296 行把这个结果称为「最坏的失败模式」**并为此专门加了 `host_can_serve` 守卫(注释里还留了 `--target x86_64-windows-msvc` 产出 ELF 的实测记录)。**守卫加在了已知 target 那条路径上,而逃生舱把同一扇门重新打开了。** 这与 `.agents/docs/2026-08-08` 那批「修补放在控制流到不了的地方」是同一形状。 + +对使用者而言这比 issue #403 描述的还糟:#403 报的是 flags 被按宿主解析后**硬失败**(至少有红);逃生舱路径是**全绿 + 错产物 + 零诊断**。 + +### 1.2 E2 现有载荷已经能产出完整裸机内核 + +只用 `xim-x-llvm/22.1.8` 里的 `clang++` / `clang` / `ld.lld` / `llvm-objcopy`,**不装任何交叉工具链**: + +``` +kmain.cppm (export module kmain; export extern "C" void kmain()) +start.S (_start: la sp, stack_top; call kmain; wfi) +link.ld (. = 0x80200000; .text/.rodata/.data/.bss) +``` + +```console +$ clang++ --no-default-config --target=riscv64-none-elf -march=rv64gc -mabi=lp64d \ + -mcmodel=medany -ffreestanding -fno-exceptions -fno-rtti -nostdinc++ \ + -std=c++23 --precompile kmain.cppm -o kmain.pcm # OK +$ clang++ … -c kmain.pcm -o kmain.o # OK +$ ld.lld -T link.ld start.o kmain.o -o kernel.elf # LINK OK + +$ file kernel.elf +kernel.elf: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, + statically linked, not stripped +$ llvm-objcopy -O binary kernel.elf kernel.bin && ls -l kernel.bin # 136 bytes +$ llvm-nm -u kernel.elf # (空:无未定义符号) +``` + +三条推论: + +- **C++20 模块在 freestanding 下完全可用**——两阶段 `--precompile` → `-c` 对裸机 target 一次通过。模块是语言特性,与 libc 无关。 +- **clang 天然多目标**,不需要「一个 target 一个 payload」。这与 GCC 路线(单目标,每个 triple 一份交叉链)是根本不同的成本结构,值得在决策 #8(「先 GCC」)旁边记一笔:**裸机这条线上 clang 的优势是结构性的**。 +- **`ld.lld` + `llvm-objcopy` 已在载荷里**,链接与镜像生成不需要新工具。 + +### 1.3 E3 ⚠️ 唯一真实的载荷缺口:builtins 只有 x86_64 + +```console +$ ls …/xim-x-llvm/22.1.8/lib/clang/22/lib/ +x86_64-unknown-linux-gnu +``` + +`libclang_rt.builtins.a` 只有宿主一份。RISC-V 64 上一旦用到编译器内建运行时(64 位除法、软浮点、`__muldi3` 类),链接期会缺符号。 + +⚠️ **这个缺口在 hello-kernel 上不可见**:E2 的内核 `llvm-nm -u` 是空的,一路全绿。**若拿 hello-kernel 当 e2e,builtins 这条永远测不到**——与 `.agents/docs/2026-08-12` 记的 bench 假绿、以及 PR#451 「CI 全绿 ≠ 覆盖到了」同族。验收用例必须**故意**含一次 64 位除法或软浮点。 + +### 1.4 E4 `import std` 是结构性 hosted-only + +```console +$ wc -l …/gcc/16.1.0/include/c++/16.1.0/bits/std.cc +4502 +$ grep -c "HOSTED\|freestanding\|_GLIBCXX_HOSTED" …/bits/std.cc +0 +``` + +`std.cc` 第 26 行起:`#include `,随后**无条件**再拉 `` 和 ``。**整份文件没有任何 freestanding 分区**。 + +libc++ 侧同理:`share/libc++/v1/std.cppm` 共 280 行,配置守卫仅 `_LIBCPP_HAS_ATOMIC_HEADER` **1** 处。 + +⇒ 两个实现都把 `std` 模块做成了「全都要」的单体。**没有实现侧的开关可以让 `import std` 在裸机上变小。** + +标准侧也没有:见第 3 节。**mcpp 的正确姿势是在 `os=none` 上把 `import std` 判为不可用并给诊断**,而不是尝试降级——降级出来的「少一半的 std」会是比硬失败更坏的东西,而且会污染 BMI 缓存身份(参考 `2026-07-31` C++20 档位设计里「跨档位 BMI 硬拒」的先例)。 + +### 1.5 E5 头文件子集:失败全在 libc,不在 libc++ + +libc++ 的每目标配置文件是 `include//c++/v1/__config_site`,载荷里**只有** `x86_64-unknown-linux-gnu` 一份。直接对 `riscv64-none-elf` 编 ``: + +``` +…/__config:646:8: error: "No thread API" +``` + +⚠️ `-D_LIBCPP_HAS_THREADS=0` **不管用**(该宏来自 `__config_site`,不是命令行可覆盖的)。合成一份 freestanding `__config_site`(把 `THREADS / FILESYSTEM / LOCALIZATION / MONOTONIC_CLOCK` 四个 `#define` 翻成 0)后,共测 17 个头: + +| 结果 | 头 | +|---|---| +| **OK(8)** | `` `` `` `` `` `` `` `` | +| **FAIL(9)** | `` `` `` `` `` `` `` `` `` | + +失败信息全是两条:`__mbstate_t.h:51: "We don't know how to define mbstate_t"` 和 `string.h:98: unknown type name 'size_t'`。 + +**全部是 libc 缺失,没有一条是 libc++ 自身的 hosted 依赖。** 换言之:**接上 picolibc / newlib 的头文件,这个子集会立刻变宽**(推断,未实测——本机没装 picolibc,`picolibc-riscv64-unknown-elf` 在 Ubuntu universe 里有 1.8.6-2,但装它属于改用户环境,未执行)。 + +这条对路线的意义:**「裸机上有可用的 C++ 库」不是遥远目标,是一个载荷构建决策**(给目标编一份 `__config_site` + 一个 freestanding libc),不是引擎特性。 + +### 1.6 E6 ⚠️ 三个会咬人的环境级事实 + +1. **`ld.lld` 这个名字在本机解析到 GNU ld。** + ```console + $ readlink -f $(which ld.lld) + /home/speak/.xlings/bin/xlings + $ ld.lld --version + GNU ld (XPKG: xlings install fromsource:binutils) 2.42 + ``` + 后果:`ld.lld -T link.ld …` 报 `cannot represent machine 'riscv'`。**裸机链接必须按载荷路径寻址链接器,不能按名字。** 这与 `.agents/docs/2026-08-11` 的 `$ORIGIN` 优先级、以及「链接期用 A、运行期加载 B」是同一类地址-vs-名字问题。 + +2. **`-mcmodel=medany` 不是可选项,是 link-or-not。** + 漏掉它,链接直接失败: + ``` + ld.lld: error: relocation R_RISCV_HI20 out of range: 524800 is not in [-524288, 524287] + ``` + 载入地址 `0x80200000` 超出 medlow 可寻址范围。**这是「目标规格必须是数据」的最硬证据**:它既不是优化选项也不是用户偏好,它由板子的内存布局决定。 + +3. **libc++ 的 `__config_site` 是按 target 的文件**,不是宏。「加个 `-isystem` 就能用」是错的。 + +### 1.7 X1–X10 `mcpplibs.std.freestanding` 子集包:从假设到跑通 + +E5 只证明了「零 libc 下 8 个头可用」。这一轮把真实 picolibc(Ubuntu `picolibc-riscv64-unknown-elf` 1.8.6-2,`apt-get download` + `dpkg -x` 解到临时目录,**未装进系统**)接上,把整条链走完。 + +**X1 — libc++ 自己已经把 std 模块拆成可裁剪的零件。** +`share/libc++/v1/std.cppm` 的结构是:全局模块片段 `#include
` × N → `export module std;` → `#include "std/
.inc"` × **110**。每个 `.inc` 就是那个头的 `export namespace std { using std::X; }`(例:`std/span.inc` 导出 `dynamic_extent`、`span`、`ranges::enable_borrowed_range`)。 +⇒ **子集模块是机械裁剪,不需要手写也不需要维护导出表。** + +**X2/X3 — 接上 picolibc 头之后,编译期损失为零。** + +| 配置 | 可编头数 | +|---|---| +| 零 libc(E5) | 8 / 17 抽样 | +| **+ picolibc 头** | **17 / 17 抽样;103 / 110 全量** | + +⚠️ **对照组是这条结论的关键**:失败的 7 个(`generator` `hazard_pointer` `rcu` `spanstream` `stacktrace` `stdfloat` `text_encoding`)拿到 **x86_64 宿主 + 完整 libc++/glibc** 上重测,**7 个全部同样失败**——它们是 libc++ 尚未实现的头,与裸机无关。**没有对照组就会把「libc++ 没写」记成「裸机不行」。** + +**X4 — include 能过 ≠ 东西真在。** 用真实使用探针(不是 include 探针)复核: + +| 设施 | 结果 | +|---|---| +| `std::span` / `array` / `ranges::sort` / `optional` / `expected` / `atomic` / `string_view` / `charconv` / coroutine | **可用** | +| `std::vector` / `std::string`(需堆) | 编译可用(链接期另说,见 X9) | +| `std::mutex` / `std::thread` | **干净地不存在**(`no type named 'mutex' in namespace 'std'`)—— 不是静默桩,是编译期报错 | +| `std::cout` | 声明在但不可用 | + +`__config_site` 关掉 THREADS/FILESYSTEM/LOCALIZATION 的效果正是我们想要的:**不该有的东西直接消失,而不是留个跑起来才错的壳。** + +**X5/X6 — 包装模块本身成立。** 由 `ok.list` 生成的 `stdfs.cppm`(**208 行**,全部是 `#include`)对 `riscv64-none-elf` 编出 reduced BMI 26.5 MB + object 1.3 KB;消费者 `import mcpplibs.std.freestanding;` 后编译通过。 + +**X7–X10 — 链接期的真实边界(这才是可用性的分界线)。** + +第一次链接只缺 **2 个符号**,收敛速度远超预期: + +| 缺失符号 | 性质 | 解法 | +|---|---|---| +| `std::__libcpp_verbose_abort` | `-fno-exceptions` 下所有 `__throw_*` 的落点 | **用户/BSP 提供 ~3 行**(实测:一个 `for(;;) wfi` 即可) | +| `std::__sort<__less&, int*>` | libc++ 把标量类型的 `__sort` 做成了 **`extern template`**,实体在编译版 libc++ 里 | 见下 | +| `memmove`(去掉 `verbose_abort` 后暴露) | 编译器可自行发出的 libc 函数 | picolibc `libc.a` | +| `std::basic_string::__init/append`(用 `std::format` 时暴露) | `basic_string` 的 out-of-line 成员在编译版 libc++ 里 | 需目标版 `libc++.a` | + +⚠️ `__algorithm/sort.h:834-847` 的 `extern template` **无宏可关**(逐条核过,外层无守卫)。但它**只覆盖内建标量类型**——对自定义类型/带投影的 `ranges::sort` 完全走头文件。 + +**最终边界(X10 实测,零未定义符号):** + +``` +kernel4.elf: ELF 64-bit LSB executable, UCB RISC-V, RVC, statically linked + text data bss dec + 12435 16 4096 16547 +``` + +内核里真正跑的是:`import mcpplibs.std.freestanding;` + `std::array` + `std::ranges::sort(t, {}, &Task::prio)` + `std::optional` + `std::atomic` + `std::span` + `std::string_view`,链接输入 = `start.o` + `kmain.o` + `stdfs.o` + 3 行 runtime + picolibc `libc.a`。 + +⇒ **分界线是清楚的**: + +| 档位 | 需要什么 | 覆盖 | +|---|---|---| +| **T1 header-only** | picolibc 头 + `__config_site` + ~3 行 hook | array/span/ranges/algorithm(自定义类型)/optional/expected/atomic/string_view/tuple/bit/utility/concepts/type_traits/charconv/coroutine | +| **T2 + libc.a** | 上面 + picolibc `libc.a` | 编译器隐式发出的 `memcpy/memmove/memset/memcmp`;堆(需 BSP 给 `sbrk`) | +| **T3 + 目标版 libc++.a** | 上面 + 为目标编的 libc++ | `std::format`、标量 `std::sort`、`std::string` 全功能 | + +**T1+T2 已在本机完全跑通;只有 T3 需要新载荷。** + +**X11 ⚠️ picolibc 的发行版包覆盖不到最常见的 RISC-V profile。** +Ubuntu 包的 rv64 multilib 全集:`rv64i/lp64 rv64ia/lp64 rv64iac/lp64 rv64iaf/lp64f rv64iafd/lp64d rv64if/lp64f rv64ifd/lp64d rv64im/lp64 rv64imac/lp64 rv64imafc/lp64f rv64imf/lp64f`——**没有 `rv64imafdc/lp64d`(即 `rv64gc`)**。本轮实测因此退到 `rv64imac/lp64`。⇒ **载荷不能直接复用发行版包**,要么自建 multilib,要么采纳 LLVM Embedded Toolchain(§2.4)。 + +**X12 附带发现:qemu 退出码通路是现成的。** +picolibc 载荷里带 `crt0-semihost.o` 和 `libsemihost.a` ——`mcpp test` 在 qemu 下拿测试退出码不需要自己造 semihosting。 + +### 1.8 Y1–Y5 实现中立的适配层 + 机械验证(**修正 §1.7 的一条结论**) + +§1.7 结尾写了「这条包路线天然绑定 clang/libc++」。**实测证明这条不成立**,原因有两条,都比原判断重要。 + +**Y1/Y2 — libstdc++ 有真正的 freestanding 模式,而且比 libc++ 更诚实。** + +`bits/c++config.h:1638`:`#define _GLIBCXX_HOSTED __STDC_HOSTED__` —— libstdc++ 的 freestanding **由编译期开关驱动**(`-ffreestanding` 会把 `__STDC_HOSTED__` 置 0),44 个头对它敏感。不需要合成任何配置文件。 + +`g++ -std=c++23 -ffreestanding` 下的真实使用探针: + +| 可用 | ` /ranges::sort ` | +|---|---| +| **不可用** | ` ` —— 全部是 **`#error "This header is not available in freestanding mode."`** | + +⚠️ **这条对比是反直觉的,而且方向与 §1.7 相反**: + +| | libc++ | libstdc++ | +|---|---|---| +| freestanding 开关 | 每目标 `__config_site` **文件**(载荷构建期烙死) | **`-ffreestanding` 编译期开关**,零配置 | +| 不可用设施的表现 | 头**被守卫成近乎空文件** ⇒ 报错发生在「用它的时候」,信息离现场很远 | **点名 `#error`,当场** | +| 全量扫 110 头 | 103 "OK" | 53 OK | +| 但那个数字的含义 | ⚠️ **含约 50 个只是「能 parse」的空壳**(X4 已测 `std::mutex` 根本不存在) | **53 个全部诚实**,其余硬 `#error` | + +**libc++ 的 103 不是「更宽」,是「更含糊」。** 把 include 成功率当能力指标会得到完全错误的排序 —— 这正是陷阱 #10。 + +**Y3 — 一份源码,两个标准库,零 `#if`。** + +写一个实现中立的适配层(不 `#include "std/*.inc"`,而是自己写 `export namespace std { using std::span; … }`),同一个 `core.cppm`: + +| 编译配置 | 结果 | +|---|---| +| GCC 16.1 + libstdc++ + `-ffreestanding`(宿主 x86_64) | `core_gcc.o` ✅ | +| clang 22 + libc++ + freestanding `__config_site`(`riscv64-none-elf` + picolibc) | `core_clang.pcm` ✅ | + +**⇒ 适配层可行,因为导出的名字是标准规定的,不是任何一家的私产。** libc++ 的 `.inc` 只是一份**方便的名字清单**,不是依赖。 + +**Y4 — 可移植 freestanding 子集 = 52 个头(两家交集)。** + +``` +algorithm any array atomic bit cassert cctype cfenv cfloat chrono cinttypes clocale +compare concepts coroutine csetjmp csignal cstdarg cstddef cstdint cstdio cstdlib +cstring ctime cuchar cwchar cwctype exception expected functional initializer_list +iterator limits mdspan memory new numbers numeric optional ranges ratio +scoped_allocator source_location span string_view tuple typeindex typeinfo +type_traits utility variant version +``` + +⚠️ 但「交集」不是唯一正确答案,因为**两家其实在两个不同档位上**: + +| 档 | 环境 | 得到什么 | +|---|---|---| +| **T0 `freestanding-core`** | 无 libc / 无堆 | ≈ 上面 52 个头。libstdc++ `-ffreestanding` **零配置**即是;libc++ 需 `__config_site` | +| **T1 `freestanding+heap`** | + picolibc(含 malloc) | **额外拿到 `vector` / `string` / `format` / `charconv`**(X4/X9 实测,libc++ 侧) | +| **T2 `hosted`** | 现状 | 全部 | + +libc++ + picolibc 之所以「更宽」,不是 libc++ 更强,是 **picolibc 给了堆**。⇒ **档位是环境的函数,不是标准库实现的函数。** 这条直接决定了第 9 节的生态架构。 + +**Y5 — 「这个库需要什么环境」可以从产物机械判定,零元数据。** + +| 源码用了 | `llvm-nm -u` 里出现 | 判定 | +|---|---|---| +| `new` / `delete` | `_Znwm` `_ZdlPvm` | 需要 **heap** | +| `throw` / `catch` | `__cxa_throw` `__cxa_begin_catch` `__cxa_allocate_exception` `__cxa_end_catch` | 需要 **exceptions** | +| `dynamic_cast` | `__dynamic_cast` `_ZTI1B` | 需要 **RTTI** | +| 纯计算 | **(空)** | freestanding-clean | + +`llvm-nm` 已在载荷里。⇒ **不需要生态先标注,就能判定。** 这是第 9 节的技术地基。 + +### 1.9 T1–T3b 能力是可插拔的符号契约(**修正 §7.5 里一处分类错误**) + +§7.5 曾把 `std::thread` 归到 `hosted` 档。**实测证明这是错的** —— 线程和堆都是**可插拔能力**,不是 hosted 专有设施。 + +**T1/T2 — 两家标准库都把线程做成了可插拔层。** + +| 标准库 | 机制 | 证据 | +|---|---|---| +| **libstdc++** | **gthreads**:`gthr.h` / `gthr-default.h` / `gthr-posix.h` / **`gthr-single.h`** 四件齐全;`_GLIBCXX_HAS_GTHREADS` 构建期决定 | 载荷里四个头都在 | +| **libc++** | **`_LIBCPP_HAS_THREAD_API_EXTERNAL`**:线程原语由外部提供 | `__config:622` / `:662` 明确有这条分支 | + +⇒ FreeRTOS / Zephyr 接到任一侧,裸机上就有 `std::thread`。正确的声明是 `tier="core", requires=["threads"]`。 + +**T3b — `heap` 同理,而且是标准写好的扩展点。** + +BSP 侧一个 bump 分配器(替换 `operator new/delete` 重载集),**零 libc、零 malloc**: + +``` +$ ld.lld -T link.ld start.o usevec.o bsp_heap.o -e kmain -o heapdemo.elf +heapdemo.elf: ELF 64-bit LSB executable, UCB RISC-V, statically linked + text 5911 data 0 bss 12304 ← llvm-nm -u:空 +``` + +内核里跑的是 `std::vector` + `push_back` + `std::span` 求和。C++ 标准 [new.delete] **本来就规定 `operator new/delete` 可被用户替换** —— 这不是绕过标准,是标准的扩展点。 + +⚠️ 中途暴露的一条实用细节:漏掉 `operator new(size_t, align_val_t)` 时,lld 报的是 **`undefined symbol: operator new(unsigned long, std::align_val_t)`** —— 精确点名缺哪个重载。**能力契约是可枚举的符号集,不是模糊概念。** + +**⇒ 由此闭环:能力的「契约」和能力的「判据」是同一组符号。** + +``` +heap 契约 = operator new/delete 重载集 判据 = 产物有无 _Znwm / _ZdlPvm +exceptions 契约 = __cxa_throw + _Unwind_* 判据 = 产物有无 __cxa_throw +rtti 契约 = __dynamic_cast + typeinfo 判据 = 产物有无 __dynamic_cast +threads 契约 = __gthread_* / __external_* 判据 = 产物有无这些符号 +``` + +所以 Y5 的符号审计**不是启发式规则**,它检查的就是真正的 ABI 契约;裸机链接之所以是硬门,也是因为契约缺实现在 `-nostdlib` 下必然是未定义符号。 + +⚠️ **推论**:mcpp **不应该**自己定义一套能力接口 —— 契约由 C++ 标准([new.delete])、Itanium C++ ABI(`__cxa_*`)和各标准库(gthreads / `__external_threading`)**早已定义**,再造一套就是第三套 ABI,与两边都不兼容。唯一需要生态出力的是 `threads`:两家接口不同,一个 RTOS 想服务两边要写两个 shim —— **那是包的工作,不是引擎的**。 + +### 1.10 H1–H3 生态契约的可组合形态(非标准库能力) + +§1.9 覆盖的是**语言运行时**能力。裸机上还有一类**没有任何标准契约**的能力:UART / 定时器 / 存储 / 中断。这一轮量的是它们该长什么形状。 + +**⚠️ H1 — 形态 A(模块内声明、别的包定义)被语言禁止。** + +``` +消费者要: _ZN3halW8mcpplibsW3halW7console5writeEPKcm + → hal::write@mcpplibs.hal.console(char const*, unsigned long) +提供者给: _ZN3hal5writeEPKcm + → hal::write(char const*, unsigned long) +ld.lld: error: undefined symbol: hal::write@mcpplibs.hal.console(...) +``` + +**模块归属被烙进符号名** ⇒ 附着到模块的函数,定义必须来自同一个模块。**跨包提供实现在物理上不可能。** + +⇒ 这给 2026-07-24 决策 #5(C-ABI 是唯一稳定的二进制互操作契约)补了一条**更硬的理由**:在 C++20 模块世界里,跨包提供实现**只有 C ABI 一条路**,这不再只是 ABI 稳定性论证,而是语言层面的物理约束。 + +**H2 — 形态 B(`extern "C"` 缝)可行**:消费者要 `U mcpp_hal_console_write`,提供者在另一个包定义,链接零未定义符号。代价:单一全局符号 ⇒ **不能有两个提供者**。 + +**H3 — 形态 C(concept 静态注入)可行,且是唯一可组合的**: + +```cpp +// 契约包:纯接口,零实现 +template concept Console = requires(T& t, std::span s) { t.write(s); }; +template concept Clock = requires(T& t) { { t.ticks() } -> std::same_as; }; + +// 驱动包:只依赖 concept,不知道任何板子 +template void banner(C& c, K& k); + +// BSP 包:Uart 与 Uart2 同时存在 +``` + +实测:`banner(u, t)` 与 `banner(u2, t)` **两个提供者共存**,同一驱动各实例化一次(零成本静态派发);且同一个 BSP 的 `operator new` 让 `std::vector` 一起工作 —— **生态库与 std 同时点亮**。整体链接零未定义符号,text 9040 字节。 + +| 形态 | 跨包 | 多提供者共存 | 成本 | +|---|---|---|---| +| A 模块内声明 | ❌ 语言禁止 | — | — | +| B `extern "C"` | ✅ | ❌ | 链接期绑定,不可内联 | +| C concept 注入 | ✅ | ✅ | **零成本,可内联** | + +⇒ **生态 HAL 应以 C 为主**;B 只留给天然单例的运行时能力 —— ⚠️ 而那些恰好已被 §1.9 的标准扩展点(`operator new` / gthreads / `__libcpp_verbose_abort`)覆盖,**生态基本不需要自己发明 `extern "C"` 契约**。 + +### 1.11 K1–K3 KAL(内核/arch 抽象)的两条硬约束 + +针对「用 concept 表达 KAL,支持所有 CPU、不管有没有 MMU」这个方向做的探测。**两条结果都是限制性的。** + +**K1/K2 — ⚠️ concept 检查语法,不检查语义。** + +写一个 `kal::AddressSpace` concept(`map` / `translate`),两个实现: + +``` +RiscvSv39::map(0x8000_0000, 0x9000_0000) → true 真重映射 +NoMmu ::map(0x8000_0000, 0x9000_0000) → false 只能恒等映射 +``` + +**两者都完全满足同一个 concept,编译期无法区分。** 通用内核代码调用 `map()` 做非恒等重映射,在 NoMmu 上静默失败。 + +⇒ **MMU 的有无不是一个抽象能藏住的差异**:它上面的代码分成两族(有 demand paging / fork / 地址空间隔离 vs 没有),不是一个统一接口。**这是能力轴(`cfg(mmu)`),不是契约。** + +**K3 — ⚠️ 「零成本抽象」在跨模块边界上默认不成立。** + +同一段 `setup(arch, va)` 通用代码,三种构建方式: + +| 构建 | `RiscvSv39::disable()`(应为一条 `csrci`) | 判据 | +|---|---|---| +| **同 TU + `-O2`** | **完全内联**:`csrci` / `csrsi` + 3 条指令 | ✅ 真零成本 | +| **跨模块 + `-O2`** | **真实 `jal` 调用** `_ZN5archsW5archs9RiscvSv397disableEv` | ❌ 抽象有成本 | +| **跨模块 + `-flto`** | `kmain` 内 `jal` = **0**,内联进来的 csr 指令 = **4** | ✅ 恢复 | + +⇒ **内核热路径(irq 开关、per-cpu 访问、页表项操作)一旦跨包/跨模块,默认会退化成真实函数调用。** 对内核这是不可接受的 —— `local_irq_disable()` 本该是一条指令。 + +**⇒ KAL 的构建模型必须把 LTO 当作必需项,不是优化项。** 而 LTO 在内核上有自己的代价(链接脚本 section 放置、inline asm 约束、编译期),这条必须提前进设计,不能等发现热路径慢了再补。 + +### 1.12 KA1–KA3 KAL 作为 kernel ABI 统一层(与 1.11 是两件事) + +§1.11 测的是「抽象内核内部」。这一组测的是**另一种读法**:统一 syscall / kernel ABI,转发到不同内核后端。**结果全部是肯定的。** + +契约走 C ABI 缝(H1 已证明跨包只有这一条路): + +```cpp +export module openkal.io; +extern "C" long kal_write(int fd, const char* buf, unsigned long n); +export namespace kal { inline long write(int, const char*, unsigned long); } +``` + +**KA1 —— 同一份 `app.cppm`,零 `#if`,两个内核后端:** + +| 后端 | 实现 | 结果 | +|---|---|---| +| hosted x86_64-linux | 转发到真 `::write(2)` | ✅ **实际运行输出 `hello from one source`** | +| riscv64-none-elf 裸机 | 转发到 MMIO UART | ✅ 零未定义符号,**157 字节** | + +**KA3 —— 应用对 KAL 的依赖面是可审计的符号集,且两侧完全相同:** + +``` +裸机构建的 app.o 外部符号: kal_write +hosted 构建的 app.o 外部符号: kal_write +``` + +**KA2 —— 换后端不重编应用**:同一个 `app_b.o` 换掉后端目标文件重链即成。 + +⇒ 与 K3 合起来给出两层相反的 ABI 结论: + +| | AAL | KAL | +|---|---|---| +| 典型操作 | `local_irq_disable()` 本该一条 `csrci` | `write()` 本来就要陷入内核 | +| 一次 call 的相对成本 | **灾难性** | 可忽略 | +| 正确形态 | **concept + LTO**(K3) | **C ABI**(KA2) | + +⚠️ 这正是 Linux 的实际做法(`asm/` 的 `static inline` vs 导出的 syscall 入口)—— 不是巧合。 + +### 1.13 TH1–TH6 线程契约面 + ⚠️ `thread_local` 的静默失败 + +**TH1/TH2 —— 契约面有多大(决定这条路是一个下午还是一个季度):** + +| | 名字数 | +|---|---| +| libc++ `__external_threading` | **36**(~30 函数 + 8 类型):mutex×4 · recursive_mutex×5 · condvar×5 · thread×9 · once×1 · tls×3 | +| libstdc++ gthreads(`gthr-default.h`) | 71 —— **但其中 17 个是 `__gthread_objc_*` 遗留** ⇒ 真实面 ~30,与 libc++ 高度重合 | + +libc++ 已有 `__thread/support/{pthread,windows,c11,external}.h` **四个后端** —— 可插拔线程后端这个形状在标准库里已经存在。 + +**TH4–TH6 —— ⚠️ `thread_local` 在裸机上是一个完整的静默失败:** + +``` +thread_local int counter; → 编译 ✅ 链接 ✅ 零未定义符号 ✅ 零诊断 ✅ +产生段:.tbss (4 字节) + +bump(): + lw a0, 0x0(tp) ← 通过 tp 寄存器寻址 + addiw a0, a0, 0x1 + sw a0, 0x0(tp) + +start.S 里设过 tp 吗? → 没有(只设了 sp) +``` + +**⇒ 全绿,运行期静默读写垃圾地址。** 与 §1.1 的 E1 同族:**「没有红」才是问题**。 + +`thread_local` 不属于线程契约(KAL),属于 **AAL(TLS 寄存器约定)+ BSP(`start.S` 设 `tp` + 链接脚本 `.tdata/.tbss`)**。⚠️ 且 libstdc++/libc++ 内部自己用 `thread_local`,不是「用户不写就没事」。 + +⇒ **裸机验收用例必须含一个 `thread_local`**,否则这条会活很久。 + +### 1.14 M1–M5 / ISO 系列:现代 C++ 惯用法可行,⚠️ 以及一次不合格对照的教训 + +**M1–M3 —— 惯用法在裸机上全部跑通,而且零成本是实测的:** + +`concept`(能力契约)+ concept 的 `&&`(world = 接口组合)+ `std::expected`(结构化错误,零 libc 可用)+ **deducing this**(后端只写 `write()`,`write_all()` 免费)+ `if constexpr`(可选能力做增强)。 + +链接零未定义符号,**text 698 字节**;`kmain` 反汇编 **`jal` 条数 = 0** —— 整条 `log → write_all → write → expected` 链全部内联成直线代码(前提是 **LTO**,见 K3)。 + +**⚠️ M4/M5 的初始结论是错的,原因是一次不合格的对照。** + +初稿断言「clang 22 + 模块 + concept 诊断 = ICE」。但那组对照**同时换了两个变量**(模块 vs 头文件、以及 target/标准库配置)。逐个隔离: + +| 隔离 | 结果 | +|---|---| +| ISO-1 宿主 + 正常 libc++ + 模块 + 失败 concept | ✅ 诊断完美 | +| ISO-2 加回 `--target=riscv64-none-elf -ffreestanding` | ✅ | +| ISO-3 概念里用 `std::` 类型(import std 子集) | ✅ | +| ISO-4 两个 `import` 同一行 | ✅(clang 接受;GCC 报解析错) | +| ISO-6 `deducing this` + 跨模块继承 | ✅ | +| **真因:一个 BMI 建于 `-O2`,另一个建于 `-O2 -flto`,混用** | ❌ **ICE** | +| **同组 flag 重建全部 BMI** | ✅ **诊断完美** | + +flag 一致后的诊断质量远超预期(精确到缺哪个成员): + +``` +error: static assertion failed: world 缺 threads 能力 +note: because 'bsp::BareWorld' does not satisfy 'RtosWorld' +note: because 'w.threads()' would be invalid: no member named 'threads' in 'bsp::BareWorld' +``` + +**⇒ 两条结论:** + +1. **惯用法完全成立,不存在路线阻塞。** +2. ⭐ **「BMI flag 一致性」不是优化问题,是「不一致会让编译器崩」** —— 这抬高了「BMI 缓存键必须含 freestanding 轴」的等级(后果从「结果不对」变成「编译器崩溃栈」)。**而 mcpp 的指纹/缓存键系统正是防这个的;我是手敲命令行绕过了它才踩到。** + +⚠️ **方法论教训**:这一轮我又犯了「对照组同时换两个变量」的错(与 `gcc-bmi-body-insensitive` 那次同族)。**是用户的质疑逼出了重测。** 不合格的对照会把一个配置错误读成编译器缺陷,并据此得出「路线被阻塞」的结论。 + +### 1.15 AAL / CABI 系列:最硬原语的裂缝,与 C ABI 编码的代价 + +**AAL-1/AAL-2 —— 「AAL 会不会碎」的正面测试。** + +两个**真实不同**的 arch 实现(riscv Sv39 风格 PTE 位 `V/R/W/X/U` vs x86-64 风格 `P/RW/US/NX`,Context 布局完全不同)+ 一段只依赖 concept 的通用内核代码: + +- **AAL-1 通过**:同一段 `setup_task()` 对两个 arch 各实例化一次,**`call` 条数 = 0**。 +- **AAL-2 主动找裂缝,找到三条,而且分成两类:** + +| # | 裂缝 | 表现 | 类型 | +|---|---|---|---| +| 1 | execute-only(riscv 支持 X 不带 R,x86-64 经典分页做不到) | **两边都编过**,x86 实现静默给了 R+X | **A 类:类型对、语义错、静默** | +| 2 | 页大小按下标取 `page_sizes[1]` | 编过;riscv/x86 恰好都是 2M,aarch64 16K granule 下是 32M | **A 类** | +| 3 | 通用代码设 syscall 返回值 `c.a0 = v` | `error: no member named 'a0' in 'RvSv39::Context'` | **B 类:编译期硬错** | + +⇒ **规则**:A 类**必须提到能力轴**;B 类**承认它不属于通用层**。与 K2(MMU)、实时性是同一条规则的三次应用。 + +**CABI —— `result` 在 C ABI 上的两种编码,代价实测:** + +| 编码 | x86-64 | riscv64 | +|---|---|---| +| **2 字结构返回** | **9 条指令** | **10 条指令,2 次内存存取** | +| 出参 + 错误码 | 12 条指令,3 次栈存取 | 12 条指令,4 次内存存取 | + +riscv 反汇编显示 err/value 直接走 `a0`/`a1`,`snez`/`and` 就地做分支消除,**不落栈**。 + +⇒ **两个 arch 上结构返回都更便宜** ⇒ KAL 的 `result` 用 2 字结构返回。⚠️ 限制:`T` 必须 ≤ 一个机器字,否则退化成隐藏指针。 + +--- + +## 2. 生态对照:别人怎么解 + +### 2.1 Rust / Cargo —— 最完整、也最值得抄的对照 + +Rust 把裸机拆成**四个互不耦合的层**,这正是 mcpp 缺的分层: + +| 层 | Rust 的东西 | 承载什么 | +|---|---|---| +| 库分层 | `core` / `alloc` / `std` **在源码层就是三个 crate** | `#![no_std]` 是**减法可证**的:`core` 一定存在 | +| 目标规格 | 内置 target(如 `riscv64imac-unknown-none-elf`)或 **target-spec JSON** | ISA/ABI/code-model/panic-strategy/linker-flavor 都是**数据** | +| 板级支持 | `riscv-rt` / `cortex-m-rt` crate + `memory.x` | 启动代码 + 链接脚本 **随包分发** | +| 运行/调试 | `.cargo/config.toml` 的 `runner = [...]` | `cargo run` → `qemu-system-riscv64 -kernel ` | + +典型 `.cargo/config.toml`: + +```toml +[build] +target = "riscv64gc-unknown-none-elf" +rustflags = ["-Clink-arg=-Tsrc/lds/virt.lds"] + +[target.riscv64gc-unknown-none-elf] +runner = ["qemu-system-riscv64", "-nographic", "-machine", "virt", "-kernel"] +``` + +对 mcpp 最重要的三点: + +- **`core` 的存在是 `no_std` 好用的全部原因。** C++ 侧没有任何实现提供等价物(E4)。这是 C++ 裸机体验落后 Rust 的**根因**,不是工具链问题。 +- **target spec 是文件/数据,不是 flags。** 用户不会在 `Cargo.toml` 里写 `-mcmodel=medany`。 +- **`runner` 只有一行。** 但它把「裸机产物不能直接 exec」这件事整个吸收掉了。 + +另有 `-Z build-std=core,alloc`(从源码为任意目标现编标准库)——C++ 侧的对应物恰好就是「为目标现编一份 libc++ / `__config_site`」,E5 已经证明其可行性形状。 + +### 2.2 Zig + +target 语法直接是 `arch-os-abi`,`os` 的合法取值里就有 **`freestanding`**(`riscv64-freestanding-none`)。Zig 自带多目标 LLVM 后端 + 自己的 libc/compiler-rt,所以「换 target 就换世界」是零成本的。 + +**对 mcpp 的启发是 triple 语言本身**:mcpp 的 triple 文档(`triple.cppm:6`)明写「canonical 是 `arch-os[-env]`(三段,无 vendor —— Zig 风格)」,**却把 `os` 的取值锁死在 `{linux, macos, windows}`(同文件第 12 行)**。加一个 `none` 是这套语言的自然延伸,不是异物。 + +### 2.3 CMake / Conan / PlatformIO / Zephyr + +- **CMake**:`CMAKE_SYSTEM_NAME=Generic` 表示「目标没有 OS」,并且必须配 `CMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY`,否则 CMake 的工具链探测会去链一个可执行文件而必然失败。⚠️ **这条对 mcpp 直接适用**:mcpp 的 doctor / 工具链探测里任何「编个小程序链一下」的动作,在 `os=none` 下都会假失败。 +- **Conan**:2021 年(1.43)才加 `os=baremetal` 设置,并且官方说明是「这只是一个通用命名约定,预期用户在其下自定义子设置来表达具体硬件/板子/系列」。⇒ **连 Conan 也没有把板子建模进核心**,而是留给 profile。这支持本文第 6 条:板级是数据/包,不是引擎。 +- **PlatformIO / Zephyr(west)**:走到了另一极端——内建**成千上万的板子定义**。这是「发行版构建器」的形态,与 `2026-07-24` 决策 #1(mcpp 不做发行版构建器)明确冲突,**不应作为 mcpp 的方向**。 + +### 2.4 LLVM Embedded Toolchain(可直接借用的载荷形态) + +ARM 维护的 `LLVM-embedded-toolchain-for-Arm` 打包了 **clang + lld + libc++ + libc++abi + compiler-rt + picolibc(可选 newlib / llvm-libc)**,通过 multilib 按 `--target` + FPU 选项自动选库,并提供 `--config` 配置文件。官方说明「C++ 通过 libc++/libc++abi **部分支持**,无多线程,ABI 不稳定」。 + +⇒ 这就是 E2+E3+E5 三条实测拼出来的那个东西的**成品形态**:mcpp 若走到载荷层,不必从零设计,**这套组合已被验证**(且 RISC-V 也在其目标内)。 + +### 2.5 OSDev 的既有实践约束 + +OSDev Wiki 的裸机基线要求几条硬约束,任何 mcpp 方案都绕不开: + +- 必须用**交叉编译器**,不能用宿主编译器(mcpp 侧对应 E1:必须真发 `--target`); +- 链接顺序 **`crti.o` → `crtbegin.o` → 你的 .o → `crtend.o` → `crtn.o`**,顺序错会出「奇怪的 bug」;`crtbegin/crtend` 由编译器提供,`crti/crtn` 要自己写;这两个共同实现 `_init`/`_fini`,**即全局构造函数的调用点**; +- 编译用 `-ffreestanding`,链接用 `-nostdlib` + `-lgcc`。 + +⇒ **C++ 的全局构造在裸机上是 BSP 的职责**(`_init` / `.init_array` 遍历)。这条把「BSP 必须能贡献链接输入和链接顺序」这个需求钉死了——不是可选糖。 + +--- + +## 3. 标准侧现状:freestanding 与 std 模块 + +- **freestanding 库子集正在扩大**:P1642(把 `[utilities]`/`[ranges]`/`[iterators]` 的大部分加入 freestanding 子集)、P2338(把「无需 OS 调用、无空间开销」的一切加入 C/C++ 共享 freestanding 库,含 `charconv`、`char_traits`)。C++23 起 `` 等已标为 "all freestanding"。 +- **但模块与 freestanding 的交叉是空白**:P1212R0(*Modules and Freestanding*, 2018)提出四个选项并倾向「扩库 + 裁语言(去异常/RTTI/TLS/默认堆)」,得到 SG14 支持,**但没有进入标准**。P0829 的 freestanding 提案系列同样未落地为 `std.freestanding` 模块。 +- **最新动向**:SG14 在 2025-05 的会议记录(P3693R0)里出现「下次会议是 Embedded,我想知道大家是否认为 `std.freestanding` 模块值得推进」。**这是「还在问值不值得做」的阶段,不是「在做」。** + +⇒ **未来 3~5 年内不会有标准化的 freestanding std 模块。** mcpp 若要在裸机上给用户任何 `import`-able 的标准设施,**只能自己做成一个包**(见第 7 节的 `mcpp.core` 设想),不能等标准。 + +反过来说,这也是 mcpp 唯一可能领先的位置:**mcpp 是 modules-first 的工具,而整个 C++ 生态还没有人发布过「模块化的 freestanding 子集」。** + +--- + +## 4. 问题分解:裸机支持 = 六条正交轴 + +把 issue #403 的五个痛点重排成正交轴,可以看清哪些是引擎、哪些是生态、哪些根本不是 mcpp 的事。 + +| # | 轴 | 今天 | 归属 | 规模 | +|---|---|---|---|---| +| 1 | **目标身份**:triple 语言容纳 `os=none` | `none` 被当 vendor 跳过(`triple.cppm:299`),整串不可解析 | **引擎** | 小 | +| 2 | **目标规格**:ISA / ABI / code-model / 载入地址 | 无处安放;塞 `[build].cxxflags` 会漏掉依赖 | **引擎(数据表)** | 中 | +| 3 | **链接模型**:`-nostdlib -nostartfiles -static -T script`,无 loader / 无 rpath / 用 lld | `CLibMode::None` 存在但语义是「什么都没找到,退回驱动默认」——是**退化态不是正向态** | **引擎** | 中 | +| 4 | **板级支持**:`start.S` / `link.ld` / `_init` 顺序 / 复位向量 | 无 | **生态(包)** | 小(机制已有) | +| 5 | **运行/调试**:qemu runner、semihosting 退出码 | 全仓 `runner\|qemu` 有效命中 **1 处**(且无关) | **引擎(一条缝)+ 生态(命令)** | 小 | +| 6 | **标准库**:`import std` 关断 + freestanding 子集 | 无判定、无诊断 | **引擎(诊断)+ 生态(子集包)** | 小 + 大 | + +关键观察:**六条里只有 1/2/3 必须动引擎,而且都不大;4/6 的重头在生态。** 这与 E2(载荷已够用)一致——mcpp 缺的是模型,不是能力。 + +### ⚠️ 4.1 为什么第 2 轴必须是「数据」而不是「flags」 + +`-march=rv64gc -mabi=lp64d -mcmodel=medany` 三者有一个共同性质:**链接进同一个镜像的每一个目标文件都必须用完全相同的值编译**,否则要么 lld 在链接期报 ABI 不匹配,要么更糟——过了但运行期行为错。 + +`[build].cxxflags` 的作用域是**本包**。依赖(未来的 BSP 包、驱动库、第三方 freestanding 库)拿不到它们。所以 issue #403 里那份 `mcpp.toml` 即使 mcpp 修好了「宿主解析」问题,**一旦引入第二个包就会碎**。 + +Rust 把这些放进 target spec(数据),CMake 放进 toolchain file(数据),Conan 放进 profile(数据)。**三家独立收敛到同一个答案,这不是巧合。** + +同时这条与仓库既有原则直接对齐:`.agents/docs/2026-08-18-open-items-analysis-and-axis-discipline.md` 的 flag-axis 纪律、以及 `#233/#240/#242/#344` 那批「同一决策两处推导 = 架构债」。**目标规格必须是一张表,被编译 flags、链接 flags、产物命名、runner、`cfg()` 求值共同消费。** + +--- + +## 5. 三条路线 + +### Route A — 明确不做(维持决策 #15) + +- **做什么**:把 `os=none` 加入 triple 语言**仅用于识别并拒绝**,给一条清楚的「mcpp 不支持裸机目标,建议使用 Makefile/CMake」诊断;修掉 E1 的静默失败。 +- **代价**:issue #403 的 toy_kernel 继续用 Makefile;C++ 裸机生态与 mcpp 无关。 +- **何时选它**:如果判断裸机用户与 mcpp 的核心价值(modules + 包管理 + 现代 C++23)重叠太少。⚠️ 但注意:**决策 #15 的两条理由已被 E2/E4 部分推翻**——「需要 freestanding modules 核心特性」是错的(模块在裸机上直接可用,E2),真正缺的只是 `import std`(E4);「512KB MCU 上 modules 优势无关」对 MCU 成立,**对 RISC-V 内核/SoC 不成立**(toy_kernel 正是后者)。 + +### Route B — 最小可落地缝(**推荐**) + +开三条缝,板级走包: + +1. **triple 语言收下 `os=none`**(`env` 取 `elf`,canonical `riscv64-none-elf` —— 与 Rust/LLVM 拼写一致),`family()` 返回空(⇒ `cfg(unix)` / `cfg(windows)` 自动为假,已是现有行为),新增 `cfg(freestanding)` 或 `cfg(os = "none")` 谓词。 +2. **`kKnownTargets` 增加目标规格列**(ISA / ABI / code-model / 默认链接器),并让 `[target.]` 能覆写这些键 —— 闭词表 + 逃生舱,与仓库既有风格一致。 +3. **`CLibMode` 增加正向的 `Freestanding` 态**:发 `-ffreestanding -nostdlib -nostartfiles -static -T