|
| 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 已覆盖。 |
0 commit comments