diff --git a/.agents/docs/2026-08-06-command-length-architecture.md b/.agents/docs/2026-08-06-command-length-architecture.md new file mode 100644 index 00000000..7476131a --- /dev/null +++ b/.agents/docs/2026-08-06-command-length-architecture.md @@ -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 已覆盖。 diff --git a/.agents/docs/2026-08-06-provisions-and-build-inputs.md b/.agents/docs/2026-08-06-provisions-and-build-inputs.md new file mode 100644 index 00000000..844b7192 --- /dev/null +++ b/.agents/docs/2026-08-06-provisions-and-build-inputs.md @@ -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 includeDirs; + std::vector includeDirsAfter; + std::vector 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 tools; + std::vector 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=` 是同一个解。 + +补上之后 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 块):那是独立缺陷,原因尚未定位。 diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 0c1f2613..543818e8 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -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 diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 8564a7c7..e518e61f 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -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 diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index 77bd107c..cd17ad0b 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -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 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index bc7a83dd..27ddb4ef 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.5.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.5.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -292,7 +292,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.5.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.5.2 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -363,7 +363,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.5.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.5.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 0d29cbc3..3bd7636e 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -123,7 +123,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.5.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.5.2 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 1a5cce45..43b7744d 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -118,7 +118,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.5.1' + XLINGS_VERSION: '2026.8.5.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -255,7 +255,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.5.1' + XLINGS_VERSION: '2026.8.5.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccdd8e40..a66d4695 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.5.1' + XLINGS_VERSION: '2026.8.5.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -288,7 +288,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.5.1' + XLINGS_VERSION: '2026.8.5.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -358,11 +358,11 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.5.1-linux-aarch64.tar.gz" + XLA="xlings-2026.8.5.2-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.5.1/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.5.2/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.5.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.5.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -440,7 +440,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.5.1' + XLINGS_VERSION: '2026.8.5.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -622,7 +622,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.5.1' + XLINGS_VERSION: '2026.8.5.2' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/.xlings.json b/.xlings.json index 3a76a836..220779fd 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.8.5.2" + "mcpp": "2026.8.5.3" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fd3d04..d4b112d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.5.4] — 2026-08-06 + +命令长度这一族缺陷的**第七次**,这次不再补洞。架构分析见 `.agents/docs/2026-08-06-command-length-architecture.md`。 + +### 修复 + +- **windows 上的 clang 链接改用 lld,消掉最后一个随对象数增长的上限。** 2026.8.5.3 把 mcpp**自己**写的响应文件改成按行分隔,但 clang 作为 driver 时会**再生成一个**响应文件转发给链接器,而那个是单行的 —— 我们改不到它。mcpp-index 的 `opencv-module` 因此在 2026.8.5.3 上仍然 `LNK1170`,而且是在编译完 356 秒之后。 + + **为什么现在才出现**:#344 给每个依赖的对象加了一层包目录(缓存正确性的要求),路径因此变长——同一条边在 linux 上从 56 840 涨到 161 687 字节。而 mcpp-index 的 CI 一直 pin 在 **2026.8.3.3**,正好是那之前一版,所以它的 windows 腿从没用长路径链接过。**把 pin 抬上来才第一次撞到**。 + + **为什么这不是 workaround**:lld 用 LLVM 的 tokenizer 解析响应文件,**没有单行上限** —— 消掉的是一整类,不是把某个数字调大;路径也缩不短(那层包目录正是 #344 需要的,其余是源码树自身结构)。而且 **linux 与 macOS 早就在用 lld**,windows 是唯一还在用系统链接器、也是唯一有单行上限的平台 —— 这是消除平台不一致。原生 cl.exe 保持 link.exe:那条路径上响应文件是我们自己写的。 + +### 改进 + +- **命令长度预算现在是一张表,不是七处注释(`src/build/cmdlimits.cppm`)。** 前七次每次都在注释里写下「构建系统不该有靠崩溃才发现的规模上限」,然后换个地方再犯。根因是:命令构造层对「这条命令要穿过哪些通道、每个通道上限多少」**一无所知**,而这份知识只存在于注释与 CHANGELOG 里 —— 是「同一决策在 N 处推导」的镜像:**一个关键约束在零处被表达**。 + + 表里除了字节数,还记**症状**:这一族最贵的从来不是修,而是**认出**。`posix_spawn: Argument list too long`、`LNK1170`、cmd.exe 的裸 `127` 三种表现毫无共同点,下次遇到第四种时表能直接把人指过来。新增执行通道必须回答「你的上限是多少」,与 `directives::kTable` 里「Scope 是必填字段」同一个手法。 + +- **超限改为在计划期拦下并指名道姓。** 以前是 ninja 或 link.exe 在构建末尾崩,而报错的是别人的程序,信息里没有 mcpp 的上下文(哪条边、哪个包、多少对象)。现在生成完 `build.ninja` 就统一扫描,超限时报出**边名、通道、实测字节、上限、解法、文档路径**,且发生在还没编译任何东西的时候。 + + 两处判断在实施中被纠正:校验点选在「manifest 生成后统一扫描」而不是「逐个 emit site 插桩」——后者在新增一种边时没人会想起来加,而那正是前七次的漏法;`phony` 必须排除,否则会误报到 **#274 的修复本身**(它为解决 argv 超限,正是把几千个目标收进一条 phony 聚合边,而 phony 根本没有 command)。 + +### 其他 + +- 内带 xlings 升到 **2026.8.5.2**(`.github/` 下 16 处 pin 由 `check_version_pins.sh` 机器校验同步)。 + ## [2026.8.5.3] — 2026-08-05 ### 修复 diff --git a/mcpp.toml b/mcpp.toml index 141f19b5..07e487b7 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.5.3" +version = "2026.8.5.4" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/cmdlimits.cppm b/src/build/cmdlimits.cppm new file mode 100644 index 00000000..9f448589 --- /dev/null +++ b/src/build/cmdlimits.cppm @@ -0,0 +1,136 @@ +// mcpp.build.cmdlimits — the command-length budget, as data. +// +// WHY THIS MODULE EXISTS +// +// Seven times now, a build has died because some command grew past a ceiling +// nobody was tracking (#247, #261 ×2, #274, #344, 2026.8.5.3, 2026.8.5.4). +// Every one of them was found the same way — by crashing, at the end of a +// build, with an error that named neither the edge nor the cause: +// +// ninja: fatal: posix_spawn: Argument list too long +// LNK1170: line in command file contains 135135 or more characters +// +// +// And every one of them was TRIGGERED by an unrelated change: #344 was a cache +// correctness fix that made object paths longer, #274 changed error-report +// granularity, 2026.8.5.4 was a CI pin bump. Nothing in the code connected +// "the thing I am editing" to "how long the resulting command will be", +// because that knowledge lived only in comments. +// +// So this is not another ceiling. It is the missing contract: a table of the +// channels a command travels through and what each of them will tolerate, +// plus the SYMPTOM each produces — because with this family the expensive part +// has never been the fix, it has been recognising it. The three messages above +// have nothing in common; the next one will look different again, and the +// table is where someone should be able to find it. +// +// The primary defence is still structural (see +// .agents/docs/2026-08-06-command-length-architecture.md §2 P1): unbounded +// payloads go through response files, newline-separated, to tools that have no +// per-line limit. This module is the backstop for whatever that misses. +export module mcpp.build.cmdlimits; + +import std; + +export namespace mcpp::build::cmdlimits { + +// A command does not meet one limit — it travels through a chain of them. +enum class Channel { + NinjaArgv, // ninja creates the process directly + PosixShell, // ninja wraps in `sh -c ""` — ONE argv entry + CmdWrapper, // `cmd /c` (gone since #261; kept so a revival is legible) + RspContent, // a response file, in total + RspLine, // a response file, per line +}; + +struct Limit { + Channel channel; + std::size_t bytes; + std::string_view imposedBy; + std::string_view symptom; // what the user actually sees + std::string_view remedy; +}; + +// Sizes are the real ones, not round numbers: MAX_ARG_STRLEN is 32 pages, and +// the 2 MiB ARG_MAX everyone reaches for first is the wrong limit (#344 spent +// a release believing it). +inline constexpr std::array kTable{{ + {Channel::NinjaArgv, 32u * 1024u, "Windows CreateProcess", + "the command does not run; ninja reports a spawn failure", + "route the unbounded payload through a response file"}, + + {Channel::PosixShell, 128u * 1024u, "POSIX MAX_ARG_STRLEN (32 pages)", + "ninja: fatal: posix_spawn: Argument list too long — names no edge, " + "no file, no cause", + "route the unbounded payload through a response file; note ARG_MAX " + "(2 MiB) is NOT the limit that applies here"}, + + {Channel::CmdWrapper, 8191u, "cmd.exe", + "a bare exit code 127: the command never ran, so neither ninja nor mcpp " + "prints anything", + "do not let a rule need a shell — no redirection, no `cmd /c` (#261 " + "removed the last one)"}, + + {Channel::RspContent, 0u, "no practical limit on any supported tool", + "n/a", "n/a"}, + + {Channel::RspLine, 128u * 1024u, "link.exe / lib.exe", + "LNK1170: line in command file contains N or more characters — names no " + "target", + "write the response file newline-separated, AND link with lld: a driver " + "may generate a SECOND response file we do not control, and only the " + "final tool's parser decides whether a line length matters"}, +}}; + +constexpr const Limit& limit_of(Channel c) { + for (auto const& l : kTable) + if (l.channel == c) return l; + return kTable[0]; // unreachable: every enumerator is in the table +} + +// The tightest limit a command must satisfy on this platform, given whether +// its rule needs a shell. Response-file payloads are excluded — they are the +// thing being moved OUT of the command. +constexpr std::size_t inline_budget(bool hostIsWindows, bool needsShell) { + if (hostIsWindows) + return needsShell ? limit_of(Channel::CmdWrapper).bytes + : limit_of(Channel::NinjaArgv).bytes; + // ninja runs POSIX commands through `sh -c`, so the whole command is a + // single argv entry whichever way you look at it. + return limit_of(Channel::PosixShell).bytes; +} + +struct Overrun { + Channel channel; + std::size_t actual; + std::size_t allowed; +}; + +// `text` is what will land on the command line — NOT response-file content. +constexpr std::optional check_inline(std::string_view text, + bool hostIsWindows, + bool needsShell) { + const std::size_t allowed = inline_budget(hostIsWindows, needsShell); + if (text.size() <= allowed) return std::nullopt; + const Channel c = hostIsWindows + ? (needsShell ? Channel::CmdWrapper : Channel::NinjaArgv) + : Channel::PosixShell; + return Overrun{c, text.size(), allowed}; +} + +// Deliberately verbose: this message exists so that the eighth occurrence is +// diagnosed in seconds rather than in a release. It names the edge — which is +// exactly what every underlying error fails to do — and it fires while +// generating build.ninja, before anything has been compiled, rather than at +// the end of a build that has already spent its time. +std::string explain(std::string_view what, const Overrun& o) { + auto const& l = limit_of(o.channel); + return std::format( + "{}: command is {} bytes, over the {} byte limit imposed by {}.\n" + " Left alone this surfaces as: {}\n" + " Fix: {}\n" + " Background: .agents/docs/2026-08-06-command-length-architecture.md", + what, o.actual, l.bytes, l.imposedBy, l.symptom, l.remedy); +} + +} // namespace mcpp::build::cmdlimits diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 56229969..de26950e 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -752,8 +752,42 @@ CompileFlags compute_flags(const BuildPlan& plan) { } // PE link, MSVC-ABI Clang (native MinGW is handled by the target-keyed // branch above and has already returned): no rpath/loader/payload — - // MSVC STL/SDK come via the driver, nothing extra needed. - f.ld = std::format("{}{}", user_ldflags, link_extra); + // MSVC STL/SDK come via the driver. + // + // `-fuse-ld=lld` is the one thing that IS needed, and it removes the + // last object-count ceiling in the build. + // + // WHY IT SURFACED NOW. #344 gave every dependency's objects a + // per-package directory, because the previous layout let one cache + // entry hold two different ones. Necessary, and it made every object + // path longer — the same link edge went from 56 840 to 161 687 bytes + // on linux. mcpp-index's CI had been pinned to 2026.8.3.3, one release + // BEFORE that, so its windows leg had never linked with the longer + // paths. Raising that pin is what first reached link.exe's limit: + // + // C:\…\Temp\response-4b66e9.txt : fatal error LNK1170: line in + // command file contains 135135 or more characters + // + // WHY THE PREVIOUS FIX DID NOT COVER IT. 2026.8.5.3 made mcpp's own + // response file newline-separated. That was necessary and is not + // redundant — under the msvc dialect mcpp invokes link.exe directly, + // and that file is ours. But here clang is the driver: it reads our + // file and writes a SECOND one for the linker, on a single line. No + // amount of formatting on our side reaches a file clang generates. + // + // WHY THIS IS NOT A WORKAROUND. It does not raise a threshold; it + // removes the class. lld parses response files through LLVM's + // tokenizer, which has no per-line limit, so nothing here scales with + // the number of objects any more. Nor can the paths simply be made + // shorter: the per-package component is what #344 needs, and the rest + // is the source tree's own layout. And it removes an inconsistency + // rather than adding a special case — linux and macOS already link + // with lld (kLinkDriverFlags); windows was the only platform left on + // the system linker, and the only one with a per-line ceiling. + // + // Native cl.exe (isMsvcDialect, returned above) keeps link.exe: there + // the response file is ours, and 2026.8.5.3 already fixed it. + f.ld = std::format(" -fuse-ld=lld{}{}", user_ldflags, link_extra); } else if constexpr (mcpp::platform::needs_explicit_libcxx) { // macOS. The C++ runtime itself is decided by the contract table above // (dist::Format::MachO) and rides unit_ldflags; what is left here is diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index b1f9a3a9..b6a95325 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -25,6 +25,7 @@ import mcpp.build.plan; import mcpp.build.flags; import mcpp.build.hermetic; import mcpp.build.compile_commands; +import mcpp.build.cmdlimits; import mcpp.diag; import mcpp.dyndep; import mcpp.toolchain.detect; @@ -1422,6 +1423,57 @@ std::string append_goal_phony(std::string& manifest, return std::string(kGoalPhony); } + +// Edges whose rule has no `rspfile` carry their inputs ON THE COMMAND LINE, so +// their `build` line length is what the OS will have to accept. Edges that DO +// have one are exempt: their command holds `@$out.rsp` and nothing else that +// grows. +// +// Scanning the emitted manifest rather than instrumenting each emit site is +// deliberate — a new edge kind is then covered the day it is added, which is +// precisely how the previous seven slipped through. +std::optional check_inline_command_lengths(const std::string& manifest) { + std::set rspRules; + std::string current; + for (auto line : manifest | std::views::split('\n')) { + std::string_view l{line.begin(), line.end()}; + if (l.starts_with("rule ")) { + current = std::string(l.substr(5)); + } else if (!current.empty() && l.find("rspfile") != std::string_view::npos + && l.find("rspfile_content") == std::string_view::npos) { + rspRules.insert(current); + } else if (l.empty()) { + current.clear(); + } + } + + for (auto line : manifest | std::views::split('\n')) { + std::string_view l{line.begin(), line.end()}; + if (!l.starts_with("build ")) continue; + auto colon = l.find(" : "); + if (colon == std::string_view::npos) continue; + auto rest = l.substr(colon + 3); + auto sp = rest.find(' '); + std::string rule(sp == std::string_view::npos ? rest : rest.substr(0, sp)); + if (rspRules.contains(rule)) continue; + // `phony` has no command at all, so its inputs never reach a command + // line however many there are. Checking it would fire on exactly the + // edge #274 introduced to SOLVE this problem — the goal aggregate, + // whose whole point is that thousands of targets cost one word. + if (rule == "phony") continue; + + // `sh -c` on POSIX; on windows nothing needs a shell since #261. + auto over = mcpp::build::cmdlimits::check_inline( + l, mcpp::platform::is_windows, /*needsShell=*/!mcpp::platform::is_windows); + if (!over) continue; + + auto out = l.substr(6, colon - 6); + return mcpp::build::cmdlimits::explain( + std::format("build edge '{}' (rule {})", out, rule), *over); + } + return std::nullopt; +} + std::expected NinjaBackend::build(const BuildPlan& plan, const BuildOptions& opts) { auto t0 = std::chrono::steady_clock::now(); @@ -1435,6 +1487,16 @@ std::expected NinjaBackend::build(const BuildPlan& plan auto ninja_path = plan.outputDir / "build.ninja"; auto manifest = emit_ninja_string(plan); + + // Command-length backstop (see + // .agents/docs/2026-08-06-command-length-architecture.md). The structural + // defence is that every unbounded payload goes through a response file; + // this catches the case where something new does not, and it catches it + // HERE — while writing build.ninja, before a single object is compiled — + // rather than as a spawn failure at the end of a long build that names + // neither the edge nor the reason. + if (auto over = check_inline_command_lengths(manifest)) + return std::unexpected(BuildError{*over, ninja_path}); auto goalArg = append_goal_phony(manifest, opts.ninjaTargets); write_file(ninja_path, manifest); diff --git a/src/version.cppm b/src/version.cppm index e875dfb4..cf953421 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.5.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.5.4"; } // namespace mcpp diff --git a/src/xlings.cppm b/src/xlings.cppm index 94e9db79..492673d9 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -44,7 +44,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.5.1"; + inline constexpr std::string_view kXlingsVersion = "2026.8.5.2"; inline constexpr std::string_view kNasmVersion = "3.02"; } diff --git a/tests/unit/test_cmdlimits.cpp b/tests/unit/test_cmdlimits.cpp new file mode 100644 index 00000000..8aaec1c6 --- /dev/null +++ b/tests/unit/test_cmdlimits.cpp @@ -0,0 +1,103 @@ +#include + +import std; +import mcpp.build.cmdlimits; + +// The command-length budget, as data. Seven builds have died because a command +// outgrew a ceiling nobody tracked, and each was found by crashing at the end +// of a build with an error that named neither the edge nor the cause. These +// tests hold the two properties that make the table worth having: the numbers +// are the REAL ones, and the diagnostic says enough to act on. +// +// See .agents/docs/2026-08-06-command-length-architecture.md. + +namespace cl = mcpp::build::cmdlimits; + +namespace { + +TEST(CmdLimits, EveryChannelIsInTheTable) { + // A channel that is not in the table has no budget, which is exactly the + // state that produced this defect family seven times. `limit_of` must find + // a real row for each enumerator, not fall through to the placeholder. + for (auto c : {cl::Channel::NinjaArgv, cl::Channel::PosixShell, + cl::Channel::CmdWrapper, cl::Channel::RspContent, + cl::Channel::RspLine}) { + EXPECT_EQ(cl::limit_of(c).channel, c); + EXPECT_FALSE(cl::limit_of(c).imposedBy.empty()); + } +} + +TEST(CmdLimits, NumbersAreTheRealOnes) { + // Each of these was learned the expensive way; a "tidier" number would be + // wrong. MAX_ARG_STRLEN is 32 pages — NOT the 2 MiB ARG_MAX that #344 + // spent a release believing in. + EXPECT_EQ(cl::limit_of(cl::Channel::PosixShell).bytes, 128u * 1024u); + EXPECT_EQ(cl::limit_of(cl::Channel::NinjaArgv).bytes, 32u * 1024u); + EXPECT_EQ(cl::limit_of(cl::Channel::CmdWrapper).bytes, 8191u); + EXPECT_EQ(cl::limit_of(cl::Channel::RspLine).bytes, 128u * 1024u); +} + +TEST(CmdLimits, EverySymptomIsRecorded) { + // The costly part of this family has never been the fix — it has been + // RECOGNISING it. `Argument list too long`, `LNK1170` and a bare 127 have + // nothing in common, so each row has to carry what the user actually sees. + for (auto c : {cl::Channel::NinjaArgv, cl::Channel::PosixShell, + cl::Channel::CmdWrapper, cl::Channel::RspLine}) { + auto const& l = cl::limit_of(c); + EXPECT_FALSE(l.symptom.empty()) << "channel has no symptom recorded"; + EXPECT_FALSE(l.remedy.empty()) << "channel has no remedy recorded"; + } +} + +TEST(CmdLimits, ShellIsTighterThanDirectSpawnOnWindows) { + // cmd.exe is a quarter of CreateProcess, which is why #261's redirection + // (it forced a `cmd /c` wrapper) failed where the direct spawn would have + // been fine. + EXPECT_LT(cl::inline_budget(/*win=*/true, /*shell=*/true), + cl::inline_budget(/*win=*/true, /*shell=*/false)); +} + +TEST(CmdLimits, UnderBudgetPasses) { + EXPECT_FALSE(cl::check_inline(std::string(1000, 'x'), false, true)); + EXPECT_FALSE(cl::check_inline(std::string(1000, 'x'), true, false)); +} + +TEST(CmdLimits, OverBudgetIsReportedWithBothNumbers) { + // POSIX: ninja wraps in `sh -c`, so the whole command is one argv entry. + auto over = cl::check_inline(std::string(200u * 1024u, 'x'), false, true); + ASSERT_TRUE(over); + EXPECT_EQ(over->channel, cl::Channel::PosixShell); + EXPECT_EQ(over->actual, 200u * 1024u); + EXPECT_EQ(over->allowed, 128u * 1024u); +} + +TEST(CmdLimits, WindowsPicksTheChannelItActuallyTravels) { + // 50 781 bytes is #274's real measurement (FFmpeg's 2281 units): fine for + // CreateProcess, fatal through cmd.exe. + const std::string cmd(50781, 'x'); + auto direct = cl::check_inline(cmd, /*win=*/true, /*shell=*/false); + auto shell = cl::check_inline(cmd, /*win=*/true, /*shell=*/true); + ASSERT_TRUE(direct); + EXPECT_EQ(direct->channel, cl::Channel::NinjaArgv); + ASSERT_TRUE(shell); + EXPECT_EQ(shell->channel, cl::Channel::CmdWrapper); +} + +TEST(CmdLimits, DiagnosticNamesTheEdgeTheNumbersAndTheWayOut) { + // Every underlying error omits the edge; that omission is what made these + // expensive. The message must supply it, plus enough to act without + // reading the source. + auto over = cl::check_inline(std::string(200u * 1024u, 'x'), false, true); + ASSERT_TRUE(over); + const std::string msg = cl::explain("build edge 'bin/app' (rule cxx_link)", *over); + + EXPECT_NE(msg.find("bin/app"), std::string::npos); + EXPECT_NE(msg.find("cxx_link"), std::string::npos); + EXPECT_NE(msg.find("204800"), std::string::npos); // actual + EXPECT_NE(msg.find("131072"), std::string::npos); // allowed + EXPECT_NE(msg.find("MAX_ARG_STRLEN"), std::string::npos); + EXPECT_NE(msg.find("response file"), std::string::npos); + EXPECT_NE(msg.find("2026-08-06-command-length-architecture"), std::string::npos); +} + +} // namespace