Skip to content

Latest commit

 

History

History
158 lines (107 loc) · 9.22 KB

File metadata and controls

158 lines (107 loc) · 9.22 KB

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

状态:已实施(2026.8.5.4) 触发:mcpp-index 的 opencv-module 在 windows 上 LNK1170,这是同一族缺陷的第七次 涉及:src/build/ninja_backend.cppmsrc/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,把执行通道与其上限写成一张表:

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 longLNK1170、裸 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 已覆盖。