# 命令长度:把「靠崩溃发现的规模上限」从架构上消掉

> 状态：**已实施（2026.8.5.4）**
> 触发：mcpp-index 的 `opencv-module` 在 windows 上 `LNK1170`,这是同一族缺陷的**第七次**
> 涉及：`src/build/ninja_backend.cppm`、`src/build/flags.cppm`、新模块 `src/build/cmdlimits.cppm`

---

## 0. 为什么这次不该再补一个洞

同一族缺陷,七次:

| # | 版本 | 谁超了 | 撞的是什么上限 | 当时的修法 |
|---|---|---|---|---|
| 1 | #247 | 链接命令内联 `$in`,数千对象 | Windows `CreateProcess` **32 KiB** | windows 的链接规则改走 rspfile |
| 2 | #261 | scan 规则经 shell 重定向 → 被 `cmd /c` 包裹 | `cmd.exe` **8191** | 改用 `clang-scan-deps -o`,去掉包裹 |
| 3 | #261 | 编译/扫描内联无界 `-I` 列表 | 同上 | windows 的这些规则也改走 rspfile |
| 4 | #274 | `mcpp test` 的显式 ninja 目标集(FFmpeg 2281 个单元 → argv **50 781** 字符) | `cmd.exe` **8191**,失败是**裸 127** | 目标集改成 phony 聚合边 |
| 5 | #344 | 对象路径变长(每依赖多一层包目录),同一条边 56 840 → **161 687** 字节 | POSIX `MAX_ARG_STRLEN` **128 KiB**(ninja 用 `sh -c`,整条命令是一个 argv 项) | 链接/归档**全平台**改走 rspfile |
| 6 | 2026.8.5.3 | rspfile 把所有对象写在**一行** | `link.exe` 响应文件**单行 128 KiB** | `rspfile_content = $in_newline` |
| 7 | 本次 | clang driver 读完我们的 rspfile,**又生成一个单行的**给 link.exe | 同上 | ← 本文档 |

七次的共同形状:

- **发现方式永远是崩溃**,而且崩在**构建的最后一步**(第 7 次:编译 356 秒之后)。
- **失败不可归因**:`posix_spawn: Argument list too long` 不说哪条边;`LNK1170` 不说哪个 target;`cmd /c` 那次是裸 `127`,ninja 和 mcpp 都没机会打印任何东西。
- **触发者从来不是"写了很长的命令"**,而是一个看似无关的改动:#344 是修缓存正确性,#274 是改错误报告粒度,本次是**把 CI 的 pin 从 2026.8.3.3 抬到 2026.8.5.x**。

每次修完,都在注释里写下"构建系统不该有一个靠崩溃才发现的规模上限"——然后下一次换个地方再犯。

**所以问题不在任何一个上限,而在于:命令构造层对「这条命令要经过哪些通道、每个通道的上限是多少」一无所知,而这份知识只存在于注释和 CHANGELOG 里。**

## 1. 根因:三条结构性缺陷

### R1. 构造层与执行通道之间没有契约

`ninja_backend` 负责拼命令,但一条命令实际要穿过的通道是:

```
mcpp 拼出的规则文本
  → ninja 展开(可能内联,可能写 rspfile)
    → 进程创建(CreateProcess / posix_spawn / sh -c)
      → 工具自身(driver 可能再写一个 rspfile 转发给 linker)
        → 最终工具(link.exe / lld / ar)
```

每一层都有自己的上限,**而且互不相同**。构造层不知道自己产出的东西会经过哪几层,于是「加一层包目录」这种改动无法被任何机制提醒。

这与本仓库反复付过学费的「同一决策在 N 处推导」是同一类问题的镜像:**一个关键约束在零处被表达**。

### R2. 上限是叙述,不是数据

mcpp 已经有成熟的表驱动范式:

- `CommandDialect` —— 一个 flag 怎么拼(gnu / msvc)
- `BmiTraits` —— BMI 的形态与引用方式
- `directives::kTable` —— build.mcpp 的指令(一行一条指令,解析/缓存/落盘全由该行驱动)

唯独「执行通道 → 上限」没有表。它散落在七处注释里,每处只讲自己那次。没有任何地方能回答「windows 上一条链接命令的可用预算是多少」。

### R3. 校验发生在运行期,而且是别人的运行期

上限是在 **ninja 执行边** 或 **link.exe 解析文件** 时才撞上的。那时:

- 已经花掉了全部编译时间;
- 报错的是别人的程序,信息里没有 mcpp 的上下文(哪个 target、哪个包、多少个对象);
- mcpp 没有介入的机会。

而 mcpp **在生成 build.ninja 时就完全知道**每条边的输入个数与路径长度。校验点选错了。

## 2. 设计

三条原则,对应三条根因。

### P1. 让长度不再是变量(结构性消除 > 阈值调大)

凡是可能随项目规模**无界增长**的载荷(对象列表、include 列表、库列表),必须满足:

1. 走响应文件,不进命令行;
2. 响应文件**按行分隔**;
3. 下游工具对响应文件**没有单行上限**。

第 3 条是本次新增的认识,也是前六次都没覆盖到的:**我们控制不了 driver 再生成的那个文件**。唯一的解法是让最终工具不带这个限制。

