Skip to content

Commit 00a8043

Browse files
authored
fix(link): 命令长度上限从架构上消掉,不再补第八个洞 (2026.8.5.4) (#361)
* fix(link): 命令长度上限从架构上消掉,不再补第八个洞 (2026.8.5.4) mcpp-index 的 opencv-module 在 windows 上 LNK1170 —— 这是同一族缺陷的**第七次**。 架构分析见 .agents/docs/2026-08-06-command-length-architecture.md。 前七次:#247(CreateProcess 32 KiB)、#261 两处(cmd.exe 8191)、#274(argv 50781 字符,失败是裸 127)、#344(POSIX MAX_ARG_STRLEN 128 KiB)、2026.8.5.3(link.exe 响应文件单行 128 KiB)、本次。共同形状: - **发现方式永远是崩溃**,且崩在构建最后一步(本次:编译完 356 秒之后); - **失败不可归因** —— 三种报错谁都不说是哪条边; - **触发者从来不是「写了很长的命令」**,而是无关改动:#344 修缓存正确性、#274 改 错误粒度、本次是**把 CI 的 pin 抬过 2026.8.3.4**。 每次修完都在注释里写「构建系统不该有靠崩溃才发现的规模上限」,然后换个地方再犯。 ── 为什么现在才出现 ────────────────────────────────────────────────────── #344 给每个依赖的对象加了一层包目录(缓存正确性要求),路径因此变长 —— 同一条边 在 linux 上从 56 840 涨到 161 687 字节。而 mcpp-index 的 CI 一直 pin 在 **2026.8.3.3**,正好是那之前一版,windows 腿从没用长路径链接过。抬 pin 才第一次撞到。 ── P1:让长度不再是变量 ───────────────────────────────────────────────── 2026.8.5.3 把 mcpp**自己**写的响应文件改成按行分隔。但 clang 作为 driver 时会 **再生成一个**响应文件转发给链接器,那个是单行的 —— 我们改不到它。所以 windows 上的 clang 链接改用 `-fuse-ld=lld`。 **不是 workaround**:lld 用 LLVM 的 tokenizer 解析响应文件,**没有单行上限**,消掉 的是一整类而不是把数字调大;路径也缩不短(那层包目录正是 #344 需要的);而且 **linux 与 macOS 早就在用 lld**,windows 是唯一还在用系统链接器、也是唯一有单行 上限的平台 —— 这是消除平台不一致。原生 cl.exe 保持 link.exe(那里响应文件是我们 自己写的,2026.8.5.3 已覆盖)。 ── P2:上限进表(新模块 cmdlimits.cppm)────────────────────────────────── 根因是命令构造层对「这条命令要穿过哪些通道、各自上限多少」一无所知,而这份知识 只在注释和 CHANGELOG 里 —— 是「同一决策 N 处推导」的镜像:**一个关键约束在零处 被表达**。 表里除字节数还记**症状**:这一族最贵的从来不是修,是**认出**。 `Argument list too long` / `LNK1170` / 裸 `127` 三种表现毫无共同点。新增通道必须 回答「你的上限是多少」,与 directives::kTable 里「Scope 是必填字段」同一手法。 ── P3:计划期拦截并指名道姓 ──────────────────────────────────────────── 生成完 build.ninja 统一扫描,超限时报出边名/通道/实测字节/上限/解法/文档路径, 且发生在**还没编译任何东西**的时候。 实施中纠正两处判断: - 校验点选「manifest 生成后统一扫描」而非「逐 emit site 插桩」—— 后者在新增一种边 时没人会想起来加,而那正是前七次的漏法; - **phony 必须排除**,否则会误报到 **#274 的修复本身**:它为解决 argv 超限,正是把 几千个目标收进一条 phony 聚合边,而 phony 根本没有 command。走 rspfile 的规则 同样豁免。判据是「rule 有 command 且不走 rspfile」。 ── 测试 ──────────────────────────────────────────────────────────────── 单测 test_cmdlimits 8 条:每个通道都在表里、数字是实测的那些(MAX_ARG_STRLEN 是 32 页,不是谁都会先想到的 2 MiB ARG_MAX)、每条都记了症状与解法、诊断含边名/字节/ 上限/解法/文档路径。 **没做超限 e2e**:P1 之后本地造不出自然超限的边,要造只能人为破坏 rspfile 规则, 那测的是被破坏的代码而不是真实路径。windows 的真实验证由 mcpp-index CI 承担。 全量单测 58/58;mcpp 自身 351 条边零误报。 ── 其他 ──────────────────────────────────────────────────────────────── 内带 xlings 升到 2026.8.5.2,.github/ 下 16 处 pin 由 check_version_pins.sh 校验同步。 (校验脚本当场抓到 7 处不同步,包括带 v 前缀的 5 处;并拦下了我一次误改 —— 全局 替换差点把它自己注释里的**历史记录** available: 2026.8.5.1 也改掉。) * revert: 内带 xlings 回退到 2026.8.5.1 —— 2026.8.5.2 有回归 `mcpp builds & runs xlings` 集成 CI 在 2026.8.5.2 上挂掉,**重跑可复现**: error: xlings install_packages failed (exit 1) for 'mcpplibs.xpkg@0.0.48' 归因:同一条 job 在 PR #360(xlings 仍是 2026.8.5.1)上是绿的;两个 xlings 版本 之间只有一个代码提交(xlings#481,把 runtime_deps 的版本匹配从字符串相等换成 semver 范围满足),而失败正好发生在依赖安装阶段。同一批里 cmdline / tinyhttps / capi.lua 都装成功,所以不是全部包受影响。 已报 openxlings/xlings#486。等对方发新版再升 —— 用一个已知有回归的版本去满足 「用最新版」不是升级,是把回归带进来。 .xlings.json 的 mcpp bootstrap pin(2026.8.5.3)不受影响,不动。 本 PR 其余部分(命令长度架构)与此无关,照常。 * docs: #359 的架构设计 —— 两个缺口是同一个形状 在等 xlings 修回归的间隙做 #359 的架构分析。 **关键发现:模型早就存在,新东西没接进去。** mcpp 有一套完整的「依赖提供什么 × 提供给谁」模型 —— `UsageRequirements` × {privateBuild, publicUsage, linkUsage}, include dirs / defines / ldflags / modules 全走它。而 #355 引入的两种新提供物 (host 工具、host 模块)**没有进这个模型**,各自硬编码成「只给发出请求的那条边」: prepare.cppm:4113 toolEnvByConsumer[edge.consumerPackageIndex] prepare.cppm:4016 只遍历 m->dependencies(只认 root 的直接依赖) 所以「库代用户拉起整条 codegen 工具链」在架构上不可能:工具被构建了,但环境变量 记在库的账上,消费者看不见。实测确认过,不是推断。 **根因不是少了一次传播,而是:新增一种提供物时,没有任何地方逼你回答「它怎么 传播」。** 这是「同一决策 N 处推导」的镜像 —— 一个必答问题在**零处**被表达。 缺口 B 同构:build.mcpp 的输入只有「文件内容哈希」与「环境变量」两种形态, `hash_file` 读的是内容,于是「我的输出取决于这个目录里有哪些文件」无法表达 —— 新增 .proto 静默不生成。同样是「新增一种输入时,没地方回答它的指纹怎么取」。 设计主张:两个都收敛成「表 + 必答字段」,与 directives::kTable 同一范式,而不是 各打一个补丁。三条语义写死:传播的是可见性不是自动执行、必须显式声明不能默认 传播(否则是供应链问题)、目录指纹只取成员集合不取内容(否则一次重跑放大成全量 重编)。 两条必须一起做:只做 A 仍要逐个列 proto,只做 B 仍要写 4 条依赖。 * revert: 撤回 xlings 回退 —— 我的归因是错的,与 xlings 无关 真因是 **mcpp-index 的描述符**:pkgs/x/xpkg.lua 里一个格式错误的 0.0.49 条目把 0.0.47 / 0.0.48 一起吞掉了,那两个版本根本不可解析。已由 mcpplibs/mcpp-index#160 修复。 时间线是决定性的:我最后一次失败在 **17:41:31**,#160 合并在 **17:51:19** —— 失败早于修复 10 分钟。 我的归因链有两处错误,记下来: 1. 先怪 xlings 2026.8.5.2,依据是「#360(.5.1)绿、#361(.5.2)红,且两版之间 只有一个代码提交」。**相关性是真的,因果是假的** —— 那段时间 mcpp-index 的 xpkg.lua 也刚好坏了。 2. 回退 xlings 后**仍然失败**,这本该立刻推翻结论,我却先去怀疑自己新加的命令 长度校验(本地复现证明它没误报)。 内带 xlings 恢复到 2026.8.5.2;已在 openxlings/xlings#486 更正并说明。 另记一条待办:mcpp 调 xlings 用 `install_packages ... 2>/dev/null`,把对方的 报错吞了,失败只剩一行「install_packages failed (exit 1)」,分不清是「版本不存在」 还是「构建失败」。这是我误判的助力之一,应当改掉。
1 parent 9d4995e commit 00a8043

18 files changed

Lines changed: 718 additions & 22 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# 命令长度:把「靠崩溃发现的规模上限」从架构上消掉
2+
3+
> 状态:**已实施(2026.8.5.4)**
4+
> 触发:mcpp-index 的 `opencv-module` 在 windows 上 `LNK1170`,这是同一族缺陷的**第七次**
5+
> 涉及:`src/build/ninja_backend.cppm``src/build/flags.cppm`、新模块 `src/build/cmdlimits.cppm`
6+
7+
---
8+
9+
## 0. 为什么这次不该再补一个洞
10+
11+
同一族缺陷,七次:
12+
13+
| # | 版本 | 谁超了 | 撞的是什么上限 | 当时的修法 |
14+
|---|---|---|---|---|
15+
| 1 | #247 | 链接命令内联 `$in`,数千对象 | Windows `CreateProcess` **32 KiB** | windows 的链接规则改走 rspfile |
16+
| 2 | #261 | scan 规则经 shell 重定向 → 被 `cmd /c` 包裹 | `cmd.exe` **8191** | 改用 `clang-scan-deps -o`,去掉包裹 |
17+
| 3 | #261 | 编译/扫描内联无界 `-I` 列表 | 同上 | windows 的这些规则也改走 rspfile |
18+
| 4 | #274 | `mcpp test` 的显式 ninja 目标集(FFmpeg 2281 个单元 → argv **50 781** 字符) | `cmd.exe` **8191**,失败是**裸 127** | 目标集改成 phony 聚合边 |
19+
| 5 | #344 | 对象路径变长(每依赖多一层包目录),同一条边 56 840 → **161 687** 字节 | POSIX `MAX_ARG_STRLEN` **128 KiB**(ninja 用 `sh -c`,整条命令是一个 argv 项) | 链接/归档**全平台**改走 rspfile |
20+
| 6 | 2026.8.5.3 | rspfile 把所有对象写在**一行** | `link.exe` 响应文件**单行 128 KiB** | `rspfile_content = $in_newline` |
21+
| 7 | 本次 | clang driver 读完我们的 rspfile,**又生成一个单行的**给 link.exe | 同上 | ← 本文档 |
22+
23+
七次的共同形状:
24+
25+
- **发现方式永远是崩溃**,而且崩在**构建的最后一步**(第 7 次:编译 356 秒之后)。
26+
- **失败不可归因**:`posix_spawn: Argument list too long` 不说哪条边;`LNK1170` 不说哪个 target;`cmd /c` 那次是裸 `127`,ninja 和 mcpp 都没机会打印任何东西。
27+
- **触发者从来不是"写了很长的命令"**,而是一个看似无关的改动:#344 是修缓存正确性,#274 是改错误报告粒度,本次是**把 CI 的 pin 从 2026.8.3.3 抬到 2026.8.5.x**
28+
29+
每次修完,都在注释里写下"构建系统不该有一个靠崩溃才发现的规模上限"——然后下一次换个地方再犯。
30+
31+
**所以问题不在任何一个上限,而在于:命令构造层对「这条命令要经过哪些通道、每个通道的上限是多少」一无所知,而这份知识只存在于注释和 CHANGELOG 里。**
32+
33+
## 1. 根因:三条结构性缺陷
34+
35+
### R1. 构造层与执行通道之间没有契约
36+
37+
`ninja_backend` 负责拼命令,但一条命令实际要穿过的通道是:
38+
39+
```
40+
mcpp 拼出的规则文本
41+
→ ninja 展开(可能内联,可能写 rspfile)
42+
→ 进程创建(CreateProcess / posix_spawn / sh -c)
43+
→ 工具自身(driver 可能再写一个 rspfile 转发给 linker)
44+
→ 最终工具(link.exe / lld / ar)
45+
```
46+
47+
每一层都有自己的上限,**而且互不相同**。构造层不知道自己产出的东西会经过哪几层,于是「加一层包目录」这种改动无法被任何机制提醒。
48+
49+
这与本仓库反复付过学费的「同一决策在 N 处推导」是同一类问题的镜像:**一个关键约束在零处被表达**
50+
51+
### R2. 上限是叙述,不是数据
52+
53+
mcpp 已经有成熟的表驱动范式:
54+
55+
- `CommandDialect` —— 一个 flag 怎么拼(gnu / msvc)
56+
- `BmiTraits` —— BMI 的形态与引用方式
57+
- `directives::kTable` —— build.mcpp 的指令(一行一条指令,解析/缓存/落盘全由该行驱动)
58+
59+
唯独「执行通道 → 上限」没有表。它散落在七处注释里,每处只讲自己那次。没有任何地方能回答「windows 上一条链接命令的可用预算是多少」。
60+
61+
### R3. 校验发生在运行期,而且是别人的运行期
62+
63+
上限是在 **ninja 执行边****link.exe 解析文件** 时才撞上的。那时:
64+
65+
- 已经花掉了全部编译时间;
66+
- 报错的是别人的程序,信息里没有 mcpp 的上下文(哪个 target、哪个包、多少个对象);
67+
- mcpp 没有介入的机会。
68+
69+
而 mcpp **在生成 build.ninja 时就完全知道**每条边的输入个数与路径长度。校验点选错了。
70+
71+
## 2. 设计
72+
73+
三条原则,对应三条根因。
74+
75+
### P1. 让长度不再是变量(结构性消除 > 阈值调大)
76+
77+
凡是可能随项目规模**无界增长**的载荷(对象列表、include 列表、库列表),必须满足:
78+
79+
1. 走响应文件,不进命令行;
80+
2. 响应文件**按行分隔**;
81+
3. 下游工具对响应文件**没有单行上限**
82+
83+
第 3 条是本次新增的认识,也是前六次都没覆盖到的:**我们控制不了 driver 再生成的那个文件**。唯一的解法是让最终工具不带这个限制。
84+
85+
因此:**windows 上的 clang 链接改用 `-fuse-ld=lld`**
86+
87+
> 这不是"换个工具绕过去"。理由有三:
88+
> - lld 通过 LLVM 的 tokenizer 解析响应文件,**没有单行上限**——是消掉一整类,不是把某个数字调大;
89+
> - 路径本身缩不短:per-package 那层目录正是 #344 需要的,其余是源码树自己的结构;
90+
> - **linux 与 macOS 早就在用 lld**(`kLinkDriverFlags`)。windows 是唯一还在用系统链接器的平台,也是唯一有单行上限的。这是**消除平台不一致**,不是新增特例。
91+
>
92+
> 原生 cl.exe(`isMsvcDialect`)保持 link.exe:那条路径上响应文件是 mcpp 自己写的,2026.8.5.3 已经修好。
93+
94+
### P2. 剩余上限必须是表里的数据
95+
96+
新模块 `src/build/cmdlimits.cppm`,把执行通道与其上限写成一张表:
97+
98+
```cpp
99+
enum class Channel {
100+
NinjaArgv, // ninja 直接创建进程
101+
PosixShell, // ninja 的 `sh -c "<整条命令>"`:整条是一个 argv 项
102+
CmdWrapper, // `cmd /c`(#261 起已在全仓绝迹,留在表里以防复活)
103+
RspContent, // 响应文件总量
104+
RspLine, // 响应文件单行
105+
};
106+
107+
struct Limit {
108+
Channel channel;
109+
std::size_t bytes;
110+
std::string_view where; // 谁施加的
111+
std::string_view symptom; // 撞上时用户会看到什么
112+
std::string_view remedy; // 怎么消掉
113+
};
114+
```
115+
116+
表里同时记录**症状**——因为这一族缺陷最贵的部分从来不是修,而是**认出**它。`Argument list too long`、`LNK1170`、裸 `127` 这三种表现毫无共同点,下一次遇到第四种时,表能把人直接指到这里。
117+
118+
新增一个执行通道时,**必须在表里回答"你的上限是多少"**,否则加不进来——与 `directives::kTable` 里「Scope 是必填字段」同一个手法:把一个容易忘的问题变成结构上绕不过去的字段。
119+
120+
### P3. 在计划期校验,并且指名道姓
121+
122+
`ninja_backend` 发射每条边时,已经持有该边的全部输入。因此:
123+
124+
- 估算该边在**每个它会穿过的通道**上的字节数;
125+
- 与表比对;
126+
- 超限时:**能自动降级就降级**(例如内联 → rspfile),**不能降级就报错**,并给出 target 名、通道、实测字节数、上限、以及表里的 remedy。
127+
128+
关键是**报错时机**:在 `mcpp build` 刚开始、还没编译任何东西的时候,而不是 356 秒之后。
129+
130+
## 3. 实施步骤
131+
132+
| 步 | 内容 | 状态 |
133+
|---|---|---|
134+
| 1 | `-fuse-ld=lld` 用于 windows clang 链接(P1) | ✅ `flags.cppm` |
135+
| 2 | 新模块 `cmdlimits.cppm`:通道表 + 预算/判定/诊断(P2) | ✅ |
136+
| 3 | `ninja_backend` 生成 build.ninja 后统一校验(P3) | ✅ |
137+
| 4 | 单测锁住表与诊断 | ✅ `tests/unit/test_cmdlimits.cpp`(8 条) |
138+
139+
### 实施中修正的两处判断
140+
141+
**(a) 校验点不在「发射每条边」,而在「manifest 生成之后统一扫描」。** 逐点插桩要改每个 emit site,而**新增一种边时没人会想起来加**——这正是前七次的漏法。改为扫描已生成的 manifest:新边当天就被覆盖。
142+
143+
**(b) `phony` 必须排除,否则会误报到 #274 的修复本身。** 一条 `build` 行长 ≠ 命令长:`phony` 根本没有 command。而 #274 为解决 argv 超限,正是把几千个目标收进一条 phony 聚合边——不排除的话,新校验会把那条边报成超限,把解法当成问题。走 rspfile 的规则同样豁免(命令里只有 `@$out.rsp`)。
144+
145+
判据因此是:**该边的 rule 有 command,且不走 rspfile** → 它的输入会进命令行 → 校验。
146+
147+
## 4. 验证
148+
149+
- **步 1**:mcpp-index 的 `opencv-module` / `opencv-module-dnn` 在 windows 上通过。这是当前唯一已知能触发的真实场景——本地无法复现(需要 windows + 那个规模的依赖图)。
150+
- **步 2–3**:单测 `test_cmdlimits`(8 条)锁住表与诊断——每个通道都在表里、数字是实测的那些、每条都记了症状与解法、诊断里含边名/实测字节/上限/解法/文档路径。**没有做超限的 e2e**:P1 之后本地已经造不出自然超限的边(要造只能人为破坏 rspfile 规则,那测的是被破坏的代码而非真实路径)。
151+
- 回归:全量单测 58/58;链接相关 e2e(28 / 47 / 86 / 07 / 148 / 190)绿;mcpp 自身 351 条边**零误报**。
152+
153+
## 5. 明确不做
154+
155+
- **不缩短对象路径**。per-package 那层是 #344 的正确性要求,缩回去就是拿正确性换长度。
156+
- **不给"最大项目规模"设一个文档化的数字**。P1 的目标是让这个数字不存在;凡是还存在的,进表并在计划期校验。
157+
- **不改 `cmd /c`**。#261 起它已在全仓 ninja 规则中绝迹,表里保留一行只是为了它某天复活时有人认得出。
158+
- **本次不动原生 cl.exe 路径**。那里的响应文件是 mcpp 自己写的,2026.8.5.3 已覆盖。
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# 依赖提供物与构建期输入:两个缺口,同一个形状
2+
3+
> 状态:**设计待 review**
4+
> 关联:[#359](https://github.com/mcpp-community/mcpp/issues/359)(由 grpc-m 的真实使用暴露)
5+
> 涉及:`src/modgraph/scanner.cppm``UsageRequirements`)、`src/build/prepare.cppm`
6+
> `src/build/build_program.cppm``src/build/directives.cppm`
7+
8+
---
9+
10+
## 0. 目标:用户侧从 4 条依赖 + 逐个列名,降到 1 条 + 1 行
11+
12+
grpc-m 今天要求用户写:
13+
14+
```toml
15+
[dependencies.mcpplibs]
16+
grpc = "1.83.0"
17+
grpc-plugin = { version = "1.83.0", tools = ["grpc_cpp_plugin"] }
18+
grpcgen = { version = "1.83.0", host-module = true }
19+
[dependencies.compat]
20+
protobuf = { version = "35.1", tools = ["protoc"] }
21+
```
22+
23+
```cpp
24+
import mcpp; import grpcgen;
25+
int main() { return grpcgen::generate({"helloworld"}) ? 0 : 1; }
26+
```
27+
28+
后三条**全是为了 codegen**,而且要求用户知道「gRPC 的代码生成需要 protobuf 的 protoc」——这是**库该承担的知识**。目标形态:
29+
30+
```toml
31+
grpc = { version = "1.83.0", features = ["codegen"] }
32+
```
33+
```cpp
34+
import mcpp; import grpcgen;
35+
int main() { return grpcgen::generate_all() ? 0 : 1; } // 扫 proto/**
36+
```
37+
38+
对照业界:xmake 是 `add_requires("grpc")` + `add_files("proto/*.proto")`;CMake+vcpkg 是 1 条依赖 + `protobuf_generate(...)`。达到目标形态后 mcpp **严格更优** —— 因为它还额外保有「版本错配不可表达」与「交叉编译构造上正确」这两条别人没有的性质。
39+
40+
两个缺口各挡住一半,**缺一个都到不了**。
41+
42+
## 1. 关键发现:模型已经存在,新东西没接进去
43+
44+
mcpp 早有一套「依赖能提供什么 × 提供给谁」的模型(`src/modgraph/scanner.cppm`):
45+
46+
```cpp
47+
struct UsageRequirements {
48+
std::vector<std::filesystem::path> includeDirs;
49+
std::vector<std::filesystem::path> includeDirsAfter;
50+
std::vector<std::string> cflags, cxxflags, ldflags, modules;
51+
};
52+
53+
struct PackageRoot {
54+
UsageRequirements privateBuild; // 只给自己
55+
UsageRequirements publicUsage; // 沿边传给消费者
56+
UsageRequirements linkUsage; // 链接期
57+
};
58+
```
59+
60+
include dirs、defines、link flags、modules 全都通过它传播,规则清楚、单点定义。
61+
62+
**#355 引入的两种新提供物没有进入这个模型**:
63+
64+
| 提供物 | 在模型里? | 实际实现 |
65+
|---|---|---|
66+
| include dirs / defines / ldflags / modules |`UsageRequirements` | 按作用域传播 |
67+
| **host 工具**(`tools = [...]`) || `prepare.cppm:4113` 硬编码 `toolEnvByConsumer[edge.consumerPackageIndex]` —— 只给**发出请求的那条边**的消费者 |
68+
| **host 模块**(`host-module = true`) || `prepare.cppm:4016` 只遍历 `m->dependencies`,即**只认 root 的**直接依赖 |
69+
70+
于是「库代用户拉起整条 codegen 工具链」在架构上不可能:工具**被构建了**,但环境变量记在库的账上,消费者的 `build.mcpp` 看不见。
71+
72+
> 实测确认(不是推断):一个 path 依赖在自己的 manifest 里写 `compat.protobuf = { tools = ["protoc"] }`,消费者 `mcpp::dep_bin("protobuf","protoc")` 拿到**空串**,`dep_dir` 同样为空。
73+
74+
**根因不是「少了一次传播」,而是:新增一种提供物时,没有任何地方逼你回答「它怎么传播」。** 这与本仓库反复付学费的「同一决策在 N 处推导」是同一形状的镜像——一个必答问题在**零处**被表达。`directives::kTable` 已经用「Scope 是必填字段」解过一次。
75+
76+
## 2. 缺口 B 同构:输入声明的种类是封闭的
77+
78+
`build.mcpp` 的缓存键由**声明过的输入**构成,而输入只有两种形态:
79+
80+
```cpp
81+
// build_program.cppm:283
82+
os << "in " << hash_file(abs_against_root(root, f)) << ' ' << f << '\n'; // 文件内容
83+
os << "env " << hash_string(env_value(e)) << ' ' << e << '\n'; // 环境变量
84+
```
85+
86+
`hash_file` 读的是**文件内容**。于是「我的输出取决于这个目录里有哪些文件」**无法表达**:
87+
88+
- 对目录调用 `rerun_if_changed` 无效(目录没有可读内容);
89+
- 新增一个 `.proto` 不改变任何已声明文件的哈希 → build.mcpp 不重跑 → **新文件静默不生成**
90+
91+
实测:glob `proto/**` 后新增 `fresh.proto`,`Finished dev in 0.01s`,产物 0 个。这比「要求用户列名字」更坏,所以 grpc-m 最终选了显式列表。
92+
93+
同样的形状:**新增一种输入时,没有地方回答「它的指纹怎么取」。**
94+
95+
## 3. 设计
96+
97+
一条主张:**两个缺口都收敛成「表 + 必答字段」,与 `directives::kTable` 同一范式**,而不是各打一个补丁。
98+
99+
### D1. 提供物进 `UsageRequirements`,传播由作用域决定
100+
101+
```cpp
102+
struct UsageRequirements {
103+
// …既有字段…
104+
// #359: host 工具与 host 模块。放在这里而不是旁路,是为了让「它怎么
105+
// 传播」由所在的作用域回答,与 includeDirs 完全同一条规则。
106+
std::vector<ToolProvision> tools;
107+
std::vector<HostModuleProvision> hostModules;
108+
};
109+
```
110+
111+
- 放进 `privateBuild` → 只有该包自己的 `build.mcpp` 能用;
112+
- 放进 `publicUsage` → 沿 **public 边**传给消费者。
113+
114+
于是 `grpc` 可以在描述符里声明「我的 codegen feature 对外提供 protoc 与 grpc_cpp_plugin」,消费者只写一条依赖。
115+
116+
**三条必须写死的语义**,否则这会变成一个安全与可维护性的洞:
117+
118+
1. **传播的是「可见性」,不是「自动执行」。** `dep_bin()` 只返回路径;跑不跑由消费者的 `build.mcpp` 决定。传播不改变「谁构建了这个工具」,也不改变 tool store 的键。
119+
2. **必须显式声明,不能默认传播。** 默认传播意味着任意深层依赖都能往消费者的工具命名空间里塞东西——那是供应链问题。库要对外提供,必须自己写明(与 `include_dirs` 默认 private、要 public 得显式是同一条纪律)。
120+
3. **命名冲突用包名消歧**,`dep_bin(pkg, tool)` 本来就是两段式,无需新语法。
121+
122+
> 顺带修掉一个相邻缺陷:`dep_dir()` 目前只覆盖**直接**依赖,所以传递依赖的数据文件目录取不到(protoc 的 well-known types 就是这么一个目录)。它应与 tools 走同一条传播规则。
123+
124+
### D2. 输入种类进表,指纹由种类决定
125+
126+
```cpp
127+
enum class InputKind {
128+
File, // 内容哈希(现有)
129+
Directory, // 递归成员集合:相对路径 + size + mtime,不读内容
130+
Env, // 环境变量(现有)
131+
};
132+
```
133+
134+
`Directory` 的指纹**只取集合**,不取内容——内容变化由集合里的 `File` 条目负责。这与 Cargo 的 `cargo:rerun-if-changed=<dir>` 是同一个解。
135+
136+
补上之后 glob 从「结构性不安全」变成一等用法,规则包才能提供 `generate_all()`:
137+
138+
```cpp
139+
mcpp::rerun_if_changed_dir("proto"); // 集合变了就重跑
140+
```
141+
142+
**代价要写明**:目录指纹用 mtime,而 mtime 在某些场景(容器构建、git checkout)不稳定。因此:
143+
- 只把**成员集合**纳入指纹,不把内容纳入 → 误重跑的代价只是一次 build.mcpp 重跑(秒级),不是全量重编;
144+
- 不递归进符号链接(与既有扫描一致)。
145+
146+
### D3. 为什么这两条必须一起做
147+
148+
只做 D1:用户从 4 条降到 1 条,但仍要在 `build.mcpp` 里逐个列 `.proto`。
149+
只做 D2:用户不必列 proto,但仍要写 4 条依赖并知道 gRPC 需要 protobuf 的 protoc。
150+
151+
**两条合起来**才是目标形态,也才是「对齐并超过业界」的那一步。
152+
153+
## 4. 实施步骤
154+
155+
| 步 | 内容 | 风险 |
156+
|---|---|---|
157+
| 1 | `UsageRequirements` 加 tools / hostModules 两个字段,`privateBuild` 行为保持今天不变 | 低,纯新增 |
158+
| 2 | 沿 public 边聚合(复用 features 的边聚合路径,#242/#243 已有先例) | 中——要确认不会把 private 依赖的工具泄漏出去 |
159+
| 3 | 描述符/manifest 侧:声明「对外提供」的语法 | 中——是新的用户可见语法,需按 Schema Ownership Principle 审 |
160+
| 4 | `dep_dir()` 覆盖传递依赖 | 低 |
161+
| 5 | `InputKind` 表 + `Directory` 指纹 + `rerun_if_changed_dir` | 低 |
162+
| 6 | grpc-m 侧改成 1 条依赖 + `generate_all()`,作为真实验证 | —— |
163+
164+
步 1–4 是缺口 A,步 5 是缺口 B,步 6 是端到端证据。
165+
166+
## 5. 验证
167+
168+
- **单测**:传播规则(private 不外泄、public 沿边传、冲突消歧)、目录指纹(增删文件变、改内容不变、mtime 抖动不误伤集合)。
169+
- **e2e**:一个库对外提供工具 + 一个消费者只写一条依赖就能在 `build.mcpp` 里 `dep_bin` 到;新增一个文件后 glob 场景确实重跑。
170+
- **真实场景**:grpc-m 的模板降到 1 条依赖 + 1 行 build.mcpp,且生成产物仍与官方 protoc 逐字节相同(该基线已在 2026.8.5.x 建立)。
171+
172+
## 6. 明确不做
173+
174+
- **不让传播默认开启**。库必须显式声明对外提供,理由见 D1 第 2 条。
175+
- **不把目录内容纳入指纹**。那会把一次 build.mcpp 重跑放大成全量重编,而收益为零(内容变化本来就由 File 条目覆盖)。
176+
- **不引入「工具版本独立于依赖版本」的语法**。单一版本轴正是「错配不可表达」的来源,是本设计要保住的性质。
177+
- **不在本轮解决 windows 的工具子构建失败**(见 mcpp-index 的 compat.protobuf windows 块):那是独立缺陷,原因尚未定位。

.github/actions/bootstrap-mcpp/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ inputs:
2525
# `package.name`, so one of the two was simply unreachable — and which one
2626
# depended on the machine, which is why CI failed on `compat:lua` on
2727
# Windows and `mcpplibs.capi:lua` on Linux. Never pin below that.
28-
default: '2026.8.5.1'
28+
default: '2026.8.5.2'
2929
cache-target:
3030
description: also restore/save target/ (build artifacts + BMIs)
3131
required: false

.github/actions/setup-macos-llvm/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ inputs:
1515
# Floor imposed by the index, not a routine bump — see
1616
# .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required
1717
# (two packages named `lua` in one repo need openxlings/xlings#381).
18-
default: '2026.8.5.1'
18+
default: '2026.8.5.2'
1919

2020
runs:
2121
using: composite

.github/workflows/bootstrap-macos.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
# Dormant (workflow_dispatch only), but kept in step with the rest —
1818
# check_version_pins.sh holds it there. Floor: 0.4.69, below which the
1919
# index cannot resolve two packages that share a short name.
20-
XLINGS_VERSION: '2026.8.5.1'
20+
XLINGS_VERSION: '2026.8.5.2'
2121
steps:
2222
- uses: actions/checkout@v4
2323

0 commit comments

Comments
 (0)