Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions .agents/docs/2026-08-06-command-length-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# 命令长度:把「靠崩溃发现的规模上限」从架构上消掉

> 状态:**已实施(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 已覆盖。
177 changes: 177 additions & 0 deletions .agents/docs/2026-08-06-provisions-and-build-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# 依赖提供物与构建期输入:两个缺口,同一个形状

> 状态:**设计待 review**
> 关联:[#359](https://github.com/mcpp-community/mcpp/issues/359)(由 grpc-m 的真实使用暴露)
> 涉及:`src/modgraph/scanner.cppm`(`UsageRequirements`)、`src/build/prepare.cppm`、
> `src/build/build_program.cppm`、`src/build/directives.cppm`

---

## 0. 目标:用户侧从 4 条依赖 + 逐个列名,降到 1 条 + 1 行

grpc-m 今天要求用户写:

```toml
[dependencies.mcpplibs]
grpc = "1.83.0"
grpc-plugin = { version = "1.83.0", tools = ["grpc_cpp_plugin"] }
grpcgen = { version = "1.83.0", host-module = true }
[dependencies.compat]
protobuf = { version = "35.1", tools = ["protoc"] }
```

```cpp
import mcpp; import grpcgen;
int main() { return grpcgen::generate({"helloworld"}) ? 0 : 1; }
```

后三条**全是为了 codegen**,而且要求用户知道「gRPC 的代码生成需要 protobuf 的 protoc」——这是**库该承担的知识**。目标形态:

```toml
grpc = { version = "1.83.0", features = ["codegen"] }
```
```cpp
import mcpp; import grpcgen;
int main() { return grpcgen::generate_all() ? 0 : 1; } // 扫 proto/**
```

对照业界:xmake 是 `add_requires("grpc")` + `add_files("proto/*.proto")`;CMake+vcpkg 是 1 条依赖 + `protobuf_generate(...)`。达到目标形态后 mcpp **严格更优** —— 因为它还额外保有「版本错配不可表达」与「交叉编译构造上正确」这两条别人没有的性质。

两个缺口各挡住一半,**缺一个都到不了**。

## 1. 关键发现:模型已经存在,新东西没接进去

mcpp 早有一套「依赖能提供什么 × 提供给谁」的模型(`src/modgraph/scanner.cppm`):

```cpp
struct UsageRequirements {
std::vector<std::filesystem::path> includeDirs;
std::vector<std::filesystem::path> includeDirsAfter;
std::vector<std::string> cflags, cxxflags, ldflags, modules;
};

struct PackageRoot {
UsageRequirements privateBuild; // 只给自己
UsageRequirements publicUsage; // 沿边传给消费者
UsageRequirements linkUsage; // 链接期
};
```

include dirs、defines、link flags、modules 全都通过它传播,规则清楚、单点定义。

**#355 引入的两种新提供物没有进入这个模型**:

| 提供物 | 在模型里? | 实际实现 |
|---|---|---|
| include dirs / defines / ldflags / modules | ✅ `UsageRequirements` | 按作用域传播 |
| **host 工具**(`tools = [...]`) | ❌ | `prepare.cppm:4113` 硬编码 `toolEnvByConsumer[edge.consumerPackageIndex]` —— 只给**发出请求的那条边**的消费者 |
| **host 模块**(`host-module = true`) | ❌ | `prepare.cppm:4016` 只遍历 `m->dependencies`,即**只认 root 的**直接依赖 |

于是「库代用户拉起整条 codegen 工具链」在架构上不可能:工具**被构建了**,但环境变量记在库的账上,消费者的 `build.mcpp` 看不见。

> 实测确认(不是推断):一个 path 依赖在自己的 manifest 里写 `compat.protobuf = { tools = ["protoc"] }`,消费者 `mcpp::dep_bin("protobuf","protoc")` 拿到**空串**,`dep_dir` 同样为空。

**根因不是「少了一次传播」,而是:新增一种提供物时,没有任何地方逼你回答「它怎么传播」。** 这与本仓库反复付学费的「同一决策在 N 处推导」是同一形状的镜像——一个必答问题在**零处**被表达。`directives::kTable` 已经用「Scope 是必填字段」解过一次。

## 2. 缺口 B 同构:输入声明的种类是封闭的

`build.mcpp` 的缓存键由**声明过的输入**构成,而输入只有两种形态:

```cpp
// build_program.cppm:283
os << "in " << hash_file(abs_against_root(root, f)) << ' ' << f << '\n'; // 文件内容
os << "env " << hash_string(env_value(e)) << ' ' << e << '\n'; // 环境变量
```

`hash_file` 读的是**文件内容**。于是「我的输出取决于这个目录里有哪些文件」**无法表达**:

- 对目录调用 `rerun_if_changed` 无效(目录没有可读内容);
- 新增一个 `.proto` 不改变任何已声明文件的哈希 → build.mcpp 不重跑 → **新文件静默不生成**。

实测:glob `proto/**` 后新增 `fresh.proto`,`Finished dev in 0.01s`,产物 0 个。这比「要求用户列名字」更坏,所以 grpc-m 最终选了显式列表。

同样的形状:**新增一种输入时,没有地方回答「它的指纹怎么取」。**

## 3. 设计

一条主张:**两个缺口都收敛成「表 + 必答字段」,与 `directives::kTable` 同一范式**,而不是各打一个补丁。

### D1. 提供物进 `UsageRequirements`,传播由作用域决定

```cpp
struct UsageRequirements {
// …既有字段…
// #359: host 工具与 host 模块。放在这里而不是旁路,是为了让「它怎么
// 传播」由所在的作用域回答,与 includeDirs 完全同一条规则。
std::vector<ToolProvision> tools;
std::vector<HostModuleProvision> hostModules;
};
```

- 放进 `privateBuild` → 只有该包自己的 `build.mcpp` 能用;
- 放进 `publicUsage` → 沿 **public 边**传给消费者。

于是 `grpc` 可以在描述符里声明「我的 codegen feature 对外提供 protoc 与 grpc_cpp_plugin」,消费者只写一条依赖。

**三条必须写死的语义**,否则这会变成一个安全与可维护性的洞:

1. **传播的是「可见性」,不是「自动执行」。** `dep_bin()` 只返回路径;跑不跑由消费者的 `build.mcpp` 决定。传播不改变「谁构建了这个工具」,也不改变 tool store 的键。
2. **必须显式声明,不能默认传播。** 默认传播意味着任意深层依赖都能往消费者的工具命名空间里塞东西——那是供应链问题。库要对外提供,必须自己写明(与 `include_dirs` 默认 private、要 public 得显式是同一条纪律)。
3. **命名冲突用包名消歧**,`dep_bin(pkg, tool)` 本来就是两段式,无需新语法。

> 顺带修掉一个相邻缺陷:`dep_dir()` 目前只覆盖**直接**依赖,所以传递依赖的数据文件目录取不到(protoc 的 well-known types 就是这么一个目录)。它应与 tools 走同一条传播规则。

### D2. 输入种类进表,指纹由种类决定

```cpp
enum class InputKind {
File, // 内容哈希(现有)
Directory, // 递归成员集合:相对路径 + size + mtime,不读内容
Env, // 环境变量(现有)
};
```

`Directory` 的指纹**只取集合**,不取内容——内容变化由集合里的 `File` 条目负责。这与 Cargo 的 `cargo:rerun-if-changed=<dir>` 是同一个解。

补上之后 glob 从「结构性不安全」变成一等用法,规则包才能提供 `generate_all()`:

```cpp
mcpp::rerun_if_changed_dir("proto"); // 集合变了就重跑
```

**代价要写明**:目录指纹用 mtime,而 mtime 在某些场景(容器构建、git checkout)不稳定。因此:
- 只把**成员集合**纳入指纹,不把内容纳入 → 误重跑的代价只是一次 build.mcpp 重跑(秒级),不是全量重编;
- 不递归进符号链接(与既有扫描一致)。

### D3. 为什么这两条必须一起做

只做 D1:用户从 4 条降到 1 条,但仍要在 `build.mcpp` 里逐个列 `.proto`。
只做 D2:用户不必列 proto,但仍要写 4 条依赖并知道 gRPC 需要 protobuf 的 protoc。

**两条合起来**才是目标形态,也才是「对齐并超过业界」的那一步。

## 4. 实施步骤

| 步 | 内容 | 风险 |
|---|---|---|
| 1 | `UsageRequirements` 加 tools / hostModules 两个字段,`privateBuild` 行为保持今天不变 | 低,纯新增 |
| 2 | 沿 public 边聚合(复用 features 的边聚合路径,#242/#243 已有先例) | 中——要确认不会把 private 依赖的工具泄漏出去 |
| 3 | 描述符/manifest 侧:声明「对外提供」的语法 | 中——是新的用户可见语法,需按 Schema Ownership Principle 审 |
| 4 | `dep_dir()` 覆盖传递依赖 | 低 |
| 5 | `InputKind` 表 + `Directory` 指纹 + `rerun_if_changed_dir` | 低 |
| 6 | grpc-m 侧改成 1 条依赖 + `generate_all()`,作为真实验证 | —— |

步 1–4 是缺口 A,步 5 是缺口 B,步 6 是端到端证据。

## 5. 验证

- **单测**:传播规则(private 不外泄、public 沿边传、冲突消歧)、目录指纹(增删文件变、改内容不变、mtime 抖动不误伤集合)。
- **e2e**:一个库对外提供工具 + 一个消费者只写一条依赖就能在 `build.mcpp` 里 `dep_bin` 到;新增一个文件后 glob 场景确实重跑。
- **真实场景**:grpc-m 的模板降到 1 条依赖 + 1 行 build.mcpp,且生成产物仍与官方 protoc 逐字节相同(该基线已在 2026.8.5.x 建立)。

## 6. 明确不做

- **不让传播默认开启**。库必须显式声明对外提供,理由见 D1 第 2 条。
- **不把目录内容纳入指纹**。那会把一次 build.mcpp 重跑放大成全量重编,而收益为零(内容变化本来就由 File 条目覆盖)。
- **不引入「工具版本独立于依赖版本」的语法**。单一版本轴正是「错配不可表达」的来源,是本设计要保住的性质。
- **不在本轮解决 windows 的工具子构建失败**(见 mcpp-index 的 compat.protobuf windows 块):那是独立缺陷,原因尚未定位。
2 changes: 1 addition & 1 deletion .github/actions/bootstrap-mcpp/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ inputs:
# `package.name`, so one of the two was simply unreachable — and which one
# depended on the machine, which is why CI failed on `compat:lua` on
# Windows and `mcpplibs.capi:lua` on Linux. Never pin below that.
default: '2026.8.5.1'
default: '2026.8.5.2'
cache-target:
description: also restore/save target/ (build artifacts + BMIs)
required: false
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/setup-macos-llvm/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ inputs:
# Floor imposed by the index, not a routine bump — see
# .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required
# (two packages named `lua` in one repo need openxlings/xlings#381).
default: '2026.8.5.1'
default: '2026.8.5.2'

runs:
using: composite
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/bootstrap-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
# Dormant (workflow_dispatch only), but kept in step with the rest —
# check_version_pins.sh holds it there. Floor: 0.4.69, below which the
# index cannot resolve two packages that share a short name.
XLINGS_VERSION: '2026.8.5.1'
XLINGS_VERSION: '2026.8.5.2'
steps:
- uses: actions/checkout@v4

Expand Down
Loading
Loading