因此:**windows 上的 clang 链接改用 `-fuse-ld=lld`**。

> 这不是"换个工具绕过去"。理由有三:
> - lld 通过 LLVM 的 tokenizer 解析响应文件,**没有单行上限**——是消掉一整类,不是把某个数字调大;
> - 路径本身缩不短:per-package 那层目录正是 #344 需要的,其余是源码树自己的结构;
> - **linux 与 macOS 早就在用 lld**(`kLinkDriverFlags`)。windows 是唯一还在用系统链接器的平台,也是唯一有单行上限的。这是**消除平台不一致**,不是新增特例。
>
> 原生 cl.exe(`isMsvcDialect`)保持 link.exe:那条路径上响应文件是 mcpp 自己写的,2026.8.5.3 已经修好。

### P2. 剩余上限必须是表里的数据

新模块 `src/build/cmdlimits.cppm`,把执行通道与其上限写成一张表:

```cpp
enum class Channel {
    NinjaArgv,      // ninja 直接创建进程
    PosixShell,     // ninja 的 `sh -c "<整条命令>"`:整条是一个 argv 项
    CmdWrapper,     // `cmd /c`(#261 起已在全仓绝迹,留在表里以防复活)
    RspContent,     // 响应文件总量
    RspLine,        // 响应文件单行
};

struct Limit {
    Channel          channel;
    std::size_t      bytes;
    std::string_view where;      // 谁施加的
    std::string_view symptom;    // 撞上时用户会看到什么
    std::string_view remedy;     // 怎么消掉
};
```

表里同时记录**症状**——因为这一族缺陷最贵的部分从来不是修,而是**认出**它。`Argument list too long`、`LNK1170`、裸 `127` 这三种表现毫无共同点,下一次遇到第四种时,表能把人直接指到这里。

新增一个执行通道时,**必须在表里回答"你的上限是多少"**,否则加不进来——与 `directives::kTable` 里「Scope 是必填字段」同一个手法:把一个容易忘的问题变成结构上绕不过去的字段。

### P3. 在计划期校验,并且指名道姓

`ninja_backend` 发射每条边时,已经持有该边的全部输入。因此:

- 估算该边在**每个它会穿过的通道**上的字节数;
- 与表比对;
- 超限时:**能自动降级就降级**(例如内联 → rspfile),**不能降级就报错**,并给出 target 名、通道、实测字节数、上限、以及表里的 remedy。

关键是**报错时机**:在 `mcpp build` 刚开始、还没编译任何东西的时候,而不是 356 秒之后。

## 3. 实施步骤

| 步 | 内容 | 状态 |
|---|---|---|
| 1 | `-fuse-ld=lld` 用于 windows clang 链接(P1) | ✅ `flags.cppm` |
| 2 | 新模块 `cmdlimits.cppm`:通道表 + 预算/判定/诊断(P2) | ✅ |
| 3 | `ninja_backend` 生成 build.ninja 后统一校验(P3) | ✅ |
| 4 | 单测锁住表与诊断 | ✅ `tests/unit/test_cmdlimits.cpp`(8 条) |

### 实施中修正的两处判断

**(a) 校验点不在「发射每条边」,而在「manifest 生成之后统一扫描」。** 逐点插桩要改每个 emit site,而**新增一种边时没人会想起来加**——这正是前七次的漏法。改为扫描已生成的 manifest:新边当天就被覆盖。

**(b) `phony` 必须排除,否则会误报到 #274 的修复本身。** 一条 `build` 行长 ≠ 命令长:`phony` 根本没有 command。而 #274 为解决 argv 超限,正是把几千个目标收进一条 phony 聚合边——不排除的话,新校验会把那条边报成超限,把解法当成问题。走 rspfile 的规则同样豁免(命令里只有 `@$out.rsp`)。

判据因此是:**该边的 rule 有 command,且不走 rspfile** → 它的输入会进命令行 → 校验。

## 4. 验证

- **步 1**:mcpp-index 的 `opencv-module` / `opencv-module-dnn` 在 windows 上通过。这是当前唯一已知能触发的真实场景——本地无法复现(需要 windows + 那个规模的依赖图)。
- **步 2–3**:单测 `test_cmdlimits`(8 条)锁住表与诊断——每个通道都在表里、数字是实测的那些、每条都记了症状与解法、诊断里含边名/实测字节/上限/解法/文档路径。**没有做超限的 e2e**:P1 之后本地已经造不出自然超限的边(要造只能人为破坏 rspfile 规则,那测的是被破坏的代码而非真实路径)。
- 回归:全量单测 58/58;链接相关 e2e(28 / 47 / 86 / 07 / 148 / 190)绿;mcpp 自身 351 条边**零误报**。

## 5. 明确不做

- **不缩短对象路径**。per-package 那层是 #344 的正确性要求,缩回去就是拿正确性换长度。
- **不给"最大项目规模"设一个文档化的数字**。P1 的目标是让这个数字不存在;凡是还存在的,进表并在计划期校验。
- **不改 `cmd /c`**。#261 起它已在全仓 ninja 规则中绝迹,表里保留一行只是为了它某天复活时有人认得出。
- **本次不动原生 cl.exe 路径**。那里的响应文件是 mcpp 自己写的,2026.8.5.3 已覆盖。
