From 6ce62fdd719ca68cbd349ceba7eb386dd82b1280 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:06:26 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=BA=90=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E8=A1=A8=20=E4=B8=8E=20build.mcpp=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=8A=E9=99=90=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E7=A1=AC=E7=BC=96=E7=A0=81=E5=8F=98=E6=88=90?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E5=A3=B0=E6=98=8E=20(#272,=20#410)=20(2026.8?= =?UTF-8?q?.11.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把「哪个扩展名是模块接口」和「build.mcpp 能跑多久」这两个决策从代码里拿出来, 变成 mcpp.toml 的两条声明,并顺手把它们背后的架构债与跨平台缺口补上。 新增 [build] module_extensions:additive 到内置 .cppm。声明一个扩展名会同时 让默认 sources glob 找到它、让它走模块规则(产 BMI、.o 无条件进链接)、并让 新鲜度快路径扫描它 —— 一个键而不是三处配置。拒绝已代表其他角色的扩展名。 新增 [build] build_program_timeout:优先级 env > 该包自己的 manifest > 内置 600s。超时报错点名要改的那份 mcpp.toml —— 依赖超时时改自己的那份不会有效果。 optional 承重:int 的话「没写」与「写 0」不可区分,而 0 意为不限。 架构:「扩展名 → 角色」原本在 9 个文件 20 处推导、8 份互不一致的清单。现在 分类只发生一次(SourceUnit::kind → CompileUnit::kind),下游读字段。#272 修了 链接侧却漏了 pick_rule —— 边上声明 BMI 而命令行丢了 -fmodule-output=。 实测(GCC 16.1 / Clang 22.1):Clang 根本不认 .ixx,把它当链接输入、退出码 0、 不产 BMI。而显式旗标在已识别后缀上幂等(Clang .cppm 的 BMI 逐字节相同)。 ⇒ 不维护「谁认哪个后缀」这张会过期且错了静默的表,永远显式告诉编译器。 跨平台:capture_exec_deadline 此前只在 POSIX 生效,Windows 直接回落无界路径, 于是 mcpp test --timeout / --build-timeout / 这个新键在那里全是空操作。现在 两侧各有实现(Windows 用 Job object 杀整棵树,否则孙进程攥着捕获管道会让 杀掉之后的读取挂住),process.cppm 单点 if constexpr 分派。 src/platform/ 拆成 unix/ windows/ linux/ macos/。 可观察性:mcpp self doctor 报告生效的扩展名表、超时值及其来源、deadline 是否 真的强制;module_extensions 零命中的条目告警。 顺带修掉三个发现的缺陷:isModuleInterface/isImplementation 是写而不读的死字段 (5 写 0 读、3 份不一致推导)⇒ 删除;is_implementation_source 漏 .mm 导致 Objective-C++ 对象永不进链接;stage 兜底 glob 漏全部三种汇编扩展名。 兼容:未配置时构建图零差分(同样的文件、BMI、链接对象、指纹目录)。 module_extensions 进指纹(改图形态),build_program_timeout 不进(不改任何边)。 设计:.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md --- ...ce-kind-table-and-build-program-timeout.md | 913 ++++++++++++++++++ .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 +- .github/workflows/release.yml | 14 +- docs/05-mcpp-toml.md | 79 ++ docs/07-build-mcpp.md | 50 +- docs/10-publishing-a-library.md | 18 + docs/zh/05-mcpp-toml.md | 65 ++ docs/zh/07-build-mcpp.md | 41 +- mcpp.toml | 2 +- src/build/build_program.cppm | 19 +- src/build/compile_commands.cppm | 15 +- src/build/directives.cppm | 90 +- src/build/execute.cppm | 34 +- src/build/ninja_backend.cppm | 131 ++- src/build/plan.cppm | 74 +- src/build/prepare.cppm | 40 +- src/build/program_protocol.cppm | 133 +++ src/doctor.cppm | 54 ++ src/manifest/toml.cppm | 82 +- src/manifest/types.cppm | 23 + src/modgraph/graph.cppm | 17 +- src/modgraph/p1689.cppm | 26 +- src/modgraph/scanner.cppm | 61 +- src/platform/{ => linux}/linux.cppm | 0 src/platform/{ => macos}/macos.cppm | 0 src/platform/platform.cppm | 53 +- src/platform/process.cppm | 214 ++-- src/platform/unix/bounded_process.cppm | 231 +++++ src/platform/windows/bounded_process.cppm | 288 ++++++ src/platform/{ => windows}/windows.cppm | 0 src/source_kind.cppm | 372 +++++++ src/toolchain/model.cppm | 34 + src/version.cppm | 2 +- src/xlings.cppm | 2 +- .../e2e/186_build_mcpp_protocol_and_bound.sh | 46 + tests/e2e/217_module_extensions.sh | 132 +++ .../e2e/218_module_extensions_graph_shape.sh | 139 +++ tests/unit/test_build_directives.cpp | 36 +- tests/unit/test_build_stage.cpp | 1 + tests/unit/test_compile_commands.cpp | 1 + tests/unit/test_configure.cpp | 1 + tests/unit/test_modgraph.cpp | 21 +- tests/unit/test_ninja_backend.cpp | 39 + tests/unit/test_source_kind.cpp | 227 +++++ 49 files changed, 3477 insertions(+), 361 deletions(-) create mode 100644 .agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md create mode 100644 src/build/program_protocol.cppm rename src/platform/{ => linux}/linux.cppm (100%) rename src/platform/{ => macos}/macos.cppm (100%) create mode 100644 src/platform/unix/bounded_process.cppm create mode 100644 src/platform/windows/bounded_process.cppm rename src/platform/{ => windows}/windows.cppm (100%) create mode 100644 src/source_kind.cppm create mode 100755 tests/e2e/217_module_extensions.sh create mode 100755 tests/e2e/218_module_extensions_graph_shape.sh create mode 100644 tests/unit/test_source_kind.cpp diff --git a/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md b/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md new file mode 100644 index 00000000..94da492a --- /dev/null +++ b/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md @@ -0,0 +1,913 @@ +# 源文件角色表 与 build.mcpp 运行上限 —— 把两个硬编码变成两条声明 + +> 日期:2026-08-11 +> 基线:main `e53204a`(`2026.8.10.3`) +> 来源:[PR #272](https://github.com/mcpp-community/mcpp/pull/272)(OPEN,未合)、 +> [issue #410](https://github.com/mcpp-community/mcpp/issues/410)(OPEN) +> 本文引用的 file:line 全部核于上述基线;行号是证据,不是装饰。 + +--- + +## 0. 结论先行 + +两个诉求表面无关,底下是同一个形状:**一个本该是数据的决策,现在是代码。** + +| | PR #272 | issue #410 | +|---|---|---| +| **诉求** | `.ccm` / `.cxxm` / `.ixx` 也要能当模块接口 | build.mcpp 跑久一点别被直接杀掉 | +| **现在的形态** | 「扩展名 → 角色」在 **20 处独立推导**,分成 **8 份互不一致的清单** | `run_timeout()` 是个**无参函数**,唯一入口是环境变量 | +| **改成** | 单一分类器 `SourceKind` + `[build] module_extensions` | `[build] build_program_timeout` | +| **默认行为** | **图形态零差分**(内置表不动,仍只有 `.cppm`;rule 文本会变,见 §3.8.3) | **零差分**(仍是 600s) | +| **进不进指纹** | **进**(它改构建图的形态) | **不进**(它是策略标量,不改图) | +| **老版 mcpp 遇到它** | 警告+忽略 ⇒ **构建出错的东西** ⚠️ | 警告+忽略 ⇒ 退回 600s,**报错文案清晰** ✅ | + +最后两行是这份方案里唯一需要记住的架构判据: + +> **一个配置键进不进指纹,只问一个问题:它改变了「图是什么」,还是只改变了「跑图时的策略」。** +> `module_extensions` 决定哪个文件产 BMI、哪个 `.o` 进链接 ⇒ 进。 +> `build_program_timeout` 不改任何一条边 ⇒ 不进(进了会让「把超时从 600 调到 1800」重建全世界)。 + +第四行(**默认图形态零差分**)是这份方案的稳定性支点,也是它与 PR #272 现有实现最大的分歧点。见 §1.3。 + +还有一条贯穿全文的判据,来自 §3.8.1 那次实测: + +> **在「维护一张会过期的知识表」和「无条件做一件已证明幂等的事」之间,永远选后者。** +> 前者错了是**退出码 0 的静默空转**,后者错了什么都不会发生。 + +--- + +## 1. 为什么 PR #272 不能按现在的样子合 + +PR #272 改了 4 个文件(`plan.cppm` +4/-4、`execute.cppm` +3/-2,加两个测试), +把链接侧的 `.cppm` 判断换成了 `cu.providesModule`(**这一步是对的,本方案保留**), +并让 4 种扩展名共享 `.m` 对象名前缀。 + +**先说两个状态事实**(核于 2026-08-11): + +- PR 基线是 `27d250c`,**main 已领先它 79 个提交**;`plan.cppm` 的行号从 155 漂到 303。 +- **CI 当前 10 个 job 红**(linux/windows/macOS 的 e2e、unit、cross 全红)。 + 本文**不把红全部归因给下面的缺陷** —— 79 个提交的漂移足以单独解释一部分。 + +下面三条是与基线漂移无关的、机制层面的缺陷。 + +### 1.1 漏了 `pick_rule` —— Clang 侧不产 BMI + +`src/build/ninja_backend.cppm:1005`: + +```cpp +auto pick_rule = [](const std::filesystem::path& src) -> std::string { + auto ext = src.extension(); + if (ext == ".cppm") return "cxx_module"; // ← 只认 .cppm + ... + return "cxx_object"; +}; +``` + +两个调用点(`:1191` dyndep 模式、`:1235` static 模式)都只传 `cu.source`。 +PR #272 **完全没有动这个文件**。 + +GNU 方言下,`cxx_module`(`:668`)与 `cxx_object`(`:696`)的差别就是一个 +`module_output_flag`(`:523`,`traits.needsExplicitModuleOutput` 时展开为 +`-fmodule-output=$bmi_out`)。于是一个 `.ixx` / `.ccm` 单元: + +- 边上**照样声明了 BMI 产物**(`:1193`,`if (cu.providesModule) out_line += " | " + bmi_path(...)`) +- 命令行里**没有 `-fmodule-output=`** + +⇒ Clang 上 ninja 报「declared output not produced」; +GCC 上 `gcm.cache` 是驱动的自动行为,可能照旧产出 ⇒ **同一份代码两个编译器两种结果**。 + +而 e2e `152_module_extensions.sh` 的第 2 行是 `# requires: elf gcc` —— +**Clang 与 MSVC 路径从未被这条测试走到**。 + +> 注:`module_src_flags`(`:667`)是 `msvcDeps ? " /interface /TP" : ""` —— +> **只对 MSVC 生效**,GNU 方言下是空串。也就是说 GCC/Clang 侧现在**一个 `-x` 都不发**。 +> 这条直接引出 §3.8 那个必须靠实测才能定下来的问题。 + +### 1.2 `.m` 前缀给四种扩展名 = 新开一个对象名碰撞 + +`src/build/plan.cppm:293`: + +```cpp +if (ext == ".S" || ext == ".s" || ext == ".asm") + return src.filename().string() + objExt; // 保留完整扩展名 —— 这是对的解法 +auto stem = src.stem().string(); +return stem + (ext == ".cppm" ? ".m" + objExt : objExt); +``` + +PR #272 把条件放宽成「四种扩展名都用 `.m`」。于是同目录下的 +`foo.cppm` 与 `foo.ccm` **双双变成 `foo.m.o`**。 + +`object_for`(`:1099`)的兜底救不了它:`rootBasenameCount[fname] > 1` 时的处理是 +**把源码目录结构镜像进对象路径**,而这两个文件本来就在同一个目录,镜像之后仍然重名。 + +注意汇编分支(`:298`)早就把这题做对了 —— **不认识的扩展名就保留完整文件名**。 +正确的推广是沿用它,而不是扩大 `.m` 的适用面。见 §3.5。 + +⚠️ PR 新增的单测 `tests/unit/test_module_extensions.cpp` **把这个碰撞钉成了预期行为**: + +```cpp +TEST(ModuleExtensions, CppmGetsDotMPrefix) { EXPECT_EQ(object_filename_for("src/foo.cppm", ".o"), "foo.m.o"); } +TEST(ModuleExtensions, CcmGetsDotMPrefix) { EXPECT_EQ(object_filename_for("src/foo.ccm", ".o"), "foo.m.o"); } +``` + +同一个函数、同一个输入 stem、同一个输出。**采纳本方案时这两条断言必须反过来写** +(`foo.ccm` → `foo.ccm.o`),否则测试会保护住缺陷。 +e2e 里的 `grep -q "info.m.o"` 等四条同理。 + +### 1.3 直接写进内置默认 = 对已发布包的破坏性变更 + +这是决定方案形态的一条,也是**本方案与 PR #272 的根本分歧**。 + +若把 `.ixx` 加进内置默认,`src/manifest/toml.cppm:1610` 的默认 sources glob 必然要跟着变成 +`src/**/*.{cppm,ccm,cxxm,ixx,cpp,cc,c,S,s,asm}`。后果: + +> **任何一个 `src/` 下躺着 `.ixx` 的已发布包,升级 mcpp 之后会突然开始编译它。** + +`.ixx` 尤其危险 —— 它是 MSVC 的拼法,现实中最常见的存在形式就是 +「vendored 的 MSVC-only 源码,当前平台上根本编不过」。 +包描述符按版本冻结,而 mcpp 是滚动升级的 ⇒ **一次 mcpp 升级会让一批已发布包同时变红, +且包作者无法通过改包来避免**(旧版本的 tarball 已经发出去了)。 + +这是「索引下限把旧客户端变砖」那条判据的另一面: +**发布出去的数据不得让程序失效;升级的程序也不得让已发布的数据失效。** + +⇒ **内置表保持现状(只 `.cppm`),四种扩展名全部走配置、opt-in。** +默认路径上生成的 `build.ninja` 与今天逐字节相同。 + +**PR #272 自己的 e2e 已经证明了这个形态是对的** —— 它的 fixture 里有这么一行注释和一份手写 manifest: + +```bash +# Default glob only picks up .cppm/.cpp/.cc/.c — explicitly add .ccm/.cxxm/.ixx +cat > mcpp.toml <<'EOF' +[modules] +sources = ["src/**/*.cppm", "src/**/*.ccm", "src/**/*.cxxm", "src/**/*.ixx", "src/**/*.cpp"] +EOF +``` + +也就是说:**即使按 PR #272 的实现,用户仍然必须在 mcpp.toml 里显式 opt-in**, +只不过 opt-in 的方式是手写五条 glob、且**语义分散在两处** +(`sources` 决定「编不编」,代码里的硬编码清单决定「算不算模块」)。 +本方案做的事就是把这两处合成一处声明。 + +(顺带:那份 fixture 用的是 `[modules] sources` —— 遗留镜像键;新文档一律用 `[build] sources`。) + +--- + +## 2. 共同根因:同一决策 20 处推导 + +`grep -rn 'extension()\s*==\|ext ==\|ext !=' src/ --include=*.cppm` 的完整结果, +按「它在回答什么问题」归并: + +| # | 位置 | 在问什么 | 用的清单 | `.ixx` 走到这里 | +|---|---|---|---|---| +| 1 | `modgraph/scanner.cppm:573` | 是否 C-like(跳过模块扫描) | `.c .m .S .s .asm` | ✅ 落入 C++ 扫描 | +| 2 | `modgraph/scanner.cppm:690` | 是否实现单元 | `.cpp` | ⚠️ 与 `.cc/.cxx` 同病 | +| 3 | `modgraph/p1689.cppm:388` | 是否模块接口(兜底) | `.cppm` | ⚠️ 全靠编译器 ddi | +| 4 | `modgraph/p1689.cppm:389` | 是否实现单元 | `.cpp .cxx` | ⚠️ **与 #2 不同的清单** | +| 5 | `build/plan.cppm:293` | 对象名怎么起 | `.S .s .asm` / `.cppm` | ❌ 与 `foo.cpp` 撞名 | +| 6 | `build/plan.cppm:388` | 是否实现单元 | `.cpp .cc .cxx .c .m .S .s .asm` | ⚠️ **第三份清单** | +| 7 | `build/plan.cppm:1363` | 模块对象进不进链接 | `.cppm` | ❌ **`.o` 不进链接** ← #272 修了 | +| 8 | `build/plan.cppm:1425` | 同上(link unit) | `.cppm` | ❌ 同上 ← #272 修了 | +| 9 | `build/ninja_backend.cppm:235/240/247` | C / GAS / NASM / 免扫描 | `.c .m` / `.S .s` / `.asm` | ✅ | +| 10 | `build/ninja_backend.cppm:1005` | 用哪条 ninja rule | `.cppm` | ❌ **不产 BMI** ← #272 **漏了** | +| 11 | `build/execute.cppm:591` | 快路径要不要作废 | `.cppm .cpp .cc .cxx .c .h .hpp` | ❌ **静默陈旧** | +| 12 | `build/directives.cppm:649` | 生成物可不可编译 | `…… .cppm .ixx` | ⚠️ **唯一含 `.ixx` 的清单** | +| 13 | `build/compile_commands.cppm:81` | CDB 里算不算 C | `.c .m` | ✅ | +| 14 | `build/compile_commands.cppm:236` | CDB 里算不算 GAS | `.S .s` | ✅ | +| 15 | `build/prepare.cppm:5465` | MSVC 要不要拒绝 GAS | `.S .s` / `.asm` | ✅ | +| 16 | `manifest/toml.cppm:1610` | 默认 sources glob | `cppm cpp cc c S s asm` | ❌ **根本 glob 不到** | +| 17 | `manifest/toml.cppm:1642` | 自动推断 lib target | `.cppm` | ❌ 纯 `.ixx` 库无 target | +| 18 | `build/prepare.cppm:3371` | stage 的兜底 glob | `cppm cpp cc c` | ⚠️ **比 #16 少 asm** | +| 19 | `manifest/types.cppm:1027` | `[lib]` 约定路径 | `.cppm` | — | +| 20 | `toolchain/clang.cppm:196` | std 模块源要不要 `-x c++-module` | `.ixx` | (仅 std) | + +**8 份清单**(#2 / #4 / #6 三份「什么算实现单元」互不相同;#11 / #12 / #16 / #18 四份「什么算源文件」互不相同)。 + +### 2.1 最危险的一处:#11 快路径漏扫 + +`src/build/execute.cppm:591` 是**唯一一处「漏了会静默出错、不会报错」**的: + +```cpp +for (auto& f : expand_glob(projectRoot, "src/**/*")) { + auto ext = f.extension().string(); + if (ext != ".cppm" && ext != ".cpp" && ext != ".cc" && + ext != ".cxx" && ext != ".c" && ext != ".h" && ext != ".hpp") + continue; // ← .ixx 在这里被跳过 + if (last_write_time(f) > ninjaTime) return true; +} +``` + +这个 sweep 回答的不是「文件变了没有」(那是 ninja 的活),而是 +**「构建图的形态可能变了没有」**。一个 `.ixx` 里新加一句 `import foo;`: + +- 扫描器不会重跑(快路径判定为 fresh) +- dyndep 仍是上一次的 +- ninja 照旧重编这个 `.o`(它的 mtime 变了),**但 BMI 依赖边是旧的** +- 结果:链接成功 / 运行出错,或者一个指向别处的 `Bad import dependency` + +**这就是「修补放在控制流到不了的地方」的镜像版本 —— 判据放在了控制流会跳过的地方。** +PR #272 改了这一行(加了 3 个扩展名),但仍然是第 4 份手写清单; +下一个新增语义还会漏第 5 次。 + +--- + +## 3. 设计 A:分类一次,当数据传下去 + +### 3.1 判据 + +| # | 判据 | 检验方式 | +|---|---|---| +| A-1′ | 未配置 `module_extensions` 时,构建图的**形态**相同:哪些文件被编、产不产 BMI、哪些 `.o` 进链接、快路径扫哪些文件 | 归一化 diff `build.ninja`,**diff 里只允许出现 rule 定义行**(理由见 §3.8.3) | +| A-2 | 「扩展名 → 角色」在**产品代码里只有一处**表达 | `grep -c 'extension() ==' src/` 只剩分类器 | +| A-3 | 分类**发生一次**(单元入图时),下游读字段而非重新判定 | `CompileUnit::kind` 的读者数 ≫ `classify` 的调用点数 | +| A-4 | 改 `module_extensions` 必然重新 prepare,且落到不同的 `target///` | 改键前后 `mcpp build --print-fingerprint` 不同 | +| A-5 | 任意两个源文件永不共享对象路径 | 单测穷举 `object_filename_for` | + +### 3.2 新叶子模块 `src/source_kind.cppm` + +**放在 `src/` 根、只 `import std`**,不是 `src/build/` 下 —— +因为 `mcpp.manifest.toml`(#16/#17)和 `mcpp.modgraph.scanner`(#1/#2)都要用它, +而 `mcpp.build.*` 依赖 `mcpp.manifest`,反向依赖会成环。 + +```cpp +export module mcpp.source_kind; +import std; + +export namespace mcpp { + +enum class SourceKind { + ModuleInterface, // 产 BMI;.o 无条件进链接单元 + Cxx, // C++ 实现单元(含模块实现分区) + C, // .c / .m —— 走 C 规则,不扫描 + GasAsm, // .S / .s —— C driver + NasmAsm, // .asm + Header, // .h / .hpp —— 不编译,但改动会改图形态 + Other, // 不是构建输入 +}; + +// 内置表 + manifest 追加项。按值传,构造极廉价(一次 vector 拷贝), +// 因为 prepare 是多包的:root 与每个依赖各有自己的表, +// 全局单例会让依赖包被消费者的配置分类 —— 那是 #405 那类跨包污染的形状。 +struct ExtensionTable { + // 内置:{".cppm"} —— **不含** .ccm/.cxxm/.ixx,见 §1.3 + std::vector moduleInterface; + // 其余四轴当前是常量。留结构不留键:加 cxx_extensions 时 + // 只需在这里加一个 vector + 一处 parse,不需要动 classify 的形状。 +}; + +ExtensionTable builtin_extension_table(); +ExtensionTable extension_table_for(std::span manifestExtras); + +SourceKind classify(const std::filesystem::path& p, const ExtensionTable& t); + +// 由 SourceKind 导出的谓词 —— 供只关心一个轴的读者用, +// 保证它们不会各自再写一份 switch。 +bool produces_bmi(SourceKind k); // == ModuleInterface +bool is_scan_exempt(SourceKind k); // C / GasAsm / NasmAsm +bool links_unconditionally(SourceKind k); // ModuleInterface +bool affects_graph_shape(SourceKind k); // 除 Other 之外全部 —— #11 用它 + +// 默认 sources glob 由表导出,而不是与表并列手写。#16/#18 的两份清单 +// 就是「并列手写」的产物。 +std::vector default_source_globs(const ExtensionTable& t); + +} // namespace mcpp +``` + +`ExtensionTable` 的构造要**规范化**:补前导 `.`、小写化(Windows 文件系统大小写不敏感, +`FOO.IXX` 与 `foo.ixx` 必须同类)、去重、拒绝空串。规范化只在构造时做一次, +`classify` 里不做 —— 它在扫描热路径上,每个源文件调用一次。 + +### 3.3 分类只发生一次 + +`SourceUnit`(`src/modgraph/graph.cppm:15`)已经有 `packageName` / `relPath`, +`scan_one_into`(`src/modgraph/scanner.cppm:747`)的签名里**已经有 `manifest`**。 +所以不需要任何新的管道 —— 这是本方案最省的一处: + +```cpp +// scanner.cppm,scan_one_into 内,每个源文件一次 +u.kind = classify(file, table); // table 来自这个包自己的 manifest +``` + +然后: + +| 结构 | 新增字段 | 谁写 | 谁读 | +|---|---|---|---| +| `SourceUnit` | `SourceKind kind` | scanner(#1/#2)、p1689(#3/#4) | plan | +| `CompileUnit` | `SourceKind kind` | `make_plan` 从 `SourceUnit` 拷贝 | #5 #6 #7 #8 #9 #10 #13 #14 #15 | + +`SourceUnit::isModuleInterface` / `isImplementation` 两个 bool 变成 `kind` 的导出量。 +**保留它们**(约 20 处读者),但改成从 `kind` 赋值的派生字段,不再各自判断扩展名 —— +一次性删掉它们会把这个 PR 的改动面翻倍,而收益是 0。 + +于是 `ninja_backend.cppm:1005` 变成: + +```cpp +auto pick_rule = [](const CompileUnit& cu) -> std::string { + switch (cu.kind) { + case SourceKind::ModuleInterface: return "cxx_module"; + case SourceKind::C: return "c_object"; + case SourceKind::GasAsm: + return cu.source.extension() == ".S" ? "asm_object" : "asm_object_raw"; + case SourceKind::NasmAsm: return "nasm_object"; + default: return "cxx_object"; + } +}; +``` + +> **`.S` vs `.s` 是 GasAsm 内部唯一保留的扩展名判断**,因为它区分的是 +> 「过不过预处理器」,那是同一角色下的两种编译方式,不是两个角色。 +> 强行拆成 `GasAsmPreprocessed` / `GasAsmRaw` 会让枚举去表达编译器旗标 —— 那是越界。 + +**唯一不能读字段的是 #11(快路径)** —— 它跑在 prepare 之前,手里没有 plan。 +它调 `classify(f, table)`,`table` 来自它已经加载的 manifest。这是**唯一**一处二次分类, +而且它与 scanner 读的是同一个 manifest 的同一个键,不可能漂移。 + +### 3.4 配置面 + +```toml +[build] +module_extensions = [".ixx", ".ccm", ".cxxm"] +``` + +| 决策 | 取值 | 理由 | +|---|---|---| +| 语义 | **追加**到内置 `[".cppm"]`,不能删 | 删掉 `.cppm` 没有任何正当用例,却能让整个包静默变成「无模块」 | +| 任意后缀 | **全放行** | §3.8.1 之后,放行的代价是零 —— mcpp 不再需要知道编译器认不认。`.mpp` / `.cppmi` / 任何拼法都行 | +| 唯一的守卫 | **拒绝**与内置非模块角色冲突的后缀(`.cpp` `.cc` `.cxx` `.c` `.m` `.mm` `.h` `.hpp` `.S` `.s` `.asm`) | 「声明 `.c` 是模块接口」不存在正当用例,却会让 C 文件走 C++ 模块规则,炸在一个没法诊断的地方。解析期硬错误,不是警告 | +| 排除某文件 | 用 `sources` 的 `!` 前缀 | 已有机制,不再造第二个 | +| 作用域 | **每个包读自己的 manifest** | 与 `cflags` / `globFlags` 同构;消费者不该决定依赖的源码怎么分类 | +| 传播 | **不传播** | 它是包的私有构建属性,不是 usage requirement | +| 默认 sources glob | 随表**自动扩展** | 否则声明了 `module_extensions` 却什么都不发生 —— 见 §3.6 | + +### 3.5 对象名:一个总函数,零碰撞 + +`object_filename_for` 改成对 `SourceKind` + 扩展名的**总函数**,规则只有三条: + +``` +.cpp → foo.o (今天的行为,不动) +.cppm → foo.m.o (今天的行为,不动) +其余 → foo..o (汇编分支 :298 早就这么做,现在推广到全部) +``` + +于是 `foo.ixx → foo.ixx.o`、`foo.ccm → foo.ccm.o`,与 `foo.cppm → foo.m.o` 三者互不相撞。 + +**为什么 `.cpp` / `.cppm` 必须原样不动**:对象名是全局依赖缓存条目的**内部布局** +(`CompileUnit::packageObjectRel`,`plan.cppm:1094`)。缓存键不变而条目内布局变了, +后果不是「缓存未命中」而是 **「命中一个不含所需对象的条目」** ⇒ 链接期缺 `.o`。 +`plan.cppm:1091-1094` 的 `mcpp#344` 注释守的就是这件事 +(「a pure function of the owning package」)。改这两个的代价是缓存键必须同步改版, +而收益是 0(它们今天不撞)。 + +⚠️ **已知遗留(不在本方案范围)**:同目录下的 `foo.c` 与 `foo.cpp` 今天都产出 `foo.o`, +真实碰撞,且 `rootBasenameCount` 兜底无效(理由同 §1.2)。 +修它需要动缓存条目布局 ⇒ **单独一个 issue,配缓存键版本号**。 +本方案在单测里把这条**显式标记为已知失败**,而不是让穷举测试假装通过。 + +### 3.6 联动:不联动的话这个键什么都不做 + +| 位置 | 改法 | +|---|---| +| #16 `toml.cppm:1610` | 默认 glob 由 `default_source_globs(table)` 生成 | +| #17 `toml.cppm:1642` | `hasCppm` → `hasModuleInterface`,用 `classify` | +| #18 `prepare.cppm:3371` | 删掉这份兜底清单,复用 `default_source_globs` | +| #19 `types.cppm:1027` | `[lib]` 约定路径仍产 `.cppm` —— **这是写路径,不是读路径**,不动 | + +**顺序**:`module_extensions` 在 `apply_defaults_and_infer` 之前解析完成 +(`toml.cppm` 的 parse → defaults 顺序天然满足),不需要调整。 + +用户显式写了 `sources` 时,他的 glob 优先,和今天一样。 + +### 3.7 指纹与缓存键 + +折进 `prepare.cppm` 的规范化 compile-flags 串,**root 块与 per-package 块各一处** +(`:265` 与 `:370`),形式抄 `globFlags`: + +```cpp +for (auto const& e : m.buildConfig.moduleExtensions) { s += " modext:"; s += e; } +``` + +per-package 那一处不能省。理由与 `#253` 给依赖的 `globFlags` 补指纹是同一条: +path / git 依赖不按版本冻结,它的 `module_extensions` 改了就是另一份产物。 + +### 3.8 编译器方言:两组实测,和一张被删掉的表 + +这一节原本写的是推理。第一组实测推翻了推理,第二组实测又推翻了第一组得出的设计。 +两次都记在这里 —— **推导路径本身是这份方案里最有价值的部分**。 + +**这不是「编译器不支持模块」的问题**,而是低一层的东西:每个驱动都有一张 +「后缀 → 这是什么语言」的映射表,**表里没有的后缀被当成链接输入**(和 `.o`/`.a` 一类), +转手给链接器,根本不进编译前端。`.ixx` 是 MSVC 的约定(`cl.exe` 的原生模块接口后缀), +Clang 的表收了 `.cppm`/`.ccm`/`.cxxm`,**没收它**。 + +**实测**(2026-08-11 本机,GCC 16.1.0 / Clang 22.1.8)。 +判据不是「有没有警告」而是**给文件塞一个语法错误,看驱动有没有真的编它**: + +``` +$ printf 'export module m;\nthis is not valid c++ @@@\n' > bad. +$ -std=c++23 -fsyntax-only bad. +``` + +| 后缀 | GCC 16.1.0 | Clang 22.1.8 | MSVC `cl` | +|---|---|---|---| +| `.cppm` | ✅ 报语法错误(进了前端) | ✅ 报语法错误 | ❌ 需 `/interface /TP` | +| `.ccm` / `.cxxm` | ✅ | ✅ | ❌ 推定同 `.cppm`(**未验证**) | +| `.ixx` | ✅ | ❌ **`'linker' input unused`** —— 没编 | ✅ **原生** | + +**三个编译器三张表,而 MSVC 与 Clang 正好互为盲区 —— 没有一个后缀三家通吃。** + +MSVC 两格的证据等级与前两列不同(本机无 MSVC),但都取自本仓库: + +- `.ixx` 原生:`toolchain/msvc.cppm:517-537` 编 MSVC STL 的 `modules/std.ixx` 用的是 + `cl /nologo /EHsc /O2 /W0 /c std.ixx /ifcOutput ... /Fo:...` —— + **没有 `/interface`,没有 `/TP`**,裸 `/c` 就产出 IFC,且这条路在 Windows CI 上跑着。 +- `.cppm` 非原生:`ninja_backend.cppm:666` 的注释原话是 + 「cl.exe needs /TP (our module interfaces are .cppm, **unknown to cl**) and /interface」。 +- `.ccm` / `.cxxm` 两格是**推定**,⚠️ 必须在 Windows CI 上补测,不得当结论用。 + +**最阴的是它不报错**: + +``` +$ clang++ -std=c++23 ok.ixx --precompile -o ok.pcm +clang++: warning: ok.ixx: 'linker' input unused +$ echo $? → 0 # 成功 +$ ls ok.pcm → 不存在 # 但 BMI 没有 +``` + +**退出码 0、零个 error、产物不存在。** 这正是 §1.1 那个缺陷在 Clang 上的实际形态: +命令跑完退 0 什么也没产出,报错要等到下游 `import` 时才炸,且指向别处。 + +解药一行,实测有效: + +``` +$ clang++ -std=c++23 -x c++-module ok.ixx --precompile -o ok.pcm + → ok.pcm, 18892 字节 +``` + +`toolchain/clang.cppm:193-200` 早就为 MSVC STL 的 `std.ixx` 记着同一件事 +(「Clang doesn't recognize the .ixx extension as a module source by default」), +只是那段今天只服务 `stdModuleSource`。 + +**到这里为止,自然的设计是「一张 per-方言 的原生后缀表 + 按需补旗标」。 +下一节的第二组实测把这个设计否掉了。** + +### 3.8.1 结论:把「谁认哪个后缀」这张表**整个删掉** + +初稿在这里设计了一张 `nativeModuleSuffixes` 表 + 一个 `declarePolicy`。 +**实测把它否掉了。** 关键的一组测量: + +| | GCC 16.1.0 | Clang 22.1.8 | MSVC `cl` | +|---|---|---|---| +| 显式声明模块接口的旗标 | `-x c++` | `-x c++-module` | `/interface /TP` | +| 该旗标**加在原生后缀上**是否幂等 | ✅ 尺寸相同,差异位置=噪声(下注) | ✅ **逐字节相同**(18896 = 18896) | ✅ **今天就在无条件发** | +| 该旗标能否**救回**不认识的后缀 | ✅ `.weirdext` 产出 gcm | ✅ `.ixx` 产出 18892 字节 pcm | ✅(它就是为此存在的) | +| 交叉误用 | `-x c++-module` → `language not recognized` | `-x c++` → **174 字节空壳 pcm** | — | + +> ⚠️ **方法论下注**:GCC 的 gcm **本身不可复现** —— 同一条命令跑两次, +> 差异出现在 byte 605;`-x c++` 那次差异在 byte 602,**同一处噪声**。 +> 我第一次读成「`-x c++` 改变了产物」,是**缺对照**。 +> 任何「加了旗标产物就变了」的判断,都必须先跑一次「不加旗标跑两遍」的对照。 + +⇒ **既然旗标幂等,就没有理由去判断「要不要发」。永远发。** + +``` +dialect.moduleInterfaceFlag // gcc: "-x c++"; clang: "-x c++-module"; msvc: "/interface /TP"(现状) +``` + +`cxx_module` 规则无条件带上它。**没有第二张表,没有 policy 枚举,没有版本知识。** + +注意这个常量是按**编译器家族**分的,不是按 `CommandDialect`(gnu/msvc)—— +gcc 与 clang 同属 gnu 方言但取值不同(GCC 直接拒绝 `-x c++-module`)。 + +### 3.8.2 为什么 X 优于查表:失败模式不对称 + +| | 查表 | 永远显式 | +|---|---|---| +| 表错了会怎样 | **退出码 0、无 BMI、下游 `import` 时炸在别处**(§3.8 那个静默空转) | 多发一个幂等旗标,什么都不发生 | +| 维护成本 | 3 家 × N 版本,每次升编译器重验 | 一个家族常量,零 | +| 知识时效 | 会过期(GCC 16 认 `.ixx`,GCC 14 呢?) | 无时效 | + +**一张会过期、错了还不报错的表,不该存在。** + +顺带:**MSVC 是三家里唯一一开始就做对的** —— 它今天就是「永远显式说」。 +本方案只是把 GNU 侧对齐到 MSVC 已有的做法,不是发明新机制。 + +### 3.8.3 这推翻了 A-1 的措辞(不是推翻它的意图) + +不敢做 X 的唯一理由是「会改掉每条现存 `.cppm` 命令行」。**这条站不住**: + +`toolchain/fingerprint.cppm` 的**第 8 个字段就是 mcpp version**。 +任何 mcpp 升级都已经换 `target///` 并重建全部, +而这个 `-x` 改动**只可能随一个新 mcpp 版本发布** ⇒ 成本已被版本指纹全额吸收, +**增量为零**。A-1 当初防的是一笔 mcpp 自己每次发版都在付的钱。 + +⇒ **A-1 改写为 A-1′**(§3.1 同步): + +> **未配置 `module_extensions` 时,构建图的形态相同** —— +> 哪些文件被编、产不产 BMI、哪些 `.o` 进链接、快路径扫哪些文件。 +> **rule 文本可以变**;验证方式从「空 diff」改为「diff 里只有 rule 行」。 + +--- + +## 4. 设计 B:`build_program_timeout` + +### 4.1 先否掉 issue #410 的字面诉求 + +issue 原文是「希望能够在超时时**询问用户**, 而不是直接中断」。**不做**,理由三条: + +1. **没有可用的交互通道。** build.mcpp 通过 `capture_exec_deadline` + (`build_program.cppm:766`)运行,stdout/stderr 被 dup2 进管道用于解析 + `mcpp:` 指令协议。要弹交互,得先把这个通道劈开。 +2. **构建的多数发生地没有人。** CI、`mcpp build` 进流水线、被 ninja 当子进程调 —— + 一个卡在 prompt 上的构建比一个失败的构建更难诊断(它不会失败,它会一直挂着)。 +3. **构建结果不该依赖一次击键。** 同一份源码 + 同一份 lock,两次构建应当等价。 + +**替代品是把诊断做对**:报错要指名**改哪个文件的哪个键**。见 §4.5 —— +issue #410 的作者真正需要的信息就是这个。 + +同时 maintainer 在 issue 里已经指出:「build.mcpp 一般只做代码生成/预处理/后处理, +不应该把整个构建期放到 build.mcpp 里」。这条**判断是对的,但不构成拒绝配置的理由** —— +600 s 对「opencv 全量编译」不够,对「protoc 生成 400 个文件」同样不够, +而后者完全是 build.mcpp 的正当用法。 + +### 4.2 键与语义 + +```toml +[build] +build_program_timeout = 1800 # 秒;0 = 不限 +``` + +```cpp +// manifest/types.cppm — BuildConfig +std::optional buildProgramTimeoutSecs; +``` + +⚠️ **`std::optional` 是承重的,不是风格。** 用 `int = 0` 的话「没写」与「写了 0」不可区分, +而 0 的含义是**不限** ⇒ 每个没写这个键的工程都会静默失去运行上限。 +`execute.cppm:1010-1014` 的注释已经把这条判据写死过一次: +「`--timeout 0` still means "no limit", it just has to be asked for」。 + +解析期校验:非整数 → 错误;负数 → 错误。**不接受 `"30m"` 时长串** —— +`--timeout SECS`、`--build-timeout SECS`、`MCPP_BUILD_PROGRAM_TIMEOUT` 全是秒制, +第二种拼法只会制造「哪个键吃哪种格式」的记忆负担。 + +### 4.3 优先级 + +``` +MCPP_BUILD_PROGRAM_TIMEOUT (每次调用的显式覆盖) + > 该包自己的 [build] build_program_timeout (包作者的知识) + > 内置 600s (基线) +``` + +与 `macos_deployment_target` 已文档化的优先级同构(env > manifest > 内置), +不新造一套。 + +**「该包自己的」是关键**:依赖包的 build.mcpp 用依赖包 manifest 里的值。 +消费者不知道 opencv 的生成器要跑多久,opencv 的作者知道。 +消费者需要拔高时用 env(它是全局的,这正合适 —— 卡住的是**这一次**构建)。 + +### 4.4 唯一的策略函数 + +`src/build/directives.cppm:377` 的 `run_timeout()` 现在是无参的,而且**自己 `getenv`** +—— 于是优先级逻辑无法单测(只能改进程环境)。拆成两个函数, +**优先级在纯函数里解一次**,而不是在调用点拼: + +```cpp +// directives.cppm +inline constexpr int kDefaultRunTimeoutSecs = 600; + +// 唯一读环境的地方;解析失败 / 负数 → nullopt(沿用现有的宽容行为) +std::optional env_timeout_override(); + +// 纯函数:两个 optional 进,一个时长出。优先级只在这里表达。 +std::chrono::milliseconds run_timeout(std::optional envSecs, + std::optional manifestSecs); +``` + +调用点(`build_program.cppm:766`)写成 +`run_timeout(env_timeout_override(), m.buildConfig.buildProgramTimeoutSecs)`; +`m` 本来就在手边(`:770` 已经在用 `m.package.name`)。 + +T-3 于是可以穷举 3 × 3 = 9 种组合,一次进程环境都不用碰。 + +### 4.5 诊断:指名改哪个文件 + +现在的文案(`build_program.cppm:768`)只说 env。新文案必须回答 +**「我该改哪一个 mcpp.toml」** —— 依赖包超时时,用户的直觉是改自己的,那是错的: + +``` +error: build.mcpp for 'opencv' exceeded its 600s time limit and was killed. + Raise it in that package's own manifest: + /opencv-4.10.0/mcpp.toml → [build] build_program_timeout = 1800 + Or for this invocation only: + MCPP_BUILD_PROGRAM_TIMEOUT=1800 (0 = no limit) + Output so far: + ... +``` + +根工程超时则只打第二行的本地路径。**路径必须是真实存在的那一个** —— +`m` 里已经有包根,不要拼一个「大概是这里」的路径。 + +### 4.6 不进指纹 —— 显式记下来 + +`build_program_timeout` **不进** compile-flags 串、**不进** 缓存键。 +它不改任何一条边。若进了,把超时从 600 调到 1800 会换一个 `target///`, +**重建全世界** —— 而这恰好发生在用户正因为构建太慢而调这个键的时候。 + +这条要写进代码注释,否则下一个人「为了一致性」把它加进去。 + +### 4.7 Windows:诚实地说它不生效 + +`platform/process.cppm:598` 的 `capture_exec_deadline`: + +```cpp +#if defined(__linux__) || defined(__APPLE__) + ... posix_spawn + SIGKILL ... +#else + return capture_exec(argv, extraEnv, cwd); // ← 无界,deadline 被丢弃 +#endif +``` + +⇒ **Windows 上 `build_program_timeout` 是个 no-op**,和 `MCPP_BUILD_PROGRAM_TIMEOUT` 一样。 + +处理方式: + +- **不加每次构建的告警**(设了键的包在 Windows 上每次构建都告警 = 噪声) +- `mcpp doctor` 报一次 +- `docs/05-mcpp-toml.md` 与 `docs/07-build-mcpp.md` 各说一次(zh 镜像同步) + +**要真正修**需要 Windows 侧改用 `CreateProcess` + `WaitForSingleObject(timeout)` + +`TerminateProcess`,替换现在的残留 shell launcher。这是独立工作量, +**单开 issue,不塞进本方案** —— 否则这两个键会被一个平台移植卡住。 + +--- + +## 5. 兼容性与降级 + +| 场景 | `module_extensions` | `build_program_timeout` | +|---|---|---| +| 新 mcpp + 没写这个键的老工程 | 零差分 | 零差分(仍 600s) | +| **老 mcpp + 写了这个键的包** | ⚠️ **警告+忽略 ⇒ 构建出错的东西**(`.ixx` 被当普通 TU) | ✅ 警告+忽略 ⇒ 退回 600s,失败文案清晰 | +| `--strict` | 老 mcpp 上是硬错误 ✅ | 同 | + +`[build]` 的未知键是**警告**不是错误(`toml.cppm:1029-1058`,`kKnownBuildKeys` + +`m.schemaWarnings`),所以老 mcpp 不会拒绝加载整份 manifest —— +这躲开了「一个不认识的键让整个包永远无法被采用」那个坑。 + +但 `module_extensions` 的降级是**静默错误**,不是干净失败:老 mcpp 忽略这个键之后, +`.ixx` 仍会被 `sources` glob 到(如果作者写了),然后被当成普通 TU 编译 —— +产出一个不含模块的、链接期才炸的东西。而 mcpp.toml **没有** `min_mcpp` 之类的版本下限键 +(核实过:`grep -rn 'min_mcpp\|minMcpp' src/manifest/` 为空)。 + +⇒ **发布约束(必须写进 `docs/10-publishing-a-library.md`)**: +用了 `module_extensions` 的包,索引描述符必须声明 mcpp 版本下限。 +索引侧的下限机制必须**可降级** —— 「因版本太低而不可用」要如实拼成「不可用」, +不能拼成「不存在」,否则客户端会去做无穷的重复刷新。 +**这一条是 PR-2 发布前的阻塞项**,见 §9.3。 + +**两个键都要加进 `kKnownBuildKeys`**(`toml.cppm:1040`), +以及同一处的「Supported keys:」文案 —— 那句话是硬编码的,漏了它会现场打脸。 + +--- + +## 6. 测试计划 + +⚠️ 这一节里带 ⚠️ 的,都是**会让缺陷通过测试**的写法。 +`build.ninja` 相关的回归测试有过三次「测试自己把缺陷盖住」的记录。 + +| # | 层 | 断言 | 陷阱 | +|---|---|---|---| +| T-1 | 单测 | `classify()` 对内置表的全部扩展名 × 配置追加后的全部组合 | — | +| T-2 | 单测 | `object_filename_for` 穷举:任意两个不同源文件不共享对象名 | `foo.c`/`foo.cpp` **标记为已知失败**(§3.5),不要为了绿而放宽断言 | +| T-3 | 单测 | `run_timeout` 优先级穷举(env × manifest × 未设 × 0) | env 读取必须已上移,否则测试要改进程环境 | +| T-4 | e2e `217` | 四种扩展名 build → link → **run**,GCC / Clang 双 dialect | ⚠️ **必须 `# requires:` 到 Clang** —— 只跑 GCC 会同时放过 §1.1 的 BMI 缺陷**和** §3.8 的 `.ixx` 驱动不识别 | +| T-5 | e2e `217` | **未配置**时 `.ixx` 不被编译(零差分) | 反向断言,防止有人「顺手」把它加进内置表 | +| T-6 | e2e `218` | 改 `.ixx` 里的 `import` ⇒ 快路径作废 ⇒ 全量 prepare | ⚠️ **不能用 `touch`** —— 要真正加一句 `import`;⚠️ **不能先删产物** —— 那会让 ninja 失败被读成「图过期」,回退全量 prepare,**缺陷被盖住** | +| T-7 | e2e `218` | 改 `module_extensions` ⇒ 指纹变 ⇒ 落到不同 `target///` | 比对目录名,不是比对「构建成功」 | +| T-8 | 单测 | `moduleInterfaceFlag`:gcc=`-x c++`、clang=`-x c++-module`、msvc=`/interface /TP` | ⚠️ 两者**不可互换**(实测:`-x c++-module` 在 GCC 上 `language not recognized`;`-x c++` 在 Clang 上产出 174 字节空壳 pcm)。这条断言就是防「gnu 方言共用一个常量」的简化 | +| T-10 | e2e `217` | **幂等性回归**:`.cppm` 加了旗标之后仍能正常 build → link → run | 这是 §3.8.1 那个「幂等」实测结论的守卫。它一旦不成立,整个「永远显式」的设计就塌了 | +| T-9 | e2e `186` 扩写 | manifest 键生效;env 覆盖 manifest;报错文案含 manifest 路径 | 已有 `# requires: gcc`;Windows 上这条测不了,如实 skip | + +**T-6 的两个陷阱是记录在案的真实事故**:先删产物会让 ninja 失败被读成图过期; +先 `touch` 源码会让快路径按 mtime 直接失效、**根本没走到被测代码**。 +两种写法都会得到一个绿色的、什么都没测的测试。 + +**验证方法**(而不是「看起来对」):归一化 diff 实施前后的 `build.ninja`, +在未配置 `module_extensions` 的工程上,**diff 里只允许出现 `rule cxx_module` 的定义行** +—— 任何一条 `build ...` 边的变化都是 A-1′ 失守。 + +⚠️ **还有一条方法论**(§3.8.1 的下注):任何「加了旗标之后产物变了」的判断, +必须先跑「不加旗标连跑两遍」的**对照**。GCC 的 gcm 不可复现, +没有对照的 `cmp` 会给出一个看起来很硬、实际是噪声的结论 —— 我在这份文档里踩过一次。 + +--- + +## 7. 实施顺序 + +分两个 PR,因为它们的风险面完全不同。 + +### PR-1 · `build_program_timeout`(小、独立、可先合) + +1. `manifest/types.cppm`:`BuildConfig::buildProgramTimeoutSecs`(`optional`) +2. `manifest/toml.cppm`:解析 + 校验 + `kKnownBuildKeys` + 「Supported keys:」文案 +3. `build/directives.cppm`:`run_timeout(optional, optional)` + `env_timeout_override()` +4. `build/build_program.cppm`:传值 + 重写诊断(含 manifest 路径) +5. 显式注释:**不进指纹**,附理由 +6. T-3 / T-9;`docs/05-mcpp-toml.md`、`docs/07-build-mcpp.md` + zh 镜像 +7. 单开 issue:Windows `capture_exec_deadline` 无界 + +### PR-2 · `SourceKind` 收敛 + `module_extensions` + +按「先收敛、后开放」两步走,**中间必须停下来验一次零差分**: + +1. 新建 `src/source_kind.cppm`(内置表 = 今天的行为,**不加新扩展名**) +2. `SourceUnit::kind` / `CompileUnit::kind`;scanner + p1689 写入 +3. 20 处判定点逐个改读字段或读 `classify` +4. `object_filename_for` 改成总函数(`.cpp`/`.cppm` 原样) +5. **停:归一化 diff `build.ninja`,此处必须是【真·空 diff】** ← 这一步之前不写配置解析, + 也**还没加**第 9 步的旗标,所以 A-1′ 的「rule 行例外」在这里不适用 —— 一个字节都不许变 +6. `[build] module_extensions` 解析 + `kKnownBuildKeys` + 文案 +7. 默认 glob / 自动 target 推断 联动(#16 #17 #18) +8. 指纹:root + per-package 两处 +9. `toolchain/` 加**一个**按编译器家族取值的 `moduleInterfaceFlag` + (gcc=`-x c++`、clang=`-x c++-module`、msvc=`/interface /TP` 保持现状); + `cxx_module` 规则**无条件**带上它(§3.8.1) + —— **没有后缀表、没有 policy 枚举**;⚠️ 每个方言 leg 跑一次 T-10 幂等回归 +10. T-1/T-2/T-4/T-5/T-6/T-7/T-8/T-10;文档 + zh 镜像 + `docs/10-publishing-a-library.md` 的发布约束 + +第 5 步是这个 PR 唯一的安全带。跳过它,后面任何一处 diff 都无法归因。 + +--- + +## 8. 明确不做 + +| 不做 | 理由 | +|---|---| +| 把 `.ccm/.cxxm/.ixx` 加进内置默认 | §1.3,对已发布包的破坏性变更 | +| 超时时交互询问用户 | §4.1,没有交互通道 + 构建不该依赖击键 | +| `[build.extensions]` 四轴角色表 | C/asm 三轴的清单已完整且稳定;结构留位(§3.2),等真实用例 | +| 维护「哪个编译器认哪个后缀」表 | 会过期、错了还**不报错**(退出码 0 的静默空转);改为永远显式发一个已实测幂等的旗标(§3.8.1) | +| 用「任意后缀全放行」当借口不做校验 | 唯一的守卫仍要留:**拒绝**与内置非模块角色冲突的后缀(§3.4) | +| 时长串 `"30m"` | 与三个既有秒制旗标不一致 | +| `module_extensions` 沿依赖边传播 | 它是私有构建属性,不是 usage requirement | +| 删 `SourceUnit::isModuleInterface` / `isImplementation` | 改动面翻倍,收益 0;改成 `kind` 的派生字段即可 | +| 修 `foo.c` / `foo.cpp` 对象名碰撞 | 要动缓存条目布局 ⇒ 单独 issue + 缓存键版本 | +| Windows 的 deadline 实现 | 独立工作量,不该卡住这两个键 | + +--- + +## 7.1 实施记录:设计与现实的四处偏差 + +实施 PR-2 步 1–5 时发现的、设计阶段没看到的事实。记在这里,因为其中三条是**潜在缺陷**, +不是风格问题。 + +### ① `isModuleInterface` / `isImplementation` 是**写而不读**的死字段 + +设计文档写「保留它们(约 20 处读者)」—— **错的**。 +`grep -rn 'isImplementation\|isModuleInterface' src/ tests/` 的结果是:**5 处写,0 处读**。 + +而这 5 处写里有 **3 份互不一致的推导**(scanner 说 `.cpp`,p1689 说 `.cpp || .cxx`, +scan_overrides 说「有 provides」)。 + +⇒ **删掉,而不是收敛。** 收敛三份对一个没人读的值的推导,仍然是三份推导。 +`provides` 回答「是不是接口」,`kind` 回答其余。 + +### ② `is_implementation_source` 漏了 `.mm` + +旧清单 `{.cpp .cc .cxx .c .m .S .s .asm}` **没有 `.mm`**。 +所以一个 Objective-C++ 单元会被编译、然后**永远不进链接**。 +改成 kind-based(`Cxx` 含 `.mm`)顺带修掉。 + +### ③ stage 兜底 glob 漏了全部三种汇编扩展名 + +`prepare.cppm` 的 `{cppm,cpp,cc,c}` 比约定默认少了 `.S/.s/.asm`。 +⇒ stage 一个含汇编的依赖时**静默丢掉那些源文件**。改成 `default_source_globs()` 后消失。 + +### ④ A-1′ 的验证方式:要归一化两条环境量 + +fixture(2 个 `.cppm` + 2 个 `.cpp` + 1 个 `.c` + 1 个 `.S`,覆盖全部角色) +在实施前后 `build.ninja` 的差异**只有两行**,且都与语义无关: + +``` +mcpp = <这次跑的 mcpp 二进制自己的路径> +ldflags = ... -specs=/mcpp-clean-link.specs # build dir 名含指纹 +``` + +归一化掉这两条之后是**真·空 diff**,且**指纹目录名逐字符相同**(`7ca4d5d84ff1fd98`), +11 个指纹字段逐个相同 —— 包括 `[7] compile flags hash`。 + +> ⚠️ **验证过程本身踩了一个记录在案的坑**:`find target -name mcpp | head -1` 取到的是 +> **上一个会话留下的旧二进制**(指纹目录随版本变,`head -1` 不是「最新」)。 +> 它报 `[8] 2026.8.10.2`,让我一度以为自己改动了指纹。 +> **取二进制必须按 mtime 排序,并核对 `--version`。** + +--- + +## 7.2 ⚠️ GCC 16.1.0 地雷:新模块的**导出接口**里出现 `std` 类型会毒化下游 BMI + +实施 Windows deadline(P6)时撞到的,**与本方案的两个功能都无关,但会咬所有人**。 + +### 现象 + +给 `mcpp.platform.process` 加一个 `import`,指向一个**新建的**模块,之后: + +``` +failed: obj/runtime_selection.m.o gcm.cache/mcpp.xlings.runtime_selection.gcm +mcpp.manifest.xpkg: error: failed to read compiled module cluster 192: Bad file data +mcpp.manifest.toml: error: failed to read compiled module cluster 392: Bad file data +... fatal error: failed to load pendings for 'std::allocator' +``` + +**下游几十个 BMI 全部报废**,报错指向的模块(`mcpp.manifest.xpkg`)和改动毫无关系。 + +### 逐步二分(每一步都是干净重建) + +| 变体 | 结果 | +|---|---| +| 模块存在,但没人 import 它 | ✅ 通过 | +| 被 import,只导出 `int probe()`,**purview 里没有 `import std;`** | ✅ 通过 | +| 被 import,只导出 `int probe()`,**purview 里有 `import std;`** | ✅ 通过 | +| 被 import,**导出接口里出现 `std::string` / `std::vector` / `std::chrono`** | ❌ 下游 BMI 全坏 | +| `import` 换成一个**已存在**的模块(`mcpp.platform.fs`) | ✅ 通过 | + +⇒ 触发条件是 **「新模块 × 被 import × 导出接口里有 std 类型」** 三者同时成立。 +模块内部随便用 `std`,没问题。 + +### 排除项(都实测过,都不是) + +- **不是竞态**:`ninja -j1` 串行同样失败,且三次运行完全一致。 +- **不是陈旧产物**:每次都整目录重建。 +- **不是命名冲突**:换模块名、换目录都一样。 +- **不是那条 `-Wglobal-module` 警告**:把 `extern "C" char **environ;` 从 + global module fragment 移进 purview(这本身是对的,消掉了一条真实警告),现象不变。 + +### 采取的做法 + +平台 shim 的**导出接口做成 std-free**:输出走回调 `void(*)(void*, const char*, unsigned long)`, +环境变量走 `const char* const*` 的 `"K=V"` 数组,返回值只有 `bool`/`int`。 +`std` 只在模块**内部**使用。编组代码放在调用方(`mcpp.platform.process`), +那边本来就持有这些 vector。 + +这不是权宜之计:一个平台 shim 用 C 风格边界本来就是标准做法,而且它把 +「这个模块不得把 std 类型放进接口」变成了一条**写在文件头、有实测支撑**的约束, +而不是一句口头约定。 + +⚠️ **给后来者**:在 `src/platform/` 下新建模块并让 `mcpp.platform.process` +(或任何被广泛 import 的底层模块)import 它时,**先只导出内置类型**。 +BMI 坏掉时报错会指向一个和你的改动毫无关系的模块,极难归因 —— 我花了七轮二分。 + +--- + +## 7.3 实施最终形态(与设计的差异) + +| 设计写的 | 实际做的 | 为什么 | +|---|---|---| +| 保留 `isModuleInterface`/`isImplementation` | **删掉** | 它们是写而不读的死字段(5 写 0 读),3 份互不一致的推导 | +| 方言侧「按需补 `-x`」 | **无条件补** | 旗标幂等已实测;查表会过期且错了静默 | +| Windows deadline 单开 issue | **本 PR 实现** | 用户要求跨平台;平台代码落到 `src/platform/windows/` | +| —— | **新增 `src/platform/{unix,windows,linux,macos}/`** | 用户要求;`process.cppm` 的 25 处 `#if` 收敛成一处 `if constexpr` 分派 | +| —— | **新增可观察性** | `mcpp self doctor` 报告生效的扩展名表、超时值及其来源、deadline 是否真的强制 | + +### 可观察性(实测输出) + +``` + Checking build policy + ok module interfaces: .cppm .ixx .ccm .cxxm (3 from [build] module_extensions) + ok build.mcpp run bound: 600s (from built-in default) + ok process deadlines: enforced (POSIX SIGKILL / Windows job object) +``` + +外加:`module_extensions` 里零命中的条目会告警(死配置 ≠ 打字错误,不报就分不清)。 + +### 实测通过的判据 + +| 判据 | 结果 | +|---|---| +| A-1′ 图形态零差分 | ✅ 未配置时 `build.ninja` 的 diff 只有 2 条 rule 命令 + 2 个 `unit_lang`,**零 `build` 边变化**,指纹目录名逐字符相同 | +| 四种扩展名 build→link→run | ✅ **GCC 与 Clang 两条腿都过**(e2e 217) | +| 对象名无碰撞 | ✅ `a.m.o` / `b.ccm.o` / `c.cxxm.o` / `d.ixx.o` | +| 快路径扫描 `.ixx` | ✅ 加 `import` 后强制全量 prepare(e2e 218) | +| 指纹包含 `module_extensions` | ✅ `65e7cc0a` → `497632df` | +| manifest 超时键 | ✅ 2s 生效、env=1 覆盖它、负数硬错误(e2e 186) | +| 超时报错指名 manifest | ✅ 打印该包 `mcpp.toml` 的绝对路径 | + +--- + +## 8.1 本次不做,但已排期 + +| 项 | 状态 | 说明 | +|---|---|---| +| **基于 xlings 生态的图形开发可用性验收** | **推迟**(2026-08-11 决定) | 等 xlings 最新版本发布后单独做。本 PR 的验收范围到「xlings 生态装→建→跑」为止,不含 GL context 获取那一段。图形栈本身的三层故障见 `2026-08-10-graphics-stack-usability-design.md` | + +--- + +## 9. 开放问题 + +1. **`[lib]` 约定(#19)** 是否要跟着 `module_extensions` 走? + 倾向**不**:它是**写**路径(`mcpp new` 生成 `src/.cppm`), + 把生成物的拼法交给配置只会让脚手架产出不可预测的文件名。 +2. **`module_extensions` 要不要在 `mcpp doctor` 里回显?** + 倾向要 —— 一个「为什么我的 `.ixx` 没被编译」的问题, + 目前唯一的自查手段是读 `inferredNotes`。 +3. **索引侧的 mcpp 版本下限**(§5)当前是什么机制、是否可降级 —— + 这是 PR-2 发布前的**阻塞项**,不是实施项。 diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index d22e74ef..7e18b592 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.10.4' + default: '2026.8.11.1' 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 750ed3df..d54ebe61 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.10.4' + default: '2026.8.11.1' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index 5983d4b2..7c12ee1a 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.10.4' + XLINGS_VERSION: '2026.8.11.1' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index c08bc91f..25e7d228 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.10.4 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -293,7 +293,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.10.4 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.1 # 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 @@ -364,7 +364,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.10.4 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.1 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 a33ad7ee..cc0ed548 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -133,7 +133,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.10.4 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.11.1 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 16679054..28efad39 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.10.4' + XLINGS_VERSION: '2026.8.11.1' 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.10.4' + XLINGS_VERSION: '2026.8.11.1' 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 fbb0b9b9..c1e5d96f 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.10.4' + XLINGS_VERSION: '2026.8.11.1' 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.10.4' + XLINGS_VERSION: '2026.8.11.1' 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.10.4-linux-aarch64.tar.gz" + XLA="xlings-2026.8.11.1-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.10.4/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.11.1/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.10.4-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.11.1-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.10.4' + XLINGS_VERSION: '2026.8.11.1' 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.10.4' + XLINGS_VERSION: '2026.8.11.1' 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/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 804b980a..5246c191 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -156,6 +156,8 @@ the package/feature boundary, not on an individual target. ```toml [build] sources = ["src/**/*.cppm", "src/**/*.cpp"] # Source globs (default: src/**/*.{cppm,cpp,cc,c,S,s,asm}) +module_extensions = [".ixx"] # Extra extensions your module INTERFACES use (§ below) +build_program_timeout = 1800 # Seconds a build.mcpp may run; 0 = no limit (§ below) include_dirs = ["include", "third_party/include"] # Header search paths include_dirs_after = ["*"] # Header dirs searched AFTER system dirs (-idirafter) c_standard = "c11" # Standard for C source files (default c11) @@ -194,6 +196,83 @@ baseline, and 14.0 is the floor of LLVM's official static libraries themselves). This value enters the BMI fingerprint, so switching targets automatically rebuilds the module cache. +### Module interface extensions (`module_extensions`) + +mcpp treats `.cppm` as a module interface unit. The C++ ecosystem has not +converged on one spelling — Clang also recognizes `.ccm` and `.cxxm`, MSVC uses +`.ixx` — so a project whose interfaces use another extension declares it: + +```toml +[build] +module_extensions = [".ixx", ".ccm"] +``` + +The list is **additive**: `.cppm` is always a module interface and cannot be +removed. To stop a particular file from being built, `!`-exclude it in +`sources`; that is what `sources` is for. + +Declaring an extension does three things at once, which is the point of having +one key rather than several: + +1. the convention default for `sources` grows to match, so the files are + **found** (`src/**/*.ixx` joins the default glob); +2. those units compile with the **module** rule — they emit a BMI and their + objects are linked unconditionally; +3. the freshness fast path watches them, so adding an `import` to one + invalidates the build graph instead of silently reusing a stale one. + +Any extension is accepted **except** ones that already name a non-module role +(`.cpp` `.cc` `.cxx` `.c` `.m` `.mm` `.h` `.hpp` `.hh` `.hxx` `.S` `.s` +`.asm`); claiming one of those is a manifest error rather than a warning, +because it would route (say) C files to the C++ module rule and fail somewhere +that names neither the file nor this key. + +Extensions are matched **literally, without case folding** — `.S` and `.s` are +different languages in this domain, so case is never ignored. + +mcpp always tells the compiler explicitly that a module interface unit is one +(`-x c++-module` on Clang, `-x c++` on GCC, `/interface /TP` on MSVC), so an +extension the compiler driver has never heard of works anyway. This is why any +extension is allowed: mcpp does not need the compiler to recognize it. + +> **Publishing note.** An older mcpp does not know this key: it warns, ignores +> it, and then compiles those files as ordinary translation units — a wrong +> build rather than a clean failure. If you publish a package that uses +> `module_extensions`, declare an mcpp version floor in its index descriptor. + +### Build-program timeout (`build_program_timeout`) + +A `build.mcpp` gets **600 seconds** by default, after which mcpp kills it and +fails the build naming the package. A project whose build program legitimately +runs longer (a large code-generation step) raises its own bound: + +```toml +[build] +build_program_timeout = 1800 # seconds; 0 = no limit +``` + +The value is read from **the manifest of the package that owns the +`build.mcpp`** — a dependency's generator is bounded by the dependency's own +declaration, because its author is the one who knows how long it takes. The +precedence follows the same shape as `macos_deployment_target`: + +``` +MCPP_BUILD_PROGRAM_TIMEOUT= (this invocation; highest) + > [build] build_program_timeout (that package's manifest) + > 600 (built-in default) +``` + +Leaving the key out is not the same as setting `0`: unset means "use the +default bound", `0` means "no bound at all". + +This value is deliberately **not** part of the build fingerprint — it changes +no edge in the graph, and folding it in would mean that raising a timeout +rebuilt the whole project, which is the opposite of what someone raising a +timeout wants. + +The **compile** phase is not bounded, only the build *program*. See +[07-build-mcpp.md](07-build-mcpp.md) for why that asymmetry is deliberate. + ### The C++ runtime contract (`cxx_runtime`) `cxx_runtime` states what the produced artifact promises about the machine that diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index da068594..8a5f6edb 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -401,15 +401,49 @@ When nothing changed you'll see `build.mcpp up to date (cached)`; otherwise - **CWD is the project root**, so relative paths (`src/generated.cpp`) land where you expect. - A non-zero exit from `build.mcpp` aborts the build and prints its output. -- **The run is bounded** (mcpp 2026.8.5.1+, **POSIX only**): a build program - gets **600 s** by default, after which mcpp kills it and fails the build - naming the package. Override with `MCPP_BUILD_PROGRAM_TIMEOUT=` - (`0` = no limit). **On Windows the bound is not enforced** — the process - launcher has no kill-by-handle path yet (`mcpp.platform.process`), so a - build program that hangs there still hangs the build. Same limitation as - `mcpp test --timeout`; stated rather than papered over. The - **compile** is deliberately *not* bounded — the same asymmetry `mcpp test` +- **The run is bounded** (mcpp 2026.8.5.1+): a build program gets **600 s** by + default, after which mcpp kills it and fails the build naming the package. + Configure it per package: + + ```toml + [build] + build_program_timeout = 1800 # seconds; 0 = no limit + ``` + + Precedence, highest first — the same shape `macos_deployment_target` uses: + + ``` + MCPP_BUILD_PROGRAM_TIMEOUT= this invocation only + > [build] build_program_timeout the manifest of the package that OWNS the build.mcpp + > 600 built-in default + ``` + + The value comes from the **owning package's** manifest, because its author is + the one who knows how long the generator takes. When a dependency's build + program times out, the error names the exact `mcpp.toml` to edit — editing + your own would change nothing. + + Omitting the key is not the same as `0`: unset means "use the default bound", + `0` means "no bound at all". + + **The bound is enforced on every platform** as of mcpp 2026.8.11.1. It used + to be POSIX-only: the Windows launcher fell through to an unbounded path, so + this knob — and `mcpp test --timeout`, and `--build-timeout` — silently did + nothing there. Windows now runs the child in a Job object and closes it on + expiry, which takes the whole process tree rather than just the direct child + (a grandchild left holding the capture pipe would otherwise hang the drain + after the kill). + + The **compile** is deliberately *not* bounded — the same asymmetry `mcpp test` uses: a long compile is usually legitimate (a first-run `std` module build is minutes) and killing it produces a baffling failure, while a long-running build *program* is usually stuck, and an unbounded one hangs the whole build with no diagnostic at all. + + > **Why not "ask the user instead of aborting"** ([#410](https://github.com/mcpp-community/mcpp/issues/410)): + > the program's stdout is already dup2'd into a pipe that carries the `mcpp:` + > directive protocol, so there is no interaction channel; most builds run + > where nobody is watching (CI, a pipeline, ninja's child), and a build + > blocked on a prompt is harder to diagnose than one that failed; and a build + > whose outcome depends on a keystroke is not reproducible. The configurable + > bound plus an error that names the file to edit answers the same need. diff --git a/docs/10-publishing-a-library.md b/docs/10-publishing-a-library.md index 06b72483..fd80e0f1 100644 --- a/docs/10-publishing-a-library.md +++ b/docs/10-publishing-a-library.md @@ -131,6 +131,24 @@ mcpp's build sandbox is network-isolated, so `file://` and seeded one is the copy that will still be there when the publish silently failed. +## Manifest keys that need a version floor + +Most `[build]` keys degrade cleanly on an older mcpp: it warns that the key is +unsupported, ignores it, and the build either still works or fails with a clear +message. `build_program_timeout` is one of those — an older mcpp falls back to +the 600 s default and, if that is too short, says so. + +**`module_extensions` is not.** An older mcpp warns and ignores it, and then +compiles those files as ordinary translation units — a *wrong build* rather +than a clean failure: the module interface produces no BMI, and the failure +surfaces later, somewhere that names neither the key nor the file. + +So if a package you publish uses `module_extensions`, declare an mcpp version +floor in its index descriptor. The floor mechanism must **degrade**: a package +that is unavailable because the client is too old has to be reported as +*unavailable*, never as *absent* — a client told "no such package" will keep +re-refreshing the index looking for it. + ## Checklist - [ ] `mcpp.toml` version == git tag diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index a964e893..ae91df70 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -149,6 +149,8 @@ mcpp 刻意不在一次构建里把同一个共享源编译成两份:一个源 ```toml [build] sources = ["src/**/*.cppm", "src/**/*.cpp"] # 源文件 glob(默认: src/**/*.{cppm,cpp,cc,c,S,s,asm}) +module_extensions = [".ixx"] # 模块**接口**额外使用的扩展名(见下节) +build_program_timeout = 1800 # build.mcpp 的运行上限(秒);0 = 不限(见下节) include_dirs = ["include", "third_party/include"] # 头文件搜索路径 include_dirs_after = ["*"] # 排在系统目录之后搜索的头文件目录(-idirafter) c_standard = "c11" # C 源文件的标准(默认 c11) @@ -179,6 +181,69 @@ cargo/rustc、cc 等同样尊重该变量)> 本字段(项目默认,类似 SwiftP 14.0 即 LLVM 官方静态库自身的下限)。该值会进入 BMI 指纹——切换 target 会自动重建模块缓存。 +### 模块接口扩展名(`module_extensions`) + +mcpp 把 `.cppm` 视为模块接口单元。C++ 生态并没有收敛到一种拼法 —— Clang 还认 +`.ccm` 和 `.cxxm`,MSVC 用 `.ixx` —— 所以接口用别的扩展名的工程自己声明: + +```toml +[build] +module_extensions = [".ixx", ".ccm"] +``` + +这个列表是**追加**的:`.cppm` 永远是模块接口,不能删。要让某个文件不参与构建, +用 `sources` 的 `!` 前缀 —— 那才是 `sources` 的职责。 + +声明一个扩展名会同时做三件事,这正是「一个键而不是几个键」的理由: + +1. `sources` 的约定默认值跟着变宽,文件才**能被找到**(`src/**/*.ixx` 自动进入默认 glob); +2. 这些单元用**模块**规则编译 —— 产出 BMI,其 `.o` 无条件进入链接; +3. 新鲜度快路径会扫描它们,所以给其中一个加 `import` 会让构建图作废, + 而不是静默复用一张过期的图。 + +**任何扩展名都接受**,唯独拒绝那些已经代表其他角色的 +(`.cpp` `.cc` `.cxx` `.c` `.m` `.mm` `.h` `.hpp` `.hh` `.hxx` `.S` `.s` `.asm`)—— +这是 manifest **错误**而不是警告,因为它会把(比如)C 文件送进 C++ 模块规则, +最终失败在一个既不提文件也不提这个键的地方。 + +扩展名**按字面匹配,不做大小写折叠** —— 在这个领域里 `.S` 和 `.s` 是两种不同的语言, +所以大小写从不被忽略。 + +mcpp 每次都会**显式告诉编译器**这个单元是模块接口(Clang 用 `-x c++-module`, +GCC 用 `-x c++`,MSVC 用 `/interface /TP`),所以即使编译器驱动从没听说过这个扩展名 +也能工作。这也是为什么任何扩展名都被允许:mcpp 不需要编译器认识它。 + +> **发布须知**:旧版 mcpp 不认识这个键 —— 它会警告、忽略,然后把那些文件当作普通 +> 翻译单元编译,得到一个**错误的构建**而不是一次干净的失败。如果你要发布一个用了 +> `module_extensions` 的包,请在它的索引描述符里声明 mcpp 版本下限。 + +### 构建程序超时(`build_program_timeout`) + +`build.mcpp` 默认有 **600 秒**,超时后 mcpp 杀掉它并让构建失败、点名是哪个包。 +构建程序确实需要跑更久的工程(大规模代码生成)自己抬高上限: + +```toml +[build] +build_program_timeout = 1800 # 秒;0 = 不限 +``` + +这个值读的是**拥有该 `build.mcpp` 的那个包**的 manifest —— 依赖的生成器由依赖自己的 +声明来限制,因为只有它的作者知道要跑多久。优先级与 `macos_deployment_target` 同构: + +``` +MCPP_BUILD_PROGRAM_TIMEOUT=<秒> 本次调用(最高) + > [build] build_program_timeout 该包自己的 manifest + > 600 内置默认 +``` + +**不写这个键**与**写 `0`** 不是一回事:不写表示「用默认上限」,`0` 表示「完全不设上限」。 + +这个值刻意**不进构建指纹** —— 它不改变图里的任何一条边,而把它折进指纹会让 +「抬高超时」触发全量重建,这恰好与抬高超时的人想要的相反。 + +只有构建**程序**受限,**编译**不受限。原因见 +[07-build-mcpp.md](07-build-mcpp.md)。 + ### C++ 运行时契约(`cxx_runtime`) `cxx_runtime` 声明的是**产物对运行它的机器做出的承诺**。它是**分发**属性而非 diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 3977fb84..9656b510 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -357,11 +357,40 @@ mcpp **不会**每次构建都重跑 `build.mcpp`。它会缓存程序产出的 [05 - mcpp.toml 工程文件指南](05-mcpp-toml.md)。 - **当前工作目录是工程根目录**,因此相对路径(`src/generated.cpp`)会落在你预期的位置。 - `build.mcpp` 非零退出会中止构建并打印其输出。 -- **运行有时间上限**(mcpp 2026.8.5.1+,**仅 POSIX**):构建程序默认有 **600 秒**, - 超时后 mcpp 杀掉它并让构建失败,错误里会点名是哪个包。用 - `MCPP_BUILD_PROGRAM_TIMEOUT=<秒>` 覆盖(`0` = 不限)。**Windows 上这个上限不生效** - —— 进程启动器还没有 kill-by-handle 的路径(`mcpp.platform.process`),所以在那里 - 卡死的构建程序仍会把构建挂住。与 `mcpp test --timeout` 是同一条限制;明说,而不是 - 含糊过去。**编译**这一步刻意**不设**上限——与 `mcpp test` 同一条不对称纪律: +- **运行有时间上限**(mcpp 2026.8.5.1+):构建程序默认有 **600 秒**,超时后 mcpp + 杀掉它并让构建失败,错误里会点名是哪个包。可按包配置: + + ```toml + [build] + build_program_timeout = 1800 # 秒;0 = 不限 + ``` + + 优先级(与 `macos_deployment_target` 同构): + + ``` + MCPP_BUILD_PROGRAM_TIMEOUT=<秒> 本次调用 + > [build] build_program_timeout 拥有该 build.mcpp 的那个包的 manifest + > 600 内置默认 + ``` + + 值取自**拥有该构建程序的包**的 manifest,因为只有它的作者知道生成器要跑多久。 + 当一个**依赖**的构建程序超时时,错误会点名要改的那份 `mcpp.toml` —— 改自己的 + 那份不会有任何效果。 + + 不写这个键与写 `0` 不是一回事:不写=用默认上限,`0`=完全不设上限。 + + **这个上限从 mcpp 2026.8.11.1 起在所有平台生效**。此前它只在 POSIX 上生效: + Windows 的启动器会退回到无界路径,所以这个键——以及 `mcpp test --timeout`、 + `--build-timeout`——在那里都是静默的空操作。现在 Windows 把子进程放进 Job 对象, + 到期时关闭它,于是被杀掉的是**整棵进程树**而不只是直接子进程(否则一个还攥着 + 捕获管道的孙进程会让杀掉之后的读取一直挂住)。 + + **编译**这一步刻意**不设**上限——与 `mcpp test` 同一条不对称纪律: 编译跑得久通常是正当的(首次构建 `std` 模块就是分钟级),杀掉它只会产生莫名其妙的 失败;而构建**程序**跑得久通常是卡住了,不设上限就会让整个构建挂死且毫无诊断。 + + > **为什么不做「超时时询问用户」**([#410](https://github.com/mcpp-community/mcpp/issues/410)): + > 构建程序的 stdout 已经被 dup2 进一根承载 `mcpp:` 指令协议的管道,没有交互通道; + > 多数构建发生在没有人看的地方(CI、流水线、ninja 的子进程),而一个卡在提示上的 + > 构建比一个失败的构建更难诊断;并且构建结果不应该取决于一次击键。 + > 可配置的上限 + 一条点名要改哪个文件的报错,回答的是同一个需求。 diff --git a/mcpp.toml b/mcpp.toml index eb611b5c..fe50d791 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.10.3" +version = "2026.8.11.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 6af390da..799f6429 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -763,17 +763,28 @@ std::expected run_build_program( // whole build hangs with no diagnostic at all. mcpp::ui::info("build.mcpp", "running"); bool timedOut = false; + // The bound comes from THIS package's manifest — a dependency's generator + // is bounded by the dependency's own declaration, because its author is + // the one who knows how long it takes. + const auto deadline = + dirs::run_timeout_for(m.buildConfig.buildProgramTimeoutSecs); auto rres = mcpp::platform::process::capture_exec_deadline( - {bin.string()}, childEnv, dirs::run_timeout(), &timedOut, root.string()); + {bin.string()}, childEnv, deadline, &timedOut, root.string()); if (timedOut) { + // Name the manifest to edit. Without this the user edits their OWN + // mcpp.toml when a dependency's build program is what timed out, and + // nothing changes — which is how issue #410 reads from the outside. + auto ownManifest = (root / "mcpp.toml").string(); return std::unexpected(std::format( "build.mcpp for '{}' exceeded its {}s time limit and was killed.\n" - " Raise or disable it with MCPP_BUILD_PROGRAM_TIMEOUT= " - "(0 = no limit).\n" + " Raise it in that package's own manifest:\n" + " {} -> [build] build_program_timeout = \n" + " Or for this invocation only:\n" + " MCPP_BUILD_PROGRAM_TIMEOUT= (0 = no limit)\n" " Output so far:\n{}", m.package.name.empty() ? std::string("") : m.package.name, - dirs::run_timeout().count() / 1000, rres.output)); + deadline.count() / 1000, ownManifest, rres.output)); } if (rres.exit_code != 0) { return std::unexpected(std::format( diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index 9d098426..0bee6eac 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -15,6 +15,7 @@ export module mcpp.build.compile_commands; import std; +import mcpp.source_kind; import mcpp.build.plan; import mcpp.build.flags; import mcpp.libs.json; @@ -77,11 +78,6 @@ namespace mcpp::build { namespace { -bool is_c_source(const std::filesystem::path& src) { - auto ext = src.extension(); - return ext == ".c" || ext == ".m"; -} - } // namespace // Split one flag string into CDB `arguments` tokens. @@ -229,12 +225,11 @@ std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& fla // NASM units carry a command line no CDB consumer (clangd, …) can // interpret — a bogus entry actively harms LSP diagnostics, so they // are excluded from the CDB entirely. - if (cu.source.extension() == ".asm") continue; - // Pick compiler + flags based on source type. GAS units (.S/.s) ride + if (cu.kind == mcpp::SourceKind::NasmAsm) continue; + // Pick compiler + flags based on source ROLE. GAS units (.S/.s) ride // the C driver with the asm-safe flag string, mirroring build.ninja. - const auto ext = cu.source.extension(); - const bool isGasSource = ext == ".S" || ext == ".s"; - const bool isCSource = is_c_source(cu.source) || isGasSource; + const bool isGasSource = cu.kind == mcpp::SourceKind::GasAsm; + const bool isCSource = cu.kind == mcpp::SourceKind::C || isGasSource; const auto& compiler = isCSource ? flags.ccBinary : flags.cxxBinary; const auto& flagStr = isGasSource ? flags.as : isCSource ? flags.cc diff --git a/src/build/directives.cppm b/src/build/directives.cppm index 41014758..1b8aec21 100644 --- a/src/build/directives.cppm +++ b/src/build/directives.cppm @@ -38,55 +38,34 @@ export module mcpp.build.directives; import std; +import mcpp.build.program_protocol; import mcpp.libs.json; import mcpp.manifest; +import mcpp.source_kind; import mcpp.toolchain.dialect; import mcpp.toolchain.fingerprint; // hash_string for the glob fingerprint import mcpp.modgraph.glob; // the one path-glob matcher export namespace mcpp::build::directives { -// ── Protocol version ─────────────────────────────────────────────────────── +// ── Protocol terms ───────────────────────────────────────────────────────── // -// The wire version this engine speaks. The bundled `mcpp` module announces the -// version it was built against (`mcpp:protocol=`) before main runs, so a -// program and the engine that compiled it always agree — the announcement only -// ever disagrees when a *cached* helper binary outlives an engine change, which -// is precisely the case worth catching. +// The protocol version, the cache epoch and the run bound have moved to +// `mcpp.build.program_protocol`. They are the terms both sides agree on BEFORE +// any directive is exchanged, they have consumers that need nothing else from +// this file (hostprogram stamps the version; build_program applies the bound), +// and keeping them here meant importing the whole directive table to ask one +// number. // -// Bump when the meaning of an existing directive changes, or when a new -// directive is added that a program may rely on. An engine seeing a HIGHER -// number than this must refuse: it cannot know what it is being asked to do, -// and "warn and ignore" would turn that into a silently different build. -// v2 (#359): adds `rerun-if-changed-glob`. -inline constexpr int kProtocolVersion = 2; - -// ── Cache-format epoch ───────────────────────────────────────────────────── -// -// Bump ONLY when previously written build.mcpp.cache entries become unusable — -// the record shape changed, or a directive's *interpretation* changed so that -// replaying a cached value would no longer mean what it meant when written. -// Deliberately NOT the mcpp release number: folding the whole version in would -// re-run every build program on every release for nothing. Same discipline as -// mcpp.build.cache_key::kCacheEpoch. -// Epoch 2 (#359): entries gained `glob` records. An engine that does not know -// them would replay a strict subset of the declared inputs and call a stale -// build fresh, which is exactly the silent-wrong-answer this guard exists for. -inline constexpr int kCacheEpoch = 2; - -// ── Run bound ────────────────────────────────────────────────────────────── -// -// How long a build program may RUN before mcpp kills it. The compile is -// deliberately left unbounded — the same asymmetry `mcpp test` settled on -// (run 300s / build 0): a long compile is usually legitimate (a first-run std -// module build is minutes) and killing it produces a baffling failure, while a -// long-running build PROGRAM is usually stuck, and without a bound the whole -// build hangs with no diagnostic at all. -// -// MCPP_BUILD_PROGRAM_TIMEOUT overrides, in seconds; 0 disables the bound. -inline constexpr int kDefaultRunTimeoutSecs = 600; - -std::chrono::milliseconds run_timeout(); +// Re-exported under the old names so existing readers (`dirs::kCacheEpoch` in +// build_program.cppm, `dirs::kProtocolVersion` in the tests) keep working — +// this is one contract seen through two namespaces, not two contracts. +using mcpp::build::program_protocol::kProtocolVersion; +using mcpp::build::program_protocol::kCacheEpoch; +using mcpp::build::program_protocol::kDefaultRunTimeoutSecs; +using mcpp::build::program_protocol::env_timeout_override; +using mcpp::build::program_protocol::run_timeout; +using mcpp::build::program_protocol::run_timeout_for; // ── The table ────────────────────────────────────────────────────────────── @@ -309,7 +288,8 @@ void prepare_actions(std::vector& actions, // // The non-source outputs are still declared to ninja, so the edge still // produces them and anything that includes them still waits for the generator. -bool is_compilable_output(const std::filesystem::path& p); +bool is_compilable_output(const std::filesystem::path& p, + const mcpp::ExtensionTable& t); // ── Private-scope fold (was prepare.cppm's DirectiveMark / fold pair) ────── // @@ -374,18 +354,6 @@ std::string abs_against(const fs::path& base, std::string_view p) { return pp.lexically_normal().string(); } -std::chrono::milliseconds run_timeout() { - int secs = kDefaultRunTimeoutSecs; - if (const char* v = std::getenv("MCPP_BUILD_PROGRAM_TIMEOUT")) { - std::string_view sv(v); - int parsed = 0; - if (std::from_chars(sv.data(), sv.data() + sv.size(), parsed).ec == std::errc{} - && parsed >= 0) - secs = parsed; - } - return std::chrono::milliseconds(static_cast(secs) * 1000); -} - namespace { std::string trim(std::string_view s) { @@ -645,14 +613,16 @@ std::string action_error(const Directives& d) { return {}; } -bool is_compilable_output(const fs::path& p) { - auto ext = p.extension().string(); - // The same set the plan treats as translation units, plus .cppm/.ixx for a - // generated module interface. - return ext == ".cpp" || ext == ".cc" || ext == ".cxx" || ext == ".c" - || ext == ".m" || ext == ".mm" - || ext == ".S" || ext == ".s" || ext == ".asm" - || ext == ".cppm" || ext == ".ixx"; +// Can a build program's declared output be fed to the compiler? +// +// This used to carry the fifth hand-written extension list — and the ONLY one +// that mentioned `.ixx`, which is how a generated `.ixx` was accepted here and +// then mis-handled by every stage after it. It now asks the classifier with +// the OWNING PACKAGE's table, so "mcpp will compile this" and "mcpp knows what +// this is" are the same question again. +bool is_compilable_output(const fs::path& p, const mcpp::ExtensionTable& t) { + auto kind = mcpp::classify(p, t); + return kind != mcpp::SourceKind::Header && kind != mcpp::SourceKind::Other; } void prepare_actions(std::vector& actions, diff --git a/src/build/execute.cppm b/src/build/execute.cppm index aab1b3b0..a43c4869 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -20,6 +20,7 @@ import mcpp.build.ninja; import mcpp.build.runtime_validation; import mcpp.bmi_cache; import mcpp.manifest; +import mcpp.source_kind; import mcpp.modgraph.scanner; import mcpp.toolchain.post_install; import mcpp.toolchain.stdmod; @@ -548,9 +549,14 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, // lives elsewhere under the project root and gets swept in by some other // caller's broader glob; and it's the same choke-point fix as expand_glob // itself, see scanner.cppm). +// `extTable` has no default ON PURPOSE. A default would let a future caller +// sweep with the built-in table while the project classifies with a wider one +// — the exact shape of the bug this converge is removing, reintroduced as a +// parameter default. Callers must say where their table came from. bool sources_newer_than(const std::filesystem::path& projectRoot, std::filesystem::file_time_type ninjaTime, - const std::vector& resourceScripts = {}) { + const std::vector& resourceScripts, + const mcpp::ExtensionTable& extTable) { std::error_code ec; // The root build.mcpp is a build input too — its directives shape // build.ninja (flags, generated/selected sources). A changed program must @@ -587,11 +593,18 @@ bool sources_newer_than(const std::filesystem::path& projectRoot, if (ec) { ec.clear(); continue; } // missing → prepare_build reports it if (ft > ninjaTime) return true; } + // The one place classification is legitimately re-derived: this runs + // BEFORE prepare, so there is no plan to read a kind from. It uses the + // SAME table the scanner will use (this project's manifest), so the two + // cannot drift — which is exactly what the old hand-written list did. + // + // The question here is NOT "did a file change" (ninja answers that) but + // "could the SHAPE of the graph have changed". A `.ixx` missing from the + // old list meant a new `import` inside one never invalidated the fast + // path: ninja recompiled the object, the dyndep edges stayed stale, and + // nothing reported anything. for (auto& f : mcpp::modgraph::expand_glob(projectRoot, "src/**/*")) { - auto ext = f.extension().string(); - if (ext != ".cppm" && ext != ".cpp" && ext != ".cc" && - ext != ".cxx" && ext != ".c" && ext != ".h" && ext != ".hpp") - continue; + if (!mcpp::affects_graph_shape(mcpp::classify(f, extTable))) continue; auto ft = std::filesystem::last_write_time(f, ec); if (ec || ft > ninjaTime) return true; } @@ -683,6 +696,10 @@ struct FastPathIdentity { // already parses the manifest — re-reading it to answer a second question // would be a second derivation of the same fact. std::vector resourceScripts; + // Same argument one field down: the freshness sweep has to know which + // extensions are module interfaces in THIS project, and this is already + // the only manifest read on the fast path. + mcpp::ExtensionTable extTable; }; std::optional @@ -695,6 +712,7 @@ fast_path_identity(const std::filesystem::path& projectRoot, std::string(mcpp::build::cache_mode_name( mcpp::build::resolve_cache_mode(*m, ""))), m->resources.files, + mcpp::extension_table_for(m->buildConfig.moduleExtensions), }; } @@ -774,7 +792,8 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // mcpp#225: bounded + vcs/build-dir-excluded walk (see sources_newer_than) // instead of a hand-rolled recursive_directory_iterator over src/. - if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts)) return std::nullopt; + if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts, + want->extTable)) return std::nullopt; auto validatedBefore = mcpp::build::runtime_validation::validated_artifact_snapshot( @@ -877,7 +896,8 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, auto tomlTime = std::filesystem::last_write_time(tomlPath, ec); if (ec || tomlTime > ninjaTime) return std::nullopt; - if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts)) return std::nullopt; + if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts, + want->extTable)) return std::nullopt; auto validatedBefore = mcpp::build::runtime_validation::validated_artifact_snapshot( diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 9fade77a..2f24c243 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -20,6 +20,7 @@ export module mcpp.build.ninja; import std; import mcpp.build.backend; import mcpp.manifest; +import mcpp.source_kind; import mcpp.build.distribution; import mcpp.build.graph_shape; import mcpp.build.loader_contract; @@ -109,13 +110,13 @@ std::string escape_flag_path(const std::filesystem::path& p) { return out; } -bool is_nasm_source(const std::filesystem::path& src) { - return src.extension() == ".asm"; +bool is_nasm_source(const CompileUnit& cu) { + return cu.kind == mcpp::SourceKind::NasmAsm; } std::string local_include_flags(const CompileUnit& cu, const mcpp::toolchain::CommandDialect& d) { - const bool nasmUnit = is_nasm_source(cu.source); + const bool nasmUnit = is_nasm_source(cu); const bool msvcDialect = d.includePrefix == std::string_view("/I"); std::string flags; for (auto const& inc : cu.localIncludeDirs) { @@ -231,21 +232,19 @@ std::filesystem::path mcpp_exe_path() { return mcpp::platform::fs::self_exe_path(); } -bool is_c_source(const std::filesystem::path& src) { - auto ext = src.extension(); - return ext == ".c" || ext == ".m"; +bool is_c_source(const mcpp::build::CompileUnit& cu) { + return cu.kind == mcpp::SourceKind::C; } -bool is_gas_source(const std::filesystem::path& src) { - auto ext = src.extension(); - return ext == ".S" || ext == ".s"; +bool is_gas_source(const mcpp::build::CompileUnit& cu) { + return cu.kind == mcpp::SourceKind::GasAsm; } // TUs the P1689 module scan must skip: C-family and assembly units cannot // contain `import`/`module` declarations, and feeding them to the scanner // would route them through the C++ frontend. -bool is_scan_exempt(const std::filesystem::path& src) { - return is_c_source(src) || is_gas_source(src) || is_nasm_source(src); +bool is_scan_exempt(const mcpp::build::CompileUnit& cu) { + return mcpp::is_scan_exempt(cu.kind); } // Per-unit flags an assembler can take: the -D/-U/-I subset of the unit's C @@ -415,9 +414,9 @@ std::string emit_ninja_string(const BuildPlan& plan) { bool need_c_rule = false, need_asm_rule = false, need_nasm_rule = false; for (auto& cu : plan.compileUnits) { - if (is_c_source(cu.source)) need_c_rule = true; - else if (is_gas_source(cu.source)) need_asm_rule = true; - else if (is_nasm_source(cu.source)) need_nasm_rule = true; + if (is_c_source(cu)) need_c_rule = true; + else if (is_gas_source(cu)) need_asm_rule = true; + else if (is_nasm_source(cu)) need_nasm_rule = true; } // The macOS initializer-ordering shim (#336) is a C translation unit, so @@ -662,9 +661,16 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(std::format(" rspfile_content ={}\n", payload)); }; - // cl.exe needs /TP (our module interfaces are .cppm, unknown to cl) and - // /interface to treat the TU as a module interface unit. - const std::string module_src_flags = msvcDeps ? " /interface /TP" : ""; + // Tell the driver, every time, that this TU is a module interface. + // + // The spelling is a property of the COMPILER FAMILY, not of the command + // dialect — gcc and clang share the gnu dialect but need different values + // — so it lives in BmiTraits. See the note there for the measurements + // that rule out the alternative of tracking which suffix each driver + // happens to know: that table expires with every compiler release, and + // getting it wrong is SILENT (Clang hands an unrecognized suffix to the + // linker, warns, and exits 0 having produced no BMI at all). + const std::string module_src_flags{traits.moduleInterfaceLangFlag}; append("rule cxx_module\n"); if constexpr (mcpp::platform::is_windows) { // Windows: skip BMI restat optimization (requires POSIX shell). @@ -679,13 +685,17 @@ std::string emit_ninja_string(const BuildPlan& plan) { "if [ -n \"$bmi_out\" ] && [ -f \"$bmi_out\" ]; then " "cp -p \"$bmi_out\" \"$bmi_out.bak\"; " "fi && " - "$cxx $local_includes $cxxflags $unit_cxxflags{} {}{}{} && " + // `-x c++` / `-x c++-module` is POSITIONAL on GNU drivers: it + // must precede `-c $in` (which compile_tail carries) or it + // applies to nothing. + "$cxx $local_includes $cxxflags $unit_cxxflags{}{} {}{}{} && " "if [ -n \"$bmi_out\" ] && [ -f \"$bmi_out.bak\" ] && " "cmp -s \"$bmi_out\" \"$bmi_out.bak\"; then " "mv \"$bmi_out.bak\" \"$bmi_out\"; " "else " "rm -f \"$bmi_out.bak\"; " - "fi\n", module_output_flag, mmd_flag, compile_tail, mmd_filter)); + "fi\n", module_output_flag, module_src_flags, mmd_flag, + compile_tail, mmd_filter)); append_cxx_deps(); } append(" description = MOD $out\n"); @@ -906,12 +916,16 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(std::format(" command = $cxx{} $cxxflags $unit_cxxflags " "/scanDependencies $out /TP /c $in /Fo:$compile_target\n", rsp_ref(scanPayload))); + // No $unit_lang here: /TP above already applies to every input, + // and cl has no per-suffix recognition problem to fix — a module + // interface is identified by /interface at COMPILE time, which + // the scan does not do. } else if (plan.scanDepsPath.empty()) { // GCC path: compiler-integrated P1689 scanning. append(std::format(" command = $cxx{} $cxxflags $unit_cxxflags -fmodules " "-fdeps-format=p1689r5 " "-fdeps-file=$out -fdeps-target=$compile_target " - "-M -MM -MF $out.dep -E $in -o $compile_target\n", + "-M -MM -MF $out.dep $unit_lang -E $in -o $compile_target\n", rsp_ref(scanPayload))); } else { // Clang path: clang-scan-deps writes the P1689 JSON itself via -o @@ -923,7 +937,8 @@ std::string emit_ninja_string(const BuildPlan& plan) { // overruns (#261: 48 -I entries at a deep consumer path). append(std::format( " command = $scan_deps -format=p1689 -o $out -- " - "$cxx{} $cxxflags $unit_cxxflags -c $in -o $compile_target\n", + "$cxx{} $cxxflags $unit_cxxflags $unit_lang -c $in " + "-o $compile_target\n", rsp_ref(scanPayload))); } append_rspfile(scanPayload); @@ -1002,19 +1017,26 @@ std::string emit_ninja_string(const BuildPlan& plan) { return s; }; - auto pick_rule = [](const std::filesystem::path& src) -> std::string { - auto ext = src.extension(); - if (ext == ".cppm") - return "cxx_module"; - if (ext == ".c" || ext == ".m") - return "c_object"; - if (ext == ".S") - return "asm_object"; - if (ext == ".s") - return "asm_object_raw"; - if (ext == ".asm") - return "nasm_object"; - return "cxx_object"; + // Rule selection is a pure function of the unit's KIND — never of its + // extension. mcpp#272 fixed link-object collection while this stayed + // extension-keyed, which routed a `.ixx` module interface to `cxx_object`: + // the edge still DECLARED a BMI output (that line reads providesModule) + // while the command line lost `-fmodule-output=`, so ninja asked for an + // artifact the command was no longer told to produce. + // + // `.S` vs `.s` is the one extension test that survives, and deliberately: + // it selects between two compile modes of ONE role (preprocessed or not), + // which is not a role distinction. + auto pick_rule = [](const mcpp::build::CompileUnit& cu) -> std::string { + switch (cu.kind) { + case mcpp::SourceKind::ModuleInterface: return "cxx_module"; + case mcpp::SourceKind::C: return "c_object"; + case mcpp::SourceKind::GasAsm: + return cu.source.extension() == ".S" ? "asm_object" + : "asm_object_raw"; + case mcpp::SourceKind::NasmAsm: return "nasm_object"; + default: return "cxx_object"; + } }; // ── Cache-served units: stage edges instead of compile edges ──────── @@ -1124,7 +1146,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { ddi_paths.reserve(plan.compileUnits.size()); for (auto& cu : plan.compileUnits) { if (cu.servedFromCache) continue; // staged, never scanned - if (is_scan_exempt(cu.source)) + if (is_scan_exempt(cu)) continue; auto ddi = (cu.object.parent_path() / cu.source.filename()).string() + ".ddi"; ddi_paths.push_back(ddi); @@ -1135,6 +1157,27 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(std::format(" local_includes ={}\n", includes)); if (auto flags = join_flags(cu.packageCxxflags); !flags.empty()) append(std::format(" unit_cxxflags ={}\n", flags)); + // The scan has the same suffix-recognition problem as the compile: + // a driver that does not know `.ixx` hands it to the linker and + // reports success with no .ddi. But unlike the compile rule this + // one is SHARED with implementation units, so the flag has to be + // per-edge — telling a plain `.cpp` it is `c++-module` would make + // Clang scan an implementation unit as an interface. + // + // ⚠️ The value is written WITHOUT its leading space and the + // separator lives in the rule's command string above. Ninja + // strips leading/trailing whitespace from a variable VALUE, so + // `-MF $out.dep$unit_lang` concatenated into + // `-MF foo.ddi.dep-x c++` and g++ then reported `c++` as a + // missing linker input. Same trap the stage_file `verify` + // variable documents a few hundred lines up. + if (mcpp::produces_bmi(cu.kind) + && !traits.moduleInterfaceLangFlag.empty()) { + auto lang = traits.moduleInterfaceLangFlag; + while (!lang.empty() && lang.front() == ' ') + lang.remove_prefix(1); + append(std::format(" unit_lang = {}\n", lang)); + } } append("\n"); @@ -1156,7 +1199,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { std::map ddi_expect; for (auto& cu : plan.compileUnits) { if (cu.servedFromCache) continue; - if (is_scan_exempt(cu.source)) continue; + if (is_scan_exempt(cu)) continue; if (!cu.scanOverridden && !verifyAll) continue; auto ddi = (cu.object.parent_path() / cu.source.filename()).string() + ".ddi"; std::string exp; @@ -1188,14 +1231,14 @@ std::string emit_ninja_string(const BuildPlan& plan) { // P2: module compile edges get a $bmi_out variable for BMI preservation. for (auto& cu : plan.compileUnits) { if (cu.servedFromCache) continue; // a stage_file edge owns these outputs - std::string rule = pick_rule(cu.source); + std::string rule = pick_rule(cu); std::string out_line = "build " + escape_ninja_path(cu.object); if (cu.providesModule) { out_line += " | " + bmi_path(*cu.providesModule); } out_line += std::format(" : {} {}", rule, escape_ninja_path(cu.source)); - if (!is_scan_exempt(cu.source)) { + if (!is_scan_exempt(cu)) { auto ddi = (cu.object.parent_path() / cu.source.filename()).string() + ".ddi"; auto it = ddi_to_dd.find(ddi); if (it != ddi_to_dd.end()) { @@ -1215,10 +1258,10 @@ std::string emit_ninja_string(const BuildPlan& plan) { } if (auto includes = local_include_flags(cu, dial); !includes.empty()) out_line += " local_includes =" + includes + "\n"; - if (is_gas_source(cu.source) || is_nasm_source(cu.source)) { + if (is_gas_source(cu) || is_nasm_source(cu)) { if (auto flags = join_flags(asm_unit_flags(cu)); !flags.empty()) out_line += " unit_asmflags =" + flags + "\n"; - } else if (is_c_source(cu.source)) { + } else if (is_c_source(cu)) { if (auto flags = join_flags(cu.packageCflags); !flags.empty()) out_line += " unit_cflags =" + flags + "\n"; } else { @@ -1232,11 +1275,11 @@ std::string emit_ninja_string(const BuildPlan& plan) { // ── Static-deps mode (M3.2 and earlier). ──────────────────────── for (auto& cu : plan.compileUnits) { if (cu.servedFromCache) continue; // a stage_file edge owns these outputs - std::string rule = pick_rule(cu.source); + std::string rule = pick_rule(cu); std::string implicit; // C/asm files don't `import` modules; skip BMI implicit inputs. - if (!is_scan_exempt(cu.source)) { + if (!is_scan_exempt(cu)) { for (auto& imp : cu.imports) { if (imp == "std") { if (has_std_artifacts) @@ -1267,10 +1310,10 @@ std::string emit_ninja_string(const BuildPlan& plan) { out_line += "\n"; if (auto includes = local_include_flags(cu, dial); !includes.empty()) out_line += " local_includes =" + includes + "\n"; - if (is_gas_source(cu.source) || is_nasm_source(cu.source)) { + if (is_gas_source(cu) || is_nasm_source(cu)) { if (auto flags = join_flags(asm_unit_flags(cu)); !flags.empty()) out_line += " unit_asmflags =" + flags + "\n"; - } else if (is_c_source(cu.source)) { + } else if (is_c_source(cu)) { if (auto flags = join_flags(cu.packageCflags); !flags.empty()) out_line += " unit_cflags =" + flags + "\n"; } else { diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 50873ae9..1f1f105f 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -9,6 +9,7 @@ import std; import mcpp.build.graph_shape; import mcpp.build.loader_contract; import mcpp.manifest; +import mcpp.source_kind; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.toolchain.cppfly; @@ -25,6 +26,16 @@ export namespace mcpp::build { struct CompileUnit { std::filesystem::path source; + // The unit's ROLE. Copied from the SourceUnit the scanner classified, and + // read by the object namer, the link-object collectors, the ninja rule + // picker, the CDB emitter and the assembly dialect check — none of which + // look at the extension any more. See mcpp.source_kind. + // + // Declared second so a hand-built unit reads `.source` then `.kind`. There + // is no safe default: a unit whose kind was never set would route to the + // generic C++ rule, which is a silent misroute rather than an error. Any + // producer other than make_plan (i.e. a test) must state it. + mcpp::SourceKind kind = mcpp::SourceKind::Other; std::filesystem::path object; // relative to plan.outputDir std::string packageName; std::vector localIncludeDirs; @@ -290,19 +301,23 @@ std::string sanitize_for_path(std::string_view module_name) { std::string object_filename_for(const std::filesystem::path& src, std::string_view objExt = ".o") { - auto ext = src.extension(); - // Assembly siblings of a C/C++ TU commonly share its stem (foo.c + - // foo.asm); keep the full extension in the object name so they can never - // collide — the per-package collision prefix can't help two same-stem - // files in the same directory. - if (ext == ".S" || ext == ".s" || ext == ".asm") { - return src.filename().string() + std::string(objExt); + // The naming POLICY lives in mcpp.source_kind (see ObjectNaming there for + // why every historical name is frozen and only new extensions get the + // collision-proof form). This function only formats it. + switch (mcpp::object_naming_for(src)) { + case mcpp::ObjectNaming::StemDotM: + return src.stem().string() + ".m" + std::string(objExt); + case mcpp::ObjectNaming::Stem: + return src.stem().string() + std::string(objExt); + case mcpp::ObjectNaming::FullFilename: + break; } - auto stem = src.stem().string(); - // distinguish .cppm vs .cpp by extension prefix to avoid collisions - return stem + (ext == ".cppm" - ? ".m" + std::string(objExt) - : std::string(objExt)); + // Assembly siblings of a C/C++ TU commonly share its stem (foo.c + + // foo.asm); keeping the full extension means they can never collide — + // the per-package collision prefix can't help two same-stem files in the + // same directory. Every extension a project adds via + // `[build] module_extensions` lands here for the same reason. + return src.filename().string() + std::string(objExt); } std::string qualified_package_name(const mcpp::manifest::Manifest& manifest) { @@ -385,10 +400,16 @@ std::vector runtime_aliases_for_target( return aliases; } -bool is_implementation_source(const std::filesystem::path& source) { - auto ext = source.extension(); - return ext == ".cpp" || ext == ".cc" || ext == ".cxx" || ext == ".c" || ext == ".m" - || ext == ".S" || ext == ".s" || ext == ".asm"; +// A unit whose object is linked because it CONTRIBUTES CODE, as opposed to a +// module interface (linked unconditionally, because its global initializers +// can matter even when no symbol of it is referenced). +// +// Kind-based rather than extension-based. One incidental fix comes with it: +// `.mm` (Objective-C++) was missing from the old list, so an Objective-C++ +// object was compiled and then never linked. +bool is_implementation_source(mcpp::SourceKind kind) { + return kind == mcpp::SourceKind::Cxx || kind == mcpp::SourceKind::C + || kind == mcpp::SourceKind::GasAsm || kind == mcpp::SourceKind::NasmAsm; } // How a CONSUMER links against a shared library. Also a target property: PE has @@ -770,6 +791,12 @@ make_plan(const mcpp::manifest::Manifest& manifest, plan.toolchain = tc; plan.fingerprint = fp; + // The ROOT package's extension table. Only the synthesized entry main + // needs it — every scanned unit arrives with its kind already set by the + // scanner, using its OWN package's table. + const auto rootExtTable = + mcpp::extension_table_for(manifest.buildConfig.moduleExtensions); + // Artifact naming and shared-library link shape are properties of the // TARGET. Resolved once here from tc.targetTriple (empty = host target, in // which case the host constants ARE the right answer) and threaded down, @@ -1154,6 +1181,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, CompileUnit cu; cu.source = u.path; cu.packageName = u.packageName; + cu.kind = u.kind; cu.localIncludeDirs = u.localIncludeDirs; cu.localIncludeDirsAfter = u.localIncludeDirsAfter; cu.packageCflags = u.packageCflags; @@ -1296,7 +1324,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, std::set depEntryMainSources; for (auto& cu : plan.compileUnits) { if (!devDepPackages.contains(cu.packageName)) continue; - if (!is_implementation_source(cu.source)) continue; + if (!is_implementation_source(cu.kind)) continue; if (source_defines_main(cu.source)) depEntryMainSources.insert(cu.source); } @@ -1360,13 +1388,13 @@ make_plan(const mcpp::manifest::Manifest& manifest, auto append_package_objects = [&](LinkUnit& lu, const std::string& packageName) { for (auto& cu : plan.compileUnits) { if (cu.packageName != packageName) continue; - if (cu.source.extension() == ".cppm") { + if (mcpp::links_unconditionally(cu.kind)) { lu.objects.push_back(cu.object); } } for (auto& cu : plan.compileUnits) { if (cu.packageName != packageName) continue; - if (!is_implementation_source(cu.source)) continue; + if (!is_implementation_source(cu.kind)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; if (entryFilesAcrossTargets.contains(cu.source)) continue; lu.objects.push_back(cu.object); @@ -1422,7 +1450,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // For binary target, also include main.cpp's object if main is present. for (auto& cu : plan.compileUnits) { if (sharedDepPackages.contains(cu.packageName)) continue; - if (cu.source.extension() == ".cppm") { + if (mcpp::links_unconditionally(cu.kind)) { lu.objects.push_back(cu.object); } } @@ -1443,6 +1471,10 @@ make_plan(const mcpp::manifest::Manifest& manifest, CompileUnit main_cu; main_cu.source = *lu.entryMain; main_cu.packageName = qualified_package_name(manifest); + // Synthesized outside the scanner, so it has no SourceUnit to copy + // from — classify it here with the ROOT package's table (an entry + // main is resolved against projectRoot by definition). + main_cu.kind = mcpp::classify(main_cu.source, rootExtTable); if (!packages.empty() && packages[0].usageResolved) { main_cu.localIncludeDirs = packages[0].privateBuild.includeDirs; main_cu.localIncludeDirsAfter = packages[0].privateBuild.includeDirsAfter; @@ -1538,7 +1570,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, // is exclusive to that binary). for (auto& cu : plan.compileUnits) { if (sharedDepPackages.contains(cu.packageName)) continue; - if (!is_implementation_source(cu.source)) continue; + if (!is_implementation_source(cu.kind)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; // own entry: already added above if (entryFilesAcrossTargets.contains(cu.source)) continue; // foreign entry: skip // A dependency's own main-providing object (e.g. gtest_main.o): link diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 7dcc191e..b8507a8d 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -16,6 +16,7 @@ import mcpp.platform.axis; import mcpp.libs.json; import mcpp.log; import mcpp.manifest; +import mcpp.source_kind; import mcpp.modgraph.glob; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; @@ -284,6 +285,18 @@ export std::string canonical_compile_flags(const mcpp::manifest::Manifest& m) { for (auto const& f : gf.asmflags) { s += " gas:"; s += f; } for (auto const& f : gf.defines) { s += " gd:"; s += f; } } + // [build] module_extensions changes WHICH FILES ARE MODULE INTERFACES, + // i.e. the shape of the graph: which units emit a BMI, which objects link + // unconditionally, which ninja rule each unit gets. That is a build + // variant, so it belongs in the fingerprint — mcpp.toml's mtime alone only + // protects the fast path within one output dir, not the BMI cache. + // + // Contrast [build] build_program_timeout, which is deliberately absent: + // it changes no edge. See BuildConfig::buildProgramTimeoutSecs. + for (auto const& e : m.buildConfig.moduleExtensions) { + s += " modext:"; + s += e; + } // The resolved [profile] knobs. These are NOT in cflags/cxxflags: the // profile block (see the profile resolution below) lands them in // buildConfig.optLevel/debug/lto/strip and flags.cppm turns them into @@ -389,6 +402,13 @@ std::string canonical_package_build_metadata( for (auto const& f : gf.asmflags) { s += " gas:"; s += f; } for (auto const& f : gf.defines) { s += " gd:"; s += f; } } + // Same reason as the root block, and it cannot be skipped on the + // grounds that "a descriptor is frozen per version": path and git + // dependencies are not frozen, and this key changes their products. + for (auto const& e : pkg.manifest.buildConfig.moduleExtensions) { + s += " modext:"; + s += e; + } if (pkg.usageResolved) { for (auto const& dir : pkg.privateBuild.includeDirs) { s += " private_include:"; @@ -3145,7 +3165,13 @@ prepare_build(bool print_fingerprint, if (o.find("${mcpp.") != std::string::npos) continue; // Companion outputs (protoc's .pb.h next to its .pb.cc) are // produced by the edge but are NOT translation units. - if (!mcpp::build::directives::is_compilable_output(o)) continue; + // The package that DECLARED the output classifies it: a + // dependency generating a `.ixx` is asking its own manifest, + // not the root project's. + if (!mcpp::build::directives::is_compilable_output( + o, mcpp::extension_table_for( + mm.buildConfig.moduleExtensions))) + continue; mm.buildConfig.sources.push_back(o); mm.modules.sources.push_back(o); } @@ -3369,8 +3395,11 @@ prepare_build(bool print_fingerprint, // back to the convention default if the manifest didn't set any. std::vector globs = depManifest.modules.sources; if (globs.empty()) { - globs = { "src/**/*.cppm", "src/**/*.cpp", - "src/**/*.cc", "src/**/*.c" }; + // Was a fourth hand-written copy of the convention default, and it + // had already drifted: all three assembly extensions were missing, + // so staging a dependency with .S/.s/.asm silently dropped them. + globs = mcpp::default_source_globs( + mcpp::extension_table_for(depManifest.buildConfig.moduleExtensions)); } // Glob exclusion (same as scan_one_into): `!` prefix removes. std::set sourceFiles; @@ -5461,9 +5490,8 @@ prepare_build(bool print_fingerprint, { bool hasGas = false, hasNasm = false; for (auto& cu : ctx.plan.compileUnits) { - auto ext = cu.source.extension(); - if (ext == ".S" || ext == ".s") hasGas = true; - else if (ext == ".asm") hasNasm = true; + if (cu.kind == mcpp::SourceKind::GasAsm) hasGas = true; + else if (cu.kind == mcpp::SourceKind::NasmAsm) hasNasm = true; } if (hasGas && mcpp::toolchain::dialect_for(*tc).id == "msvc") { return std::unexpected(std::string( diff --git a/src/build/program_protocol.cppm b/src/build/program_protocol.cppm new file mode 100644 index 00000000..7f6c6a98 --- /dev/null +++ b/src/build/program_protocol.cppm @@ -0,0 +1,133 @@ +// mcpp.build.program_protocol — the CONTRACT between mcpp and a `build.mcpp`. +// +// WHY THIS IS ITS OWN MODULE +// +// `mcpp.build.directives` answers "what is a directive" — one row per wire +// name, driving parse / serialize / apply / declared-output / private-fold. +// That is a big table and it grows every time a directive is added. +// +// The three things in this file are a different kind of fact: they are the +// terms both sides agree on BEFORE any directive is exchanged. +// +// * which wire version is spoken, +// * when previously written cache entries stop meaning what they said, +// * how long the program may run. +// +// They have separate consumers — `mcpp.build.hostprogram` stamps the protocol +// version into the bundled `mcpp` module and needs nothing else from the +// directive table; `mcpp.build.build_program` needs the run bound. Splitting +// them out keeps those two from importing the table, and keeps this file +// small enough that changing a protocol term is visibly a protocol change. +// +// Imports `std` and NOTHING else, deliberately: a protocol term that needed +// the manifest, the toolchain or the filesystem to be stated would not be a +// protocol term. +// +// See .agents/docs/2026-08-05-build-mcpp-extensibility-architecture.md §4 and +// .agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md §4. + +export module mcpp.build.program_protocol; + +import std; + +export namespace mcpp::build::program_protocol { + +// ── Protocol version ─────────────────────────────────────────────────────── +// +// The wire version this engine speaks. The bundled `mcpp` module announces the +// version it was built against (`mcpp:protocol=`) before main runs, so a +// program and the engine that compiled it always agree — the announcement only +// ever disagrees when a *cached* helper binary outlives an engine change, which +// is precisely the case worth catching. +// +// Bump when the meaning of an existing directive changes, or when a new +// directive is added that a program may rely on. An engine seeing a HIGHER +// number than this must refuse: it cannot know what it is being asked to do, +// and "warn and ignore" would turn that into a silently different build. +// v2 (#359): adds `rerun-if-changed-glob`. +inline constexpr int kProtocolVersion = 2; + +// ── Cache-format epoch ───────────────────────────────────────────────────── +// +// Bump ONLY when previously written build.mcpp.cache entries become unusable — +// the record shape changed, or a directive's *interpretation* changed so that +// replaying a cached value would no longer mean what it meant when written. +// Deliberately NOT the mcpp release number: folding the whole version in would +// re-run every build program on every release for nothing. Same discipline as +// mcpp.build.cache_key::kCacheEpoch. +// Epoch 2 (#359): entries gained `glob` records. An engine that does not know +// them would replay a strict subset of the declared inputs and call a stale +// build fresh, which is exactly the silent-wrong-answer this guard exists for. +inline constexpr int kCacheEpoch = 2; + +// ── Run bound ────────────────────────────────────────────────────────────── +// +// How long a build program may RUN before mcpp kills it. The compile is +// deliberately left unbounded — the same asymmetry `mcpp test` settled on +// (run 300s / build 0): a long compile is usually legitimate (a first-run std +// module build is minutes) and killing it produces a baffling failure, while a +// long-running build PROGRAM is usually stuck, and without a bound the whole +// build hangs with no diagnostic at all. +inline constexpr int kDefaultRunTimeoutSecs = 600; + +// The environment override, in seconds. `nullopt` means "not set / unusable" +// — a malformed or negative value is ignored rather than fatal, which is the +// behaviour this variable has always had. +// +// Split out from run_timeout() so the PRECEDENCE below is a pure function and +// can be tested without touching the process environment. +std::optional env_timeout_override(); + +// The effective bound. Precedence, highest first: +// +// 1. `envSecs` — MCPP_BUILD_PROGRAM_TIMEOUT, this invocation only +// 2. `manifestSecs` — `[build] build_program_timeout` in the manifest of +// the package that OWNS this build.mcpp (its author is +// the one who knows how long the generator takes; a +// consumer who needs to override reaches for the env +// var, which is global for exactly that reason) +// 3. kDefaultRunTimeoutSecs +// +// Zero at any level means "no bound" and is returned as a zero duration, which +// capture_exec_deadline treats as unbounded. `nullopt` and `0` are therefore +// NOT the same thing at either level, which is why both are optionals all the +// way down rather than an int with a sentinel. +// +// Mirrors the precedence `macos_deployment_target` documents (env > manifest > +// built-in default); a second shape for the same idea would be one more thing +// to remember. +std::chrono::milliseconds run_timeout(std::optional envSecs, + std::optional manifestSecs); + +// Convenience for callers that have a manifest but no reason to read the +// environment themselves. +std::chrono::milliseconds run_timeout_for(std::optional manifestSecs); + +} // namespace mcpp::build::program_protocol + +namespace mcpp::build::program_protocol { + +std::optional env_timeout_override() { + const char* v = std::getenv("MCPP_BUILD_PROGRAM_TIMEOUT"); + if (!v) return std::nullopt; + std::string_view sv(v); + int parsed = 0; + auto r = std::from_chars(sv.data(), sv.data() + sv.size(), parsed); + if (r.ec != std::errc{} || r.ptr != sv.data() + sv.size()) return std::nullopt; + if (parsed < 0) return std::nullopt; + return parsed; +} + +std::chrono::milliseconds run_timeout(std::optional envSecs, + std::optional manifestSecs) { + int secs = kDefaultRunTimeoutSecs; + if (manifestSecs && *manifestSecs >= 0) secs = *manifestSecs; + if (envSecs && *envSecs >= 0) secs = *envSecs; + return std::chrono::milliseconds(static_cast(secs) * 1000); +} + +std::chrono::milliseconds run_timeout_for(std::optional manifestSecs) { + return run_timeout(env_timeout_override(), manifestSecs); +} + +} // namespace mcpp::build::program_protocol diff --git a/src/doctor.cppm b/src/doctor.cppm index b6d5201a..89312b3d 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -10,6 +10,9 @@ module; export module mcpp.doctor; import std; +import mcpp.build.program_protocol; +import mcpp.source_kind; +import mcpp.manifest; import mcpp.bmi_cache.maintenance; import mcpp.build.prepare; import mcpp.build.plan; @@ -476,6 +479,57 @@ export int doctor_report() { } #endif + // ── Build-policy knobs that are otherwise invisible ──────────────────── + // + // Both of these change behaviour without changing anything a user can see + // in the output of a successful build, which is how "I set the key and + // nothing happened" becomes unanswerable. Report the EFFECTIVE value and, + // for the ones that have one, where it came from. + { + mcpp::ui::status("Checking", "build policy"); + + std::error_code pec; + auto manifestPath = std::filesystem::current_path(pec) / "mcpp.toml"; + std::optional m; + if (!pec && std::filesystem::exists(manifestPath, pec)) + if (auto loaded = mcpp::manifest::load(manifestPath)) m = std::move(*loaded); + + // Module-interface extensions: built-ins plus this project's additions. + { + auto table = mcpp::extension_table_for( + m ? m->buildConfig.moduleExtensions : std::vector{}); + std::string list; + for (auto const& e : table.moduleInterface) { + if (!list.empty()) list += ' '; + list += e; + } + const auto extra = table.moduleInterface.size() - 1; // built-in is .cppm + ok(std::format("module interfaces: {}{}", list, + extra ? std::format(" ({} from [build] module_extensions)", extra) + : " (built-in only)")); + } + + // Run bound for build.mcpp, with its source named. + { + namespace pp = mcpp::build::program_protocol; + auto envSecs = pp::env_timeout_override(); + auto manSecs = m ? m->buildConfig.buildProgramTimeoutSecs + : std::optional{}; + auto effective = pp::run_timeout(envSecs, manSecs).count() / 1000; + std::string_view from = envSecs ? "MCPP_BUILD_PROGRAM_TIMEOUT" + : manSecs ? "[build] build_program_timeout" + : "built-in default"; + ok(std::format("build.mcpp run bound: {} (from {})", + effective ? std::format("{}s", effective) + : std::string("none — 0 disables it"), + from)); + } + + // Whether a deadline is actually enforced here. This used to be "no" + // on Windows while every knob claimed otherwise. + ok("process deadlines: enforced (POSIX SIGKILL / Windows job object)"); + } + std::println(""); if (errors) std::println("Doctor result: {} errors, {} warnings", errors, warns); else if (warns) std::println("Doctor result: {} warnings", warns); diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index f424a193..129269d9 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -4,6 +4,7 @@ export module mcpp.manifest.toml; import mcpp.manifest.types; import std; +import mcpp.source_kind; import mcpp.libs.toml; import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; @@ -1006,6 +1007,37 @@ std::expected parse_string(std::string_view content, return std::unexpected(error(origin, *err)); } } + // [build] module_extensions — extra module-interface extensions. Parsed + // BEFORE apply_defaults_and_infer runs, because the convention default for + // `sources` is derived from it. + if (auto v = doc->get_string_array("build.module_extensions")) { + if (auto err = mcpp::validate_module_extensions(*v)) + return std::unexpected(error(origin, *err)); + m.buildConfig.moduleExtensions = *v; + } + // [build] build_program_timeout — seconds a build.mcpp may run; 0 = no + // limit. `optional` is load-bearing: with a plain int, "absent" and + // "explicitly 0" would be the same value, and every project that never + // mentions the key would silently lose its run bound. + if (auto* tv = doc->get("build.build_program_timeout")) { + if (!tv->is_int()) { + return std::unexpected(error(origin, + "[build].build_program_timeout must be an integer number of " + "seconds (0 = no limit)")); + } + auto secs = tv->as_int(); + if (secs < 0) { + return std::unexpected(error(origin, std::format( + "[build].build_program_timeout is {} seconds; it cannot be " + "negative (0 = no limit)", secs))); + } + if (secs > std::numeric_limits::max()) { + return std::unexpected(error(origin, std::format( + "[build].build_program_timeout is {} seconds, which does not " + "fit in the deadline; use 0 for no limit", secs))); + } + m.buildConfig.buildProgramTimeoutSecs = static_cast(secs); + } if (auto v = doc->get_string("build.c_standard")) m.buildConfig.cStandard = *v; if (auto v = doc->get_string("build.target")) m.buildConfig.target = *v; if (auto v = doc->get_string("build.default-profile")) m.buildConfig.defaultProfile = *v; @@ -1037,11 +1069,11 @@ std::expected parse_string(std::string_view content, // // MUST stay in sync with the `doc->get_*("build.")` reads above. static constexpr std::string_view kKnownBuildKeys[] = { - "allow_host_libs", "c_standard", "cache", "cflags", "cxxflags", - "default-profile", "defines", "dialect_cxxflags", "flags", - "include_dirs", "include_dirs_after", "ldflags", - "macos_deployment_target", "profile", "sources", "static_stdlib", - "target", + "allow_host_libs", "build_program_timeout", "c_standard", "cache", + "cflags", "cxxflags", "default-profile", "defines", "dialect_cxxflags", + "flags", "include_dirs", "include_dirs_after", "ldflags", + "macos_deployment_target", "module_extensions", "profile", "sources", + "static_stdlib", "target", }; if (auto* bt = doc->get_table("build")) { for (auto& [key, _] : *bt) { @@ -1050,10 +1082,11 @@ std::expected parse_string(std::string_view content, if (!known) { m.schemaWarnings.push_back(std::format( "[build] has unsupported key '{}' (ignored). Supported keys: " - "sources, cflags, cxxflags, ldflags, defines, flags, " - "include_dirs, include_dirs_after, dialect_cxxflags, " - "c_standard, target, static_stdlib, allow_host_libs, cache, " - "profile, macos_deployment_target.", key)); + "sources, module_extensions, cflags, cxxflags, ldflags, " + "defines, flags, include_dirs, include_dirs_after, " + "dialect_cxxflags, c_standard, target, static_stdlib, " + "allow_host_libs, cache, profile, build_program_timeout, " + "macos_deployment_target.", key)); } } } @@ -1605,18 +1638,16 @@ void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { // Default sources glob (covers .cppm/.cpp/.cc/.c plus assembly under // src/). Assembly in the tree almost certainly wants building; a project // that vendors foreign-syntax .asm can `!`-exclude it. + const auto extTable = + mcpp::extension_table_for(m.buildConfig.moduleExtensions); if (m.buildConfig.sources.empty()) { - m.buildConfig.sources = { - "src/**/*.cppm", - "src/**/*.cpp", - "src/**/*.cc", - "src/**/*.c", - "src/**/*.S", - "src/**/*.s", - "src/**/*.asm", - }; + // Derived from the extension table rather than written beside it — + // otherwise declaring `module_extensions = [".ixx"]` would change how + // `.ixx` is TREATED without changing whether it is FOUND, and the key + // would appear to do nothing. + m.buildConfig.sources = mcpp::default_source_globs(extTable); m.modules.sources = m.buildConfig.sources; // legacy mirror - m.inferredNotes.push_back("sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}]"); + m.inferredNotes.push_back(mcpp::default_source_globs_note(extTable)); } // Default include_dirs: ["include"] iff /include/ exists. @@ -1634,13 +1665,16 @@ void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { auto mainCpp = root / "src" / "main.cpp"; bool hasMain = std::filesystem::exists(mainCpp, ec); - bool hasCppm = false; + // "Is there a module interface under src/" — asked through the table, + // so a library whose interfaces are all `.ixx` still infers a lib + // target instead of silently having none. + bool hasModuleInterface = false; if (std::filesystem::is_directory(root / "src", ec)) { for (auto& e : std::filesystem::recursive_directory_iterator(root / "src", ec)) { if (ec) break; if (e.is_regular_file(ec) && !ec - && e.path().extension() == ".cppm") { - hasCppm = true; break; + && mcpp::produces_bmi(mcpp::classify(e.path(), extTable))) { + hasModuleInterface = true; break; } } } @@ -1653,13 +1687,13 @@ void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { m.targets.push_back(std::move(t)); m.inferredNotes.push_back( std::format("target {} (bin from src/main.cpp)", m.package.name)); - } else if (hasCppm) { + } else if (hasModuleInterface) { Target t; t.name = m.package.name; t.kind = Target::Library; m.targets.push_back(std::move(t)); m.inferredNotes.push_back( - std::format("target {} (lib from .cppm in src/)", m.package.name)); + std::format("target {} (lib from module interface in src/)", m.package.name)); } // If neither, no auto-target — caller will error if it needs one. } diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 9a73c0a4..2f7811a0 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -377,6 +377,29 @@ struct BuildConfig : BuildInputs { // on feature-off builds. Private per-TU flags — never propagate (contrast // featureDefines above, which are interface switches). std::map> featureFlags; + // [build] module_extensions — extra file extensions this package's module + // INTERFACES use, on top of the built-in `.cppm`. Additive and opt-in: + // `.ccm` / `.cxxm` / `.ixx` are NOT built in, because widening the + // built-in set also widens the default source glob, which would make a + // published package with a vendored MSVC-only `.ixx` start compiling it on + // the next mcpp upgrade — a break its author cannot fix. + // + // Consumed through mcpp.source_kind (never read raw): the table it builds + // decides the graph shape, so this vector is part of the fingerprint. + // Scoped to the declaring package — a dependency is classified by its own + // manifest, never by its consumer's. + std::vector moduleExtensions; + // [build] build_program_timeout — seconds this package's build.mcpp may + // run before mcpp kills it. 0 = no limit; nullopt = use the built-in 600. + // + // `optional` is load-bearing, not style. With a plain `int` the default + // value would have to be 0, which MEANS "no limit" — so every project that + // never mentions the key would silently lose its run bound. + // + // Deliberately NOT part of the fingerprint: it changes no edge in the + // graph, and folding it in would make raising a timeout rebuild the whole + // project — the opposite of what someone raising a timeout wants. + std::optional buildProgramTimeoutSecs; std::map generatedFiles; // Form B package-owned support files // Build-graph nodes declared by this package's build program // (`mcpp:action=`). Empty for every package that does not use one, so an diff --git a/src/modgraph/graph.cppm b/src/modgraph/graph.cppm index 05312bf6..222fa585 100644 --- a/src/modgraph/graph.cppm +++ b/src/modgraph/graph.cppm @@ -3,6 +3,7 @@ export module mcpp.modgraph.graph; import std; +import mcpp.source_kind; export namespace mcpp::modgraph { @@ -34,8 +35,20 @@ struct SourceUnit { std::vector packageAsmflags; // per-glob asmflags (G4) std::optional provides; std::vector requires_; - bool isModuleInterface = false; // .cppm with export module - bool isImplementation = false; // .cpp without export + // The unit's ROLE, decided once by the scanner from the owning package's + // extension table and carried from here on. Every downstream consumer + // (planner, backend, compile_commands, the asm dialect check) reads this + // instead of re-deriving it from the extension — see mcpp.source_kind for + // why that mattered. + mcpp::SourceKind kind = mcpp::SourceKind::Other; + // + // `isModuleInterface` / `isImplementation` used to live here. They were + // WRITE-ONLY: three sites derived them (the scanner said `.cpp`, the p1689 + // reader said `.cpp || .cxx`, the scan_overrides branch said "has + // provides"), the three disagreed, and nothing in src/ or tests/ ever read + // the result. Converging three derivations of a value nobody reads is + // still three derivations, so they are gone instead. `provides` answers + // "is this an interface"; `kind` answers the rest. // Unit built from a manifest scan_overrides declaration instead of a // real scan — plan-vs-ddi verification is mandatory for these. bool scanOverridden = false; diff --git a/src/modgraph/p1689.cppm b/src/modgraph/p1689.cppm index b3c01d01..f1e891f5 100644 --- a/src/modgraph/p1689.cppm +++ b/src/modgraph/p1689.cppm @@ -23,7 +23,9 @@ export module mcpp.modgraph.p1689; import std; import mcpp.modgraph.graph; import mcpp.platform; +import mcpp.source_kind; import mcpp.toolchain.detect; +import mcpp.toolchain.model; export namespace mcpp::modgraph::p1689 { @@ -50,7 +52,8 @@ scan_file(const std::filesystem::path& source, const mcpp::toolchain::Toolchain& tc, const std::filesystem::path& tmpDir, const std::vector& includeDirs, - std::string_view cppStandardFlag); + std::string_view cppStandardFlag, + const mcpp::ExtensionTable& extTable); } // namespace mcpp::modgraph::p1689 @@ -321,7 +324,8 @@ scan_file(const std::filesystem::path& source, const mcpp::toolchain::Toolchain& tc, const std::filesystem::path& tmpDir, const std::vector& includeDirs, - std::string_view cppStandardFlag) + std::string_view cppStandardFlag, + const mcpp::ExtensionTable& extTable) { std::error_code ec; std::filesystem::create_directories(tmpDir, ec); @@ -347,8 +351,16 @@ scan_file(const std::filesystem::path& source, include_flags += " -I"; include_flags += shell_escape(dir); } + // Same suffix-recognition problem the ninja scan edge has: a driver that + // does not know this source's extension hands it to the linker and exits 0 + // with no .ddi. Told explicitly for module interfaces, and only for them — + // an implementation unit must not be scanned as an interface. + std::string lang_flag; + if (mcpp::produces_bmi(mcpp::classify(source, extTable))) + lang_flag = std::string(mcpp::toolchain::bmi_traits(tc).moduleInterfaceLangFlag); + std::string cmd = std::format( - "{} {} -fmodules{}{}" + "{} {} -fmodules{}{}{}" " -fdeps-format=p1689r5" " -fdeps-file={}" " -fdeps-target={}" @@ -358,6 +370,7 @@ scan_file(const std::filesystem::path& source, std_flag, sysroot_flag, include_flags, + lang_flag, shell_escape(ddi), shell_escape(obj), shell_escape(dep), @@ -382,12 +395,9 @@ scan_file(const std::filesystem::path& source, SourceUnit u; u.path = source; u.packageName = packageName; + u.kind = mcpp::classify(source, extTable); if (!rule->provides.empty()) { - u.provides = ModuleId{ rule->provides.front().logicalName }; - u.isModuleInterface = rule->provides.front().isInterface - || source.extension() == ".cppm"; - } else if (source.extension() == ".cpp" || source.extension() == ".cxx") { - u.isImplementation = true; + u.provides = ModuleId{ rule->provides.front().logicalName }; } for (auto& r : rule->requires_) { u.requires_.push_back(ModuleId{ r }); diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 47e94dcf..aae39cd2 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -14,6 +14,7 @@ import mcpp.manifest; import mcpp.modgraph.glob; import mcpp.modgraph.graph; import mcpp.modgraph.p1689; +import mcpp.source_kind; import mcpp.toolchain.detect; export namespace mcpp::modgraph { @@ -56,9 +57,11 @@ std::filesystem::path glob_literal_prefix(std::string_view glob); // alternation transparently. std::vector expand_braces(std::string_view glob); -// Scan a single source file. +// Scan a single source file. `extTable` is the OWNING PACKAGE's table — a +// dependency is classified by its own manifest, never by the consumer's. std::expected scan_file(const std::filesystem::path& file, - const std::string& packageName); + const std::string& packageName, + const mcpp::ExtensionTable& extTable); // Scan the entire package: collects all sources via manifest globs and returns a Graph. struct ScanResult { @@ -555,7 +558,8 @@ void normalize_include_flags(const std::filesystem::path& root, } std::expected scan_file(const std::filesystem::path& file, - const std::string& packageName) + const std::string& packageName, + const mcpp::ExtensionTable& extTable) { std::ifstream is(file); if (!is) return std::unexpected(ScanError{file, 0, "cannot open"}); @@ -563,6 +567,9 @@ std::expected scan_file(const std::filesystem::path& file SourceUnit u; u.path = file; u.packageName = packageName; + // The ONE place a source's role is decided. Everything downstream reads + // `u.kind`; nothing re-derives it from the extension. + u.kind = mcpp::classify(file, extTable); // C-like files are not C++ modules: they cannot legally contain `module` / `import` // declarations, and we route them to the C-language compile rule (no @@ -570,9 +577,7 @@ std::expected scan_file(const std::filesystem::path& file // avoid any chance of a benign identifier (`import_foo`, `module_t`, ...) // being misparsed. Objective-C .m files use the same C-like path, and so // does assembly (.S/.s via the C driver, .asm via NASM). - auto sext = file.extension(); - if (sext == ".c" || sext == ".m" - || sext == ".S" || sext == ".s" || sext == ".asm") { + if (mcpp::is_scan_exempt(u.kind)) { return u; } @@ -634,7 +639,6 @@ std::expected scan_file(const std::filesystem::path& file u.provides->logicalName, name)}); } u.provides = ModuleId{name}; - u.isModuleInterface = true; } else { // implementation unit (`module foo;`) — non-exporting. // Don't claim ownership of `foo` (partition would be foo:part); @@ -686,11 +690,6 @@ std::expected scan_file(const std::filesystem::path& file } } - // Classify implementation .cpp (no provides + not a partition) - if (!u.provides && file.extension() == ".cpp") { - u.isImplementation = true; - } - return u; } @@ -752,6 +751,12 @@ void scan_one_into(ScanResult& result, const std::vector& packageCflags, const std::vector& packageCxxflags) { + // This package's own extension table. Built once per package, not per + // file, and taken from THIS manifest — a dependency is classified by its + // own `[build] module_extensions`, never by the consumer's. + const auto extTable = + mcpp::extension_table_for(manifest.buildConfig.moduleExtensions); + // Glob exclusion: patterns starting with `!` remove files from the // include set (like .gitignore). // sources = ["src/**/*.cpp", "!src/**/*_test.cpp"] @@ -853,9 +858,11 @@ void scan_one_into(ScanResult& result, u.relPath = std::filesystem::relative(f, root); u.packageName = qualifiedName; u.scanOverridden = true; + // A declared unit still gets its role from the same classifier — + // scan_overrides overrides what was SCANNED, not what the file is. + u.kind = mcpp::classify(f, extTable); if (!ov->provides.empty()) { u.provides = ModuleId{ov->provides.front()}; - u.isModuleInterface = true; if (ov->provides.size() > 1) { result.errors.push_back(ScanError{f, 0, "scan_overrides: a unit may declare at most one " @@ -876,7 +883,7 @@ void scan_one_into(ScanResult& result, result.graph.units.push_back(std::move(u)); continue; } - auto r = scan_file(f, qualifiedName); + auto r = scan_file(f, qualifiedName, extTable); if (!r) { result.errors.push_back(r.error()); continue; @@ -902,6 +909,27 @@ void scan_one_into(ScanResult& result, "(typo, or the glob is not covered by `sources`)", glob)}); } } + + // A `module_extensions` entry that matches nothing is dead config, and it + // is invisible otherwise: the build succeeds, the extension does nothing, + // and the author has no way to tell a typo (".ixxx") from "this project + // simply has none yet". A WARNING rather than an error — declaring an + // extension before the first file that uses it is legitimate, and a + // package whose `.ixx` sources are all behind an inactive feature would + // otherwise fail to build. + for (auto const& raw : manifest.buildConfig.moduleExtensions) { + auto ext = mcpp::normalize_extension(raw); + if (ext.empty()) continue; + bool seen = false; + for (auto const& f : all_files) + if (f.extension().string() == ext) { seen = true; break; } + if (!seen) { + result.warnings.push_back(ScanError{root, 0, std::format( + "[build] module_extensions declares '{}' but no source file " + "under `sources` has that extension (dead entry, or a typo)", + ext)}); + } + } for (std::size_t i = 0; i < globFlagHits.size(); ++i) { if (globFlagHits[i] == 0) { // Zero scanned-source hits is not yet a dead glob: the entry may @@ -1005,6 +1033,9 @@ ScanResult scan_packages_p1689(const std::vector& packages, { ScanResult result; for (auto const& p : packages) { + // Same contract as scan_one_into: each package's own table. + const auto extTable = + mcpp::extension_table_for(p.manifest.buildConfig.moduleExtensions); std::set all_files; for (auto const& g : p.manifest.modules.sources) { for (auto& f : expand_glob(p.root, g)) all_files.insert(f); @@ -1018,7 +1049,7 @@ ScanResult scan_packages_p1689(const std::vector& packages, for (auto const& f : all_files) { auto r = mcpp::modgraph::p1689::scan_file( f, p.manifest.package.name, tc, tmpDir, - localIncludeDirs, cppStandardFlag); + localIncludeDirs, cppStandardFlag, extTable); if (!r) { result.errors.push_back(ScanError{ f, 0, r.error() }); continue; diff --git a/src/platform/linux.cppm b/src/platform/linux/linux.cppm similarity index 100% rename from src/platform/linux.cppm rename to src/platform/linux/linux.cppm diff --git a/src/platform/macos.cppm b/src/platform/macos/macos.cppm similarity index 100% rename from src/platform/macos.cppm rename to src/platform/macos/macos.cppm diff --git a/src/platform/platform.cppm b/src/platform/platform.cppm index 9dc40c24..1d9f32d3 100644 --- a/src/platform/platform.cppm +++ b/src/platform/platform.cppm @@ -1,14 +1,53 @@ // mcpp.platform — unified platform abstraction facade. // -// Import this single module to get access to all platform capabilities. -// Re-exports every sub-module so consumers can write: +// Import this single module to get access to all platform capabilities: // // import mcpp.platform; // // then use mcpp::platform::fs::self_exe_path(), etc. // -// Platform-specific modules (macos, linux, windows) are always compiled -// on all platforms but their functions are no-ops on non-matching -// platforms, so consumers can call them without #ifdef guards. +// ─── LAYOUT ──────────────────────────────────────────────────────────────── +// +// src/platform/*.cppm the FACADE and the portable modules. A module +// here answers a question the same way everywhere, +// or dispatches once (see "dispatch" below). +// src/platform/unix/ POSIX-only implementations. +// src/platform/windows/ Win32-only implementations. +// src/platform/linux/ Linux-only. +// src/platform/macos/ macOS-only. +// +// A directory does not change a module's NAME — `src/platform/linux/linux.cppm` +// still declares `mcpp.platform.linux`, so moving a file here costs no importer +// a single line. The directory is what tells a reader, before opening anything, +// whether a file can contain platform-specific code at all. +// +// ─── HOW PLATFORM DIFFERENCES ARE EXPRESSED ──────────────────────────────── +// +// Preferred, in order: +// +// 1. `if constexpr (mcpp::platform::is_windows)` in the facade layer, calling +// into one implementation module per platform. Both branches then have to +// COMPILE everywhere, which is what keeps the unused branch from rotting — +// the Windows deadline was dead code for months precisely because nothing +// on a Linux CI ever compiled it. +// +// `mcpp.platform.process`'s bounded-run dispatch is the reference example: +// two implementation modules behind one signature, one dispatch, and the +// only `#if` left is inside each implementation, guarding its own headers. +// +// 2. A per-platform module whose non-matching build is a no-op stub +// (`mcpp.platform.macos`, `.linux`, `.windows`). Consumers call them +// unconditionally, with no `#ifdef` at the call site. +// +// 3. `#if` inside ONE module, when the difference is a header or a syscall +// rather than a behaviour. Still the common case for the older portable +// modules (`fs`, `env`, `scaffold_fs`); migrating those is incremental +// work, not a precondition for adding new code the right way. +// +// ⚠️ A module under a platform directory MUST NOT name a `std` type in its +// EXPORTED interface if a widely-imported module will import it: under GCC +// 16.1 that corrupts every BMI downstream, and the error points at an +// unrelated module. Both bounded_process modules document the measurements. +// Builtin types plus a callback is the shape that works. export module mcpp.platform; @@ -21,3 +60,7 @@ export import mcpp.platform.macos; export import mcpp.platform.linux; export import mcpp.platform.windows; export import mcpp.platform.terminal; + +// The bounded-run implementations are deliberately NOT re-exported: they are +// an implementation detail of `mcpp.platform.process`'s deadline variants, and +// nothing outside it should reach past the dispatch to a specific platform. diff --git a/src/platform/process.cppm b/src/platform/process.cppm index 7e220772..c31ecd4c 100644 --- a/src/platform/process.cppm +++ b/src/platform/process.cppm @@ -34,11 +34,9 @@ module; #include // pipe, dup2, close, read #include // waitpid #include // posix_spawnp, posix_spawn_file_actions_* (incl. addchdir_np) -#include // kill, SIGKILL (deadline runners) -#include // errno, EINTR (deadline wait loop) -#include // poll (deadline capture) -#include // fcntl O_NONBLOCK (deadline capture) -#include // nanosleep (deadline wait loop) +// The deadline runners' headers (signal.h, errno, poll.h, fcntl.h, time.h) +// left with them: bounded runs now live in mcpp.platform.unix.bounded_process +// and mcpp.platform.windows.bounded_process, and this file only dispatches. #if defined(__APPLE__) #include // _NSGetEnviron — direct `environ` is only linkable // from executables on Apple, not from dylibs @@ -50,8 +48,11 @@ extern "C" char **environ; export module mcpp.platform.process; import std; +import mcpp.platform.common; // is_windows, for the dispatch import mcpp.platform.env; import mcpp.platform.shell; +import mcpp.platform.unix.bounded_process; +import mcpp.platform.windows.bounded_process; export namespace mcpp::platform::process { @@ -93,10 +94,19 @@ RunResult capture_exec( const std::vector>& extraEnv = {}, std::string_view cwd = {}); -// Deadline variants (POSIX): kill the child with SIGKILL once `deadline` -// elapses and set *timed_out. A zero deadline means no limit. On Windows the -// deadline is currently ignored (no supported kill-by-handle path in the -// residual shell launcher) — callers must treat the timeout as best-effort. +// Deadline variants: kill the child once `deadline` elapses and set +// *timed_out. A zero deadline means no limit. +// +// The implementations live per platform — mcpp.platform.unix.bounded_process +// (posix_spawn + SIGKILL) and mcpp.platform.windows.bounded_process +// (CreateProcess + a Job object, so the kill takes the whole tree rather than +// just the direct child). This file dispatches between them ONCE, in +// dispatch_bounded below. +// +// BOTH are real bounds now. They were not: the Windows side used to fall +// through to the unbounded launcher, so every timeout knob (`mcpp test +// --timeout`, `--build-timeout`, `[build] build_program_timeout`) was a silent +// no-op there — set, reported nowhere, and doing nothing. int run_exec_deadline(const std::vector& argv, const std::vector>& extraEnv, std::chrono::milliseconds deadline, @@ -552,6 +562,74 @@ RunResult capture_exec( #endif } +// ─── The ONE place the platform question is asked for a bounded run ──────── +// +// Both launchers answer the same contract behind a `std`-free interface (see +// either module for the BMI corruption that forced that), so everything +// platform-specific about a bounded child is this dispatch plus the two +// implementations — instead of the branches that used to be spread through +// both functions below, with the Windows half of them a silent no-op. +// +// The two calls differ in ONE way, and it is a real difference rather than an +// abstraction leak: POSIX names a program with an argv array, Windows with a +// single quoted command line. Flattening that would move the quoting rules +// somewhere they could not be unit-tested — they are tested right here, via +// windows_command_from_argv. +struct BoundedOutcome { + bool supported = false; + int exit_code = 0; + bool timed_out = false; + std::string output; +}; + +BoundedOutcome dispatch_bounded( + const std::vector& argv, + const std::vector>& extraEnv, + std::string_view cwd, + std::chrono::milliseconds deadline) +{ + BoundedOutcome outcome; + + std::vector envStore; + envStore.reserve(extraEnv.size()); + for (auto const& [k, v] : extraEnv) envStore.push_back(k + "=" + v); + std::vector envPtrs; + envPtrs.reserve(envStore.size()); + for (auto const& e : envStore) envPtrs.push_back(e.c_str()); + const char* const* envArg = envPtrs.empty() ? nullptr : envPtrs.data(); + const auto envCount = static_cast(envPtrs.size()); + + std::string cwdStore(cwd); + const char* cwdArg = cwdStore.empty() ? nullptr : cwdStore.c_str(); + const auto ms = static_cast(deadline.count()); + + // One sink for both, appending into the outcome's own buffer. + const auto sink = +[](void* ctx, const char* data, unsigned long len) { + static_cast(ctx)->append(data, len); + }; + + if constexpr (mcpp::platform::is_windows) { + const auto cmd = windows_command_from_argv(argv); + auto r = mcpp::platform::winproc::capture_with_deadline( + cmd.c_str(), envArg, envCount, cwdArg, ms, sink, &outcome.output); + outcome.supported = r.supported; + outcome.exit_code = r.exit_code; + outcome.timed_out = r.timed_out; + } else { + std::vector argvPtrs; + argvPtrs.reserve(argv.size()); + for (auto const& a : argv) argvPtrs.push_back(a.c_str()); + auto r = mcpp::platform::unixproc::capture_with_deadline( + argvPtrs.data(), static_cast(argvPtrs.size()), + envArg, envCount, cwdArg, ms, sink, &outcome.output); + outcome.supported = r.supported; + outcome.exit_code = r.exit_code; + outcome.timed_out = r.timed_out; + } + return outcome; +} + + int run_exec_deadline(const std::vector& argv, const std::vector>& extraEnv, std::chrono::milliseconds deadline, @@ -560,39 +638,15 @@ int run_exec_deadline(const std::vector& argv, if (timed_out) *timed_out = false; if (deadline.count() <= 0) return run_exec(argv, extraEnv); if (argv.empty()) return 127; -#if defined(__linux__) || defined(__APPLE__) - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - pid_t pid = 0; - if (::posix_spawnp(&pid, cargv[0], nullptr, nullptr, cargv.data(), envp.data()) != 0) - return 127; - auto until = std::chrono::steady_clock::now() + deadline; - int status = 0; - for (;;) { - pid_t r = ::waitpid(pid, &status, WNOHANG); - if (r == pid) return normalize_exit_code(status); - if (r < 0 && errno != EINTR) return 127; - if (std::chrono::steady_clock::now() >= until) { - ::kill(pid, SIGKILL); - while (::waitpid(pid, &status, 0) < 0) { /* EINTR retry */ } - if (timed_out) *timed_out = true; - return normalize_exit_code(status); - } - struct timespec ts{0, 20'000'000}; // 20ms - ::nanosleep(&ts, nullptr); - } -#else - // Windows: the residual shell launcher has no kill-by-handle path yet — - // run untimed (documented best-effort semantics). - return run_exec(argv, extraEnv); -#endif + auto r = dispatch_bounded(argv, extraEnv, {}, deadline); + if (!r.supported) return run_exec(argv, extraEnv); + // `run_exec` streams to the terminal; the bounded launchers capture. The + // output is replayed here rather than dropped — a bounded run that must + // ALSO stream live has no implementation and, today, no caller. + if (!r.output.empty()) std::fputs(r.output.c_str(), stdout); + if (timed_out) *timed_out = r.timed_out; + return r.exit_code; } RunResult capture_exec_deadline( @@ -606,75 +660,17 @@ RunResult capture_exec_deadline( if (deadline.count() <= 0) return capture_exec(argv, extraEnv, cwd); RunResult result; if (argv.empty()) { result.exit_code = 127; return result; } -#if defined(__linux__) || defined(__APPLE__) - int fds[2]; - if (::pipe(fds) != 0) { result.exit_code = 127; return result; } - auto envStore = merged_environ(extraEnv); - std::vector envp; - for (auto& s : envStore) envp.push_back(s.data()); - envp.push_back(nullptr); - std::vector cargv; - for (auto& a : argv) cargv.push_back(const_cast(a.c_str())); - cargv.push_back(nullptr); - - posix_spawn_file_actions_t fa; - ::posix_spawn_file_actions_init(&fa); - // Same cwd contract as capture_exec: a timed child must land in the same - // directory an untimed one would, or adding a timeout would silently - // change where a build program's relative writes go. - std::string cwdStore(cwd); - if (!cwdStore.empty()) - ::posix_spawn_file_actions_addchdir_np(&fa, cwdStore.c_str()); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); - ::posix_spawn_file_actions_addclose(&fa, fds[0]); - ::posix_spawn_file_actions_addclose(&fa, fds[1]); - - pid_t pid = 0; - int sp = ::posix_spawnp(&pid, cargv[0], &fa, nullptr, cargv.data(), envp.data()); - ::posix_spawn_file_actions_destroy(&fa); - ::close(fds[1]); - if (sp != 0) { - ::close(fds[0]); - result.exit_code = 127; - result.output = spawn_failure(argv.front(), sp); - return result; - } - - ::fcntl(fds[0], F_SETFL, ::fcntl(fds[0], F_GETFL) | O_NONBLOCK); - - auto until = std::chrono::steady_clock::now() + deadline; - bool killed = false; - std::array buf{}; - for (;;) { - struct pollfd pfd{fds[0], POLLIN, 0}; - ::poll(&pfd, 1, 50); - for (;;) { - ssize_t n = ::read(fds[0], buf.data(), buf.size()); - if (n > 0) { result.output.append(buf.data(), static_cast(n)); continue; } - break; - } - int status = 0; - pid_t r = ::waitpid(pid, &status, WNOHANG); - if (r == pid) { - // Drain whatever is left in the pipe after exit. - ssize_t n; - while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) - result.output.append(buf.data(), static_cast(n)); - ::close(fds[0]); - result.exit_code = normalize_exit_code(status); - if (timed_out) *timed_out = killed; - return result; - } - if (!killed && std::chrono::steady_clock::now() >= until) { - ::kill(pid, SIGKILL); - killed = true; - } - } -#else - return capture_exec(argv, extraEnv, cwd); -#endif + auto r = dispatch_bounded(argv, extraEnv, cwd, deadline); + // `supported == false` means the child COULD NOT BE SPAWNED — not that it + // ran and failed. Reporting those the same way would hide a launcher + // problem behind a child's exit code, so fall back to the untimed path and + // let it produce the real diagnostic. + if (!r.supported) return capture_exec(argv, extraEnv, cwd); + result.exit_code = r.exit_code; + result.output = std::move(r.output); + if (timed_out) *timed_out = r.timed_out; + return result; } } // namespace mcpp::platform::process diff --git a/src/platform/unix/bounded_process.cppm b/src/platform/unix/bounded_process.cppm new file mode 100644 index 00000000..6a1d066c --- /dev/null +++ b/src/platform/unix/bounded_process.cppm @@ -0,0 +1,231 @@ +// mcpp.platform.unix.bounded_process — a child process with a deadline on +// POSIX. The peer of mcpp.platform.windows.bounded_process. +// +// WHY THE TWO SIDES ARE SEPARATE MODULES WITH ONE SHARED SIGNATURE +// +// They share no code — this is posix_spawn + a pipe + waitpid + SIGKILL, the +// other is CreateProcess + a Job object + WaitForSingleObject. What they share +// is a CONTRACT: "run this, capture its output, kill it at the deadline, and +// tell me whether you killed it". Putting that contract in one signature and +// each implementation behind its own module is what lets +// `mcpp.platform.process` dispatch once, with `if constexpr`, instead of +// carrying the platform question through twenty-five separate `#if` blocks. +// +// ⚠️ THE INTERFACE NAMES NO `std` TYPE — SAME HARD CONSTRAINT AS THE WINDOWS +// SIDE. See mcpp.platform.windows.bounded_process for the measurements: a new +// module imported by mcpp.platform.process whose EXPORTS mention std types +// corrupts every BMI downstream of it under GCC 16.1. `std` inside the module +// is fine; `std` in what it exports is not. +// +// The POSIX side does not strictly need the constraint (it is reached through +// the same dispatch, so it would be one more module in the same position — and +// the failure is silent enough that "probably fine" is not worth finding out). +// Keeping both sides identical also means the dispatcher marshals once, not +// twice. + +module; + +#if defined(__linux__) || defined(__APPLE__) +#ifndef _GNU_SOURCE +#define _GNU_SOURCE // posix_spawn_file_actions_addchdir_np (glibc) +#endif +#include // pipe, close, read +#include // waitpid +#include // posix_spawnp, posix_spawn_file_actions_* +#include // kill, SIGKILL +#include // errno, EINTR +#include // fcntl O_NONBLOCK +#include // nanosleep +#if defined(__APPLE__) +#include // _NSGetEnviron +#endif +#endif + +export module mcpp.platform.unix.bounded_process; + +import std; + +#if defined(__linux__) +// Declared in the module purview, not the global module fragment: an entity +// with C language linkage is never module-attached, so this names the same +// symbol the loader provides — and GCC rejects non-`#include` content in a +// global module fragment (-Wglobal-module). +extern "C" char **environ; +#endif + +export namespace mcpp::platform::unixproc { + +// Every member is a builtin type. Same reason as the Windows peer. +struct DeadlineRun { + // False on every non-POSIX build, and on POSIX when the child could not be + // spawned. "Could not spawn" and "ran and failed" must not share an exit + // code, so the caller falls back rather than reporting a failure. + bool supported = false; + int exit_code = 0; + bool timed_out = false; +}; + +using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); + +// `argvEntries` is `argvCount` NUL-terminated strings; `envEntries` is +// `envCount` "KEY=VALUE" strings applied on top of the current environment. +// `cwd` may be null. A non-positive `deadlineMs` is rejected with +// supported=false: "no bound" belongs on the caller's untimed path. +// +// When `sink` is null the output is discarded but the child is still bounded — +// that is the `run_exec_deadline` shape. +DeadlineRun capture_with_deadline(const char* const* argvEntries, + unsigned long argvCount, + const char* const* envEntries, + unsigned long envCount, + const char* cwd, + long long deadlineMs, + OutputSink sink, + void* ctx); + +} // namespace mcpp::platform::unixproc + +namespace mcpp::platform::unixproc { + +#if defined(__linux__) || defined(__APPLE__) + +namespace { + +char** current_environ() { +#if defined(__APPLE__) + return *::_NSGetEnviron(); +#else + return environ; +#endif +} + +int normalize_status(int status) { + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) return 128 + WTERMSIG(status); + return status; +} + +} // namespace + +DeadlineRun capture_with_deadline(const char* const* argvEntries, + unsigned long argvCount, + const char* const* envEntries, + unsigned long envCount, + const char* cwd, + long long deadlineMs, + OutputSink sink, + void* ctx) +{ + DeadlineRun out; + if (deadlineMs <= 0 || argvCount == 0 || !argvEntries) return out; + + // The child's environment: ours, minus anything overridden, plus the + // overrides. Names are case-SENSITIVE here (unlike the Windows peer). + std::vector envStore; + { + std::vector overridden; + overridden.reserve(envCount); + for (unsigned long i = 0; i < envCount; ++i) { + std::string_view e(envEntries[i]); + overridden.push_back(e.substr(0, e.find('='))); + } + for (char** p = current_environ(); p && *p; ++p) { + std::string_view e(*p); + auto name = e.substr(0, e.find('=')); + if (std::ranges::find(overridden, name) != overridden.end()) continue; + envStore.emplace_back(e); + } + for (unsigned long i = 0; i < envCount; ++i) + envStore.emplace_back(envEntries[i]); + } + std::vector envp; + envp.reserve(envStore.size() + 1); + for (auto& s : envStore) envp.push_back(s.data()); + envp.push_back(nullptr); + + std::vector cargv; + cargv.reserve(argvCount + 1); + for (unsigned long i = 0; i < argvCount; ++i) + cargv.push_back(const_cast(argvEntries[i])); + cargv.push_back(nullptr); + + int fds[2]; + if (::pipe(fds) != 0) return out; + + posix_spawn_file_actions_t fa; + ::posix_spawn_file_actions_init(&fa); + // Same cwd contract as the untimed launcher: a bounded child must land in + // the same directory an unbounded one would, or adding a timeout would + // silently move where a build program's relative writes go. + if (cwd && *cwd) + ::posix_spawn_file_actions_addchdir_np(&fa, cwd); + ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); + ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); + ::posix_spawn_file_actions_addclose(&fa, fds[0]); + ::posix_spawn_file_actions_addclose(&fa, fds[1]); + + pid_t pid = 0; + int sp = ::posix_spawnp(&pid, cargv[0], &fa, nullptr, cargv.data(), envp.data()); + ::posix_spawn_file_actions_destroy(&fa); + ::close(fds[1]); + if (sp != 0) { ::close(fds[0]); return out; } + + // Non-blocking reads so the deadline is still checked while the child is + // quiet. A blocking read on a silent, hung child is exactly the hang this + // whole mechanism exists to stop. + ::fcntl(fds[0], F_SETFL, ::fcntl(fds[0], F_GETFL, 0) | O_NONBLOCK); + + const auto until = std::chrono::steady_clock::now() + + std::chrono::milliseconds(deadlineMs); + std::array buf{}; + bool killed = false; + int status = 0; + + for (;;) { + ssize_t n; + bool drained = false; + while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) { + if (sink) sink(ctx, buf.data(), + static_cast(n)); + drained = true; + } + if (drained) continue; + + pid_t r = ::waitpid(pid, &status, WNOHANG); + if (r == pid) { + // Drain the tail: the child is gone, so this terminates. + while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) + if (sink) sink(ctx, buf.data(), + static_cast(n)); + break; + } + if (r < 0 && errno != EINTR && errno != ECHILD) break; + + if (!killed && std::chrono::steady_clock::now() >= until) { + ::kill(pid, SIGKILL); + killed = true; + continue; + } + struct timespec ts{0, 20'000'000}; // 20ms + ::nanosleep(&ts, nullptr); + } + ::close(fds[0]); + + out.exit_code = normalize_status(status); + out.timed_out = killed; + out.supported = true; + return out; +} + +#else + +DeadlineRun capture_with_deadline(const char* const*, unsigned long, + const char* const*, unsigned long, + const char*, long long, OutputSink, void*) { + // Not POSIX: mcpp.platform.windows.bounded_process owns this. + return {}; +} + +#endif + +} // namespace mcpp::platform::unixproc diff --git a/src/platform/windows/bounded_process.cppm b/src/platform/windows/bounded_process.cppm new file mode 100644 index 00000000..6a1a141c --- /dev/null +++ b/src/platform/windows/bounded_process.cppm @@ -0,0 +1,288 @@ +// mcpp.platform.windows.bounded_process — a child process with a real +// deadline on Windows. +// +// WHY THIS EXISTS +// +// `capture_exec_deadline` enforced its deadline on POSIX only; everywhere else +// it fell through to the unbounded launcher. So every timeout knob was a +// silent no-op on Windows — `mcpp test --timeout`, `--build-timeout`, and +// `[build] build_program_timeout`. A knob that reports nothing and does +// nothing is worse than an absent one: the user sets it, the build still +// hangs, and nothing connects the two. +// +// WHY A JOB OBJECT +// +// Not thoroughness — correctness. The child inherits the capture pipe's write +// handle, and so does anything IT spawns. Killing only the child leaves a +// grandchild holding that handle, and the parent's drain then blocks forever: +// the timeout would "fire" and hang anyway. A Job with +// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE takes the whole tree down at once, which +// closes every inherited handle and lets the drain finish. +// +// ⚠️ WHY THE INTERFACE HAS NO `std` TYPES IN IT +// +// This is a hard constraint, not a style preference. Measured on GCC 16.1.0 +// while adding this module: a NEW module that (a) is imported by +// `mcpp.platform.process` and (b) names `std` types in its EXPORTED +// interface makes every BMI downstream of `mcpp.platform.process` come back as +// +// mcpp.manifest.xpkg: error: failed to read compiled module cluster 192: +// Bad file data +// +// deterministically, in a clean build, and identically under `ninja -j1` (so +// it is not a write/read race). Bisected precisely: +// +// module exists but nothing imports it -> builds +// imported, exports only `int probe()` -> builds +// imported, `import std;` in the purview-> builds +// imported, exports std::string/… -> every downstream BMI corrupt +// +// So `std` may be used freely INSIDE this module; it must not appear in what +// the module exports. The output is therefore delivered through a callback +// instead of returned as a string, and the environment arrives as an array of +// `"K=V"` C strings. +// +// The same constraint is why the caller does the marshalling: see +// `mcpp.platform.process`. + +module; + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +export module mcpp.platform.windows.bounded_process; + +import std; + +export namespace mcpp::platform::winproc { + +// Every member is a builtin type — see the note above. +struct DeadlineRun { + // False on every non-Windows build, and on Windows when the process could + // not be created at all. The caller must fall back to its unbounded path + // rather than treating this as a failed child: "could not spawn it" and + // "it ran and failed" are different answers and must not share an exit + // code. + bool supported = false; + int exit_code = 0; + bool timed_out = false; +}; + +// Receives stdout+stderr as it arrives. Called on the calling thread only. +using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); + +// `commandLine` is already quoted for CreateProcess (callers pass the output +// of windows_command_from_argv). `envEntries` is `envCount` NUL-terminated +// "KEY=VALUE" strings applied on top of the current environment. `cwd` may be +// null. A non-positive `deadlineMs` is rejected with supported=false — "no +// bound" belongs on the caller's untimed path, which needs none of this. +DeadlineRun capture_with_deadline(const char* commandLine, + const char* const* envEntries, + unsigned long envCount, + const char* cwd, + long long deadlineMs, + OutputSink sink, + void* ctx); + +} // namespace mcpp::platform::winproc + +namespace mcpp::platform::winproc { + +#if defined(_WIN32) + +namespace { + +// A `\0`-separated, `\0\0`-terminated block: the current environment with +// `envEntries` applied on top. +// +// The override match is case-INSENSITIVE because Windows environment names +// are: passing `Path=` alongside an existing `PATH=` would otherwise leave two +// entries and let the loader pick. +std::string environment_block(const char* const* envEntries, + unsigned long envCount) { + auto upper = [](std::string s) { + for (auto& c : s) + c = static_cast(std::toupper(static_cast(c))); + return s; + }; + + std::vector overriddenUpper; + overriddenUpper.reserve(envCount); + for (unsigned long i = 0; i < envCount; ++i) { + std::string entry(envEntries[i]); + auto eq = entry.find('='); + overriddenUpper.push_back(upper(entry.substr(0, eq == std::string::npos + ? entry.size() : eq))); + } + + std::vector entries; + if (LPCH env = ::GetEnvironmentStringsA()) { + for (const char* p = env; *p; ) { + std::string entry(p); + p += entry.size() + 1; + // Drive-letter entries ("=C:=C:\path") start with '=' and have no + // name to compare; they must survive verbatim or relative paths + // resolve differently in the child. + auto eq = entry.find('=', 1); + if (eq != std::string::npos) { + if (std::ranges::find(overriddenUpper, upper(entry.substr(0, eq))) + != overriddenUpper.end()) + continue; + } + entries.push_back(std::move(entry)); + } + ::FreeEnvironmentStringsA(env); + } + for (unsigned long i = 0; i < envCount; ++i) + entries.emplace_back(envEntries[i]); + + std::string block; + for (auto const& e : entries) { block += e; block.push_back('\0'); } + // An empty block still needs its terminator or CreateProcess reads past + // the buffer. + block.push_back('\0'); + return block; +} + +struct Handle { + HANDLE h = nullptr; + Handle() = default; + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + ~Handle() { reset(); } + void reset() { + if (h && h != INVALID_HANDLE_VALUE) ::CloseHandle(h); + h = nullptr; + } +}; + +} // namespace + +DeadlineRun capture_with_deadline(const char* commandLine, + const char* const* envEntries, + unsigned long envCount, + const char* cwd, + long long deadlineMs, + OutputSink sink, + void* ctx) +{ + DeadlineRun out; + if (deadlineMs <= 0 || !commandLine || !*commandLine) return out; + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + Handle readEnd, writeEnd; + if (!::CreatePipe(&readEnd.h, &writeEnd.h, &sa, 0)) return out; + // Only the WRITE end may cross into the child. An inheritable read end + // there would keep the pipe alive past the child's exit and the drain + // below would never see EOF. + if (!::SetHandleInformation(readEnd.h, HANDLE_FLAG_INHERIT, 0)) return out; + + Handle job; + job.h = ::CreateJobObjectA(nullptr, nullptr); + if (job.h) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{}; + jeli.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject(job.h, JobObjectExtendedLimitInformation, + &jeli, sizeof(jeli)); + } + + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = writeEnd.h; + si.hStdError = writeEnd.h; + si.hStdInput = ::GetStdHandle(STD_INPUT_HANDLE); + + PROCESS_INFORMATION pi{}; + std::string cmdBuf(commandLine); // CreateProcessA may modify it + auto envBlock = environment_block(envEntries, envCount); + + // CREATE_SUSPENDED so the child joins the job BEFORE it can spawn + // anything — a grandchild created in that gap would escape the kill. + BOOL ok = ::CreateProcessA( + nullptr, cmdBuf.data(), nullptr, nullptr, /*bInheritHandles=*/TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, + envBlock.data(), + (cwd && *cwd) ? cwd : nullptr, + &si, &pi); + if (!ok) return out; + + Handle proc; proc.h = pi.hProcess; + Handle thread; thread.h = pi.hThread; + if (job.h) ::AssignProcessToJobObject(job.h, proc.h); + ::ResumeThread(thread.h); + + // The parent must drop its copy of the write end or the pipe never reaches + // EOF, even after every child has exited. + writeEnd.reset(); + + const auto until = std::chrono::steady_clock::now() + + std::chrono::milliseconds(deadlineMs); + std::array buf{}; + bool killed = false; + + auto drain_available = [&]() -> bool { + DWORD avail = 0; + if (!::PeekNamedPipe(readEnd.h, nullptr, 0, nullptr, &avail, nullptr)) + return false; + if (avail == 0) return false; + DWORD want = static_cast( + std::min(buf.size(), static_cast(avail))); + DWORD got = 0; + if (!::ReadFile(readEnd.h, buf.data(), want, &got, nullptr) || got == 0) + return false; + if (sink) sink(ctx, buf.data(), static_cast(got)); + return true; + }; + + for (;;) { + if (drain_available()) continue; // empty the pipe before sleeping + + if (::WaitForSingleObject(proc.h, 0) == WAIT_OBJECT_0) { + while (drain_available()) { /* tail */ } + break; + } + + if (!killed && std::chrono::steady_clock::now() >= until) { + killed = true; + // Closing the job takes the whole tree with it. TerminateProcess + // alone would leave grandchildren holding the pipe open. + if (job.h) job.reset(); + else ::TerminateProcess(proc.h, 1); + ::WaitForSingleObject(proc.h, 5000); + continue; + } + + ::Sleep(20); + } + + DWORD code = 0; + ::GetExitCodeProcess(proc.h, &code); + out.exit_code = static_cast(code); + out.timed_out = killed; + out.supported = true; + return out; +} + +#else + +DeadlineRun capture_with_deadline(const char*, const char* const*, unsigned long, + const char*, long long, OutputSink, void*) { + // Not Windows: the POSIX launcher in mcpp.platform.process owns this. + return {}; +} + +#endif + +} // namespace mcpp::platform::winproc diff --git a/src/platform/windows.cppm b/src/platform/windows/windows.cppm similarity index 100% rename from src/platform/windows.cppm rename to src/platform/windows/windows.cppm diff --git a/src/source_kind.cppm b/src/source_kind.cppm new file mode 100644 index 00000000..1ec14efd --- /dev/null +++ b/src/source_kind.cppm @@ -0,0 +1,372 @@ +// mcpp.source_kind — what ROLE a source file plays in the build, decided once. +// +// WHY THIS MODULE EXISTS +// +// "Which extension means what" used to be derived in twenty places across +// nine files, in eight mutually inconsistent lists. Three different answers to +// "is this an implementation unit" (`.cpp` in the scanner, `.cpp`/`.cxx` in the +// p1689 reader, eight extensions in the planner); four different answers to +// "is this a source file at all" (the fast-path sweep, the build-program output +// check, the default glob, the staging fallback). Adding `.ixx` to one of them +// silently left the other seven unchanged — which is exactly how mcpp#272 fixed +// link-object collection while `pick_rule` still routed the same file to a rule +// that emits no BMI. +// +// The repair is not "everyone calls the same function". It is: +// +// classification happens ONCE, where the file enters the graph, and is +// carried as data from there on. +// +// `SourceUnit::kind` is set by the scanner (which already has the owning +// package's manifest in hand); `CompileUnit::kind` is copied from it by the +// planner; every consumer downstream reads the field and never looks at an +// extension again. The one legitimate re-derivation is the fast path, which +// runs BEFORE prepare and therefore has no plan — it calls classify() with the +// table from the same manifest the scanner will use, so the two cannot drift. +// +// WHY A LEAF MODULE (import std, nothing else) +// +// `mcpp.manifest.toml` needs it (default globs, auto-target inference) and +// `mcpp.modgraph.scanner` needs it, while every `mcpp.build.*` module imports +// `mcpp.manifest`. Living under src/build/ would make the dependency circular. +// Same argument as mcpp.version: a leaf can be used by anyone. Keep it a leaf. +// +// WHY EXTENSIONS ARE COMPARED LITERALLY (no case folding) +// +// `.S` and `.s` are DIFFERENT LANGUAGES here — `.S` goes through the C +// preprocessor, `.s` does not. Case is load-bearing in this domain, so +// normalization only trims and supplies a missing leading dot. This also +// preserves today's semantics exactly: every site being replaced compared +// extensions literally. + +export module mcpp.source_kind; + +import std; + +export namespace mcpp { + +// The role a file plays. Deliberately about the BUILD GRAPH, not about +// language dialects: two files with the same kind are handled the same way by +// the planner, the backend and the caches. +enum class SourceKind { + // Provides a module interface: compiles with the module rule, emits a BMI, + // and its object is linked unconditionally (module initializers can run + // even when nothing references a symbol in it). + ModuleInterface, + // C++ translation unit that is not a module interface. + Cxx, + // C / Objective-C. Routed to the C compile rule; never scanned for imports + // (a benign `import_foo` identifier must not be misparsed). + C, + // GNU assembler. `.S` is preprocessed, `.s` is not — the distinction lives + // in the extension, not in the kind, because it selects a compile rule + // within one role rather than a different role. + GasAsm, + // NASM syntax. + NasmAsm, + // Not compiled, but editing one can change what the graph should be. + Header, + // Not a build input. + Other, +}; + +std::string_view to_string(SourceKind k); + +// Which extensions name a module interface. Everything else about +// classification is fixed: the C / assembly / header axes have been complete +// and stable since they were introduced, so this is the only axis a project +// can extend. +// +// The struct (rather than a bare vector) is the room to grow: adding a +// `cxx` axis later is one member plus one parse site, and no signature in this +// file changes. Resist filling it in speculatively — an axis nobody asked for +// is an axis nobody has tested. +struct ExtensionTable { + // Always contains the built-ins first, in their historical order. + std::vector moduleInterface; +}; + +// Trim, then supply a leading dot if absent. Does NOT change case — see the +// header comment. Returns an empty string for input that cannot name an +// extension (empty, or "." alone), which callers must reject. +std::string normalize_extension(std::string_view raw); + +// The built-in table. `.cppm` ONLY — `.ccm` / `.cxxm` / `.ixx` are opt-in via +// `[build] module_extensions`. +// +// This is a compatibility decision, not a conservatism reflex. Widening the +// built-in set widens the DEFAULT source glob with it, so a published package +// with a vendored MSVC-only `.ixx` under src/ would start compiling it on the +// next mcpp upgrade — a break its author cannot fix, because the tarball for +// that version has already shipped. +ExtensionTable builtin_extension_table(); + +// Built-ins plus a package's own `[build] module_extensions`. Entries are +// normalized and de-duplicated; already-built-in and empty entries are +// dropped. Validation of hostile entries is `validate_module_extensions` +// below — this function never fails, so that a manifest which failed +// validation still classifies exactly like a default one. +ExtensionTable extension_table_for(std::span extras); + +// Extensions that already name a non-module role. Declaring one of these as a +// module interface has no legitimate use and would route (say) a C file to the +// C++ module rule, failing somewhere that names neither the file nor the key. +// Rejected at parse time as a hard error. +bool is_reserved_non_module_extension(std::string_view normalizedExt); + +// nullopt = fine. Otherwise the (already formatted) reason, for a manifest +// error. Checks each entry in order so the message names the first offender. +std::optional +validate_module_extensions(std::span extras); + +SourceKind classify(const std::filesystem::path& p, const ExtensionTable& t); + +// ─── Predicates derived from the kind ──────────────────────────────────── +// +// These exist so that a consumer interested in ONE axis does not write its own +// switch — which is how the eight inconsistent lists happened in the first +// place. + +// Compiles with the module rule and emits a BMI. +bool produces_bmi(SourceKind k); +// Its object is linked even when nothing references it. +bool links_unconditionally(SourceKind k); +// Cannot contain `import` / `module`: no P1689 scan, no BMI implicit inputs. +bool is_scan_exempt(SourceKind k); +// A C++ translation unit or module interface — i.e. compiled by the C++ +// driver rather than the C driver or an assembler. +bool is_cxx_like(SourceKind k); +// Editing a file of this kind can change what the graph SHOULD be, so the +// fast path must fall through to a full prepare. +// +// Assembly is deliberately absent: an assembly unit has no `import` and no +// scanned include graph, so editing one changes its object's content (which +// ninja tracks on its own) but never the shape of the graph. A NEW assembly +// file is a different question, and glob_inputs_stale answers it. +bool affects_graph_shape(SourceKind k); + +// The convention default for `[build] sources`, derived from the table rather +// than written beside it. Two hand-maintained copies of this list already +// existed and had already drifted apart (the staging fallback was missing all +// three assembly extensions). +std::vector default_source_globs(const ExtensionTable& t); + +// The human-readable form of the above, for `inferredNotes`. Derived for the +// same reason. +std::string default_source_globs_note(const ExtensionTable& t); + +// ─── Object file naming ────────────────────────────────────────────────── + +// How a source's object file is named. Three cases, and the reason there are +// three rather than one is compatibility, not taste. +enum class ObjectNaming { + // `foo.cpp` -> `foo.o` + Stem, + // `foo.cppm` -> `foo.m.o` + StemDotM, + // `foo.ixx` -> `foo.ixx.o` + FullFilename, +}; + +// An object's name is part of the INTERNAL LAYOUT of a global cache entry +// (`CompileUnit::packageObjectRel`). Renaming one without changing the cache +// key does not produce a cache miss — it produces a HIT on an entry that does +// not contain the object the link step then asks for. So every extension that +// has a historical name keeps it, and only extensions that had no name before +// (anything a project adds via `module_extensions`) get the collision-proof +// full-filename form that assembly has always used. +// +// The function is total and monotone: adding a new extension can never change +// an existing name. +// +// KNOWN GAP, deliberately not closed here: `foo.c` and `foo.cpp` in the same +// directory both answer `Stem` and therefore collide, as they always have. +// Fixing it means renaming C objects, which is precisely the cache-layout +// change described above and needs a cache-key revision to be safe. Tracked +// separately; `tests/unit/test_source_kind.cpp` pins it as a known gap rather +// than weakening the exhaustive no-collision assertion around it. +ObjectNaming object_naming_for(const std::filesystem::path& src); + +} // namespace mcpp + +namespace mcpp { + +namespace { + +// Historical object-naming sets. Literal, and intentionally NOT derived from +// SourceKind: the question here is "did this extension have a name before", +// which is a fact about mcpp's history, not about the language. +constexpr std::string_view kStemNamed[] = { + ".cpp", ".cc", ".cxx", ".c", ".m", ".mm", +}; +constexpr std::string_view kFullFilenameNamed[] = { + ".S", ".s", ".asm", +}; + +constexpr std::string_view kCxxExtensions[] = { ".cpp", ".cc", ".cxx", ".mm" }; +constexpr std::string_view kCExtensions[] = { ".c", ".m" }; +constexpr std::string_view kGasExtensions[] = { ".S", ".s" }; +constexpr std::string_view kNasmExtensions[] = { ".asm" }; +constexpr std::string_view kHeaderExtensions[] = { ".h", ".hpp", ".hh", ".hxx" }; + +bool contains(std::span set, std::string_view ext) { + for (auto e : set) if (e == ext) return true; + return false; +} + +} // namespace + +std::string_view to_string(SourceKind k) { + switch (k) { + case SourceKind::ModuleInterface: return "module-interface"; + case SourceKind::Cxx: return "c++"; + case SourceKind::C: return "c"; + case SourceKind::GasAsm: return "gas"; + case SourceKind::NasmAsm: return "nasm"; + case SourceKind::Header: return "header"; + case SourceKind::Other: return "other"; + } + return "other"; +} + +std::string normalize_extension(std::string_view raw) { + std::size_t b = 0, e = raw.size(); + while (b < e && (raw[b] == ' ' || raw[b] == '\t')) ++b; + while (e > b && (raw[e - 1] == ' ' || raw[e - 1] == '\t')) --e; + auto trimmed = raw.substr(b, e - b); + if (trimmed.empty() || trimmed == ".") return {}; + if (trimmed.front() == '.') return std::string(trimmed); + return "." + std::string(trimmed); +} + +ExtensionTable builtin_extension_table() { + return ExtensionTable{ .moduleInterface = { ".cppm" } }; +} + +ExtensionTable extension_table_for(std::span extras) { + auto t = builtin_extension_table(); + for (auto const& raw : extras) { + auto ext = normalize_extension(raw); + if (ext.empty()) continue; + if (std::ranges::find(t.moduleInterface, ext) != t.moduleInterface.end()) + continue; + t.moduleInterface.push_back(std::move(ext)); + } + return t; +} + +bool is_reserved_non_module_extension(std::string_view ext) { + return contains(kCxxExtensions, ext) || contains(kCExtensions, ext) + || contains(kGasExtensions, ext) || contains(kNasmExtensions, ext) + || contains(kHeaderExtensions, ext); +} + +std::optional +validate_module_extensions(std::span extras) { + for (auto const& raw : extras) { + auto ext = normalize_extension(raw); + if (ext.empty()) { + return std::format( + "[build].module_extensions contains an empty entry ('{}'); " + "each entry names a file extension, e.g. \".ixx\"", raw); + } + if (ext.find('/') != std::string::npos + || ext.find('\\') != std::string::npos + || ext.find('*') != std::string::npos) { + return std::format( + "[build].module_extensions entry '{}' is not an extension. " + "This key takes extensions (\".ixx\"), not paths or globs — " + "use [build].sources to choose WHICH files are built.", raw); + } + if (ext.find('.', 1) != std::string::npos) { + return std::format( + "[build].module_extensions entry '{}' has more than one dot; " + "an extension is the final segment only (\".ixx\")", raw); + } + if (is_reserved_non_module_extension(ext)) { + return std::format( + "[build].module_extensions cannot claim '{}': it already names " + "a non-module source role, and declaring it as a module " + "interface would route those files to the C++ module rule.\n" + " If you have module interfaces with an unusual " + "extension, name that extension instead.", ext); + } + } + return std::nullopt; +} + +SourceKind classify(const std::filesystem::path& p, const ExtensionTable& t) { + auto ext = p.extension().string(); + if (ext.empty()) return SourceKind::Other; + // The project's axis wins: a package that declares `.ixx` means it. + // It can never shadow a non-module role — validate_module_extensions + // rejects those entries before they reach a table. + for (auto const& m : t.moduleInterface) + if (ext == m) return SourceKind::ModuleInterface; + if (contains(kCxxExtensions, ext)) return SourceKind::Cxx; + if (contains(kCExtensions, ext)) return SourceKind::C; + if (contains(kGasExtensions, ext)) return SourceKind::GasAsm; + if (contains(kNasmExtensions, ext)) return SourceKind::NasmAsm; + if (contains(kHeaderExtensions, ext)) return SourceKind::Header; + return SourceKind::Other; +} + +bool produces_bmi(SourceKind k) { return k == SourceKind::ModuleInterface; } + +bool links_unconditionally(SourceKind k) { return k == SourceKind::ModuleInterface; } + +bool is_scan_exempt(SourceKind k) { + return k == SourceKind::C || k == SourceKind::GasAsm || k == SourceKind::NasmAsm; +} + +bool is_cxx_like(SourceKind k) { + return k == SourceKind::ModuleInterface || k == SourceKind::Cxx; +} + +bool affects_graph_shape(SourceKind k) { + return k == SourceKind::ModuleInterface || k == SourceKind::Cxx + || k == SourceKind::C || k == SourceKind::Header; +} + +std::vector default_source_globs(const ExtensionTable& t) { + std::vector globs; + globs.reserve(t.moduleInterface.size() + 6); + for (auto const& e : t.moduleInterface) globs.push_back("src/**/*" + e); + // Assembly in the tree almost certainly wants building; a project that + // vendors foreign-syntax `.asm` can `!`-exclude it. (`.cxx` has never been + // in the convention default and is not added here — that would be a + // behavioral change wearing a refactor's clothes.) + globs.push_back("src/**/*.cpp"); + globs.push_back("src/**/*.cc"); + globs.push_back("src/**/*.c"); + globs.push_back("src/**/*.S"); + globs.push_back("src/**/*.s"); + globs.push_back("src/**/*.asm"); + return globs; +} + +std::string default_source_globs_note(const ExtensionTable& t) { + std::string s = "sources [src/**/*.{"; + bool first = true; + for (auto const& e : t.moduleInterface) { + if (!first) s += ','; + first = false; + s += e.substr(1); // drop the leading dot + } + s += ",cpp,cc,c,S,s,asm}]"; + return s; +} + +ObjectNaming object_naming_for(const std::filesystem::path& src) { + auto ext = src.extension().string(); + if (contains(kFullFilenameNamed, ext)) return ObjectNaming::FullFilename; + if (ext == ".cppm") return ObjectNaming::StemDotM; + if (contains(kStemNamed, ext)) return ObjectNaming::Stem; + // Anything with no historical name — every extension a project can add + // via `module_extensions`, and any stray input — gets the collision-proof + // form. `foo.ixx` -> `foo.ixx.o` can collide with neither `foo.cppm` -> + // `foo.m.o` nor `foo.cpp` -> `foo.o`. + return ObjectNaming::FullFilename; +} + +} // namespace mcpp diff --git a/src/toolchain/model.cppm b/src/toolchain/model.cppm index df1bf0b8..85891073 100644 --- a/src/toolchain/model.cppm +++ b/src/toolchain/model.cppm @@ -125,6 +125,31 @@ struct BmiTraits { std::string_view stdCompatBmiUsePrefix; // "" | " -fmodule-file=std.compat=" | " /reference std.compat=" std::string_view moduleOutputPrefix; // "" | " -fmodule-output=" | " /ifcOutput " std::string_view bmiSearchPrefix; // "" | " -fprebuilt-module-path=" | " /ifcSearchDir " + // How this compiler is TOLD that a translation unit is a module interface. + // Emitted UNCONDITIONALLY on every module compile — mcpp never asks + // "does this driver recognize this extension?". + // + // WHY UNCONDITIONALLY. Every driver has a private, version-dependent + // suffix->language table, and the three disagree in both directions: + // measured 2026-08-11, Clang 22.1.8 does not recognize `.ixx` at all + // (it hands the file to the LINKER, warns, and exits 0 with no BMI — + // a silent no-op), while cl.exe does not recognize `.cppm`. Maintaining + // "who knows which suffix" would be a table that expires with every + // compiler release AND whose errors are silent. + // + // Saying it every time costs nothing: the flag is IDEMPOTENT on a suffix + // the driver already knows. Measured on the same day — Clang's `.cppm` + // BMI is byte-identical with and without `-x c++-module` (18896 bytes + // both); GCC's `.gcm` is unchanged too (its output is not byte- + // reproducible run to run, so the comparison is same-size plus a diff + // offset indistinguishable from the run-to-run noise). + // + // NOT interchangeable between families: `-x c++-module` makes GCC exit + // with "language c++-module not recognized", and `-x c++` makes Clang + // emit a 174-byte stub instead of a module BMI. + // + // Positional on GNU, so the emitter must place it before `-c $in`. + std::string_view moduleInterfaceLangFlag; // " -x c++" | " -x c++-module" | " /interface /TP" }; BmiTraits bmi_traits(const Toolchain& tc); @@ -203,6 +228,10 @@ BmiTraits bmi_traits(const Toolchain& tc) { .stdCompatBmiUsePrefix = " /reference std.compat=", .moduleOutputPrefix = " /ifcOutput ", .bmiSearchPrefix = " /ifcSearchDir ", + // Pre-existing behaviour, unchanged: cl has always been told + // explicitly, because mcpp's interfaces are `.cppm` and cl does + // not know that suffix. The other two families now match it. + .moduleInterfaceLangFlag = " /interface /TP", }; } if (is_clang(tc)) { @@ -218,6 +247,7 @@ BmiTraits bmi_traits(const Toolchain& tc) { .stdCompatBmiUsePrefix = " -fmodule-file=std.compat=", .moduleOutputPrefix = " -fmodule-output=", .bmiSearchPrefix = " -fprebuilt-module-path=", + .moduleInterfaceLangFlag = " -x c++-module", }; } return { @@ -234,6 +264,10 @@ BmiTraits bmi_traits(const Toolchain& tc) { .stdCompatBmiUsePrefix = "", .moduleOutputPrefix = "", .bmiSearchPrefix = "", + // GCC decides interface-ness from the content (`export module`), so + // it only needs to be told the LANGUAGE. `-x c++-module` is not a + // value GCC accepts. + .moduleInterfaceLangFlag = " -x c++", }; } diff --git a/src/version.cppm b/src/version.cppm index 3b7bbac1..09fb58f9 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.10.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.11.1"; } // namespace mcpp diff --git a/src/xlings.cppm b/src/xlings.cppm index 21e06b93..437c6b13 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.10.4"; + inline constexpr std::string_view kXlingsVersion = "2026.8.11.1"; inline constexpr std::string_view kNasmVersion = "3.02"; } diff --git a/tests/e2e/186_build_mcpp_protocol_and_bound.sh b/tests/e2e/186_build_mcpp_protocol_and_bound.sh index c792afcf..9317f886 100755 --- a/tests/e2e/186_build_mcpp_protocol_and_bound.sh +++ b/tests/e2e/186_build_mcpp_protocol_and_bound.sh @@ -112,6 +112,52 @@ grep -q "time limit" b4.log || { cat b4.log; echo "FAIL: no timeout diagnostic"; grep -q "'app'" b4.log || { cat b4.log; echo "FAIL: timeout error does not name the package"; exit 1; } grep -q "MCPP_BUILD_PROGRAM_TIMEOUT" b4.log || { cat b4.log; echo "FAIL: timeout error does not say how to change the bound"; exit 1; } +# ...and WHICH mcpp.toml to edit. When a DEPENDENCY's build program times out, +# the user's instinct is to edit their own manifest, which changes nothing. +grep -q "mcpp.toml" b4.log || { + cat b4.log; echo "FAIL: timeout error does not name the manifest to edit"; exit 1; } + +# ── 4b. The bound is configurable from the manifest ───────────────────────── +# +# Deliberately 2 seconds, not something near the 600s default: the point is +# that the KEY is read, and a long value would just make CI wait. +cat >> mcpp.toml <<'EOF' + +[build] +build_program_timeout = 2 +EOF +fresh +start=$(date +%s) +if "$MCPP" build > b4b.log 2>&1; then + cat b4b.log; echo "FAIL: [build] build_program_timeout did not bound the run"; exit 1 +fi +elapsed=$(( $(date +%s) - start )) +[ "$elapsed" -lt 60 ] || { echo "FAIL: the manifest bound did not fire (took ${elapsed}s)"; exit 1; } +grep -q "exceeded its 2s time limit" b4b.log || { + cat b4b.log; echo "FAIL: the manifest value is not the effective bound"; exit 1; } + +# The env var is a per-invocation override and must WIN over the manifest. +fresh +if MCPP_BUILD_PROGRAM_TIMEOUT=1 "$MCPP" build > b4c.log 2>&1; then + cat b4c.log; echo "FAIL: build succeeded under a 1s bound"; exit 1 +fi +grep -q "exceeded its 1s time limit" b4c.log || { + cat b4c.log; echo "FAIL: MCPP_BUILD_PROGRAM_TIMEOUT did not override the manifest"; exit 1; } + +# A negative value is a manifest error, not a silently-ignored one: it would +# otherwise read as "no bound" and remove the guard entirely. +cp mcpp.toml mcpp.toml.bak +sed -i.tmp 's/^build_program_timeout = 2$/build_program_timeout = -1/' mcpp.toml +if "$MCPP" build > b4d.log 2>&1; then + cat b4d.log; echo "FAIL: a negative build_program_timeout was accepted"; exit 1 +fi +grep -q "build_program_timeout" b4d.log || { + cat b4d.log; echo "FAIL: the error does not name the key"; exit 1; } +mv mcpp.toml.bak mcpp.toml +rm -f mcpp.toml.tmp + +# Restore a manifest with no bound override for the sections that follow. +sed -i '/^\[build\]$/,$d' mcpp.toml # ── 5. Cache: the entry carries an epoch, and a foreign one invalidates it ── cat > build.mcpp <<'EOF' diff --git a/tests/e2e/217_module_extensions.sh b/tests/e2e/217_module_extensions.sh new file mode 100755 index 00000000..998d0d9c --- /dev/null +++ b/tests/e2e/217_module_extensions.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# requires: gcc +# 217_module_extensions.sh — `[build] module_extensions`: a project declares +# which extensions its module INTERFACES use, and mcpp treats them as such +# everywhere. +# +# What each part protects against: +# +# * opt-in — `.ccm`/`.cxxm`/`.ixx` are NOT built in. If they were, the +# default source glob would widen with them and a published +# package with a vendored MSVC-only `.ixx` under src/ would +# start compiling it on the next mcpp upgrade — a break its +# author cannot fix, because that version's tarball shipped. +# Part 1 pins the un-configured behaviour. +# * rule routing — mcpp#272 fixed link-object collection while `pick_rule` +# stayed keyed on the extension, so a `.ixx` was routed to +# `cxx_object`: the edge still DECLARED a BMI output (that +# line reads providesModule) while the command line lost +# `-fmodule-output=`. GCC's gcm.cache made it look fine. +# * clang leg — and that is why this test must not be GCC-only. Clang does +# not recognize `.ixx` at all: it hands the file to the +# LINKER, warns, and exits 0 having produced no BMI. A +# GCC-only test would report green for a build that cannot +# work anywhere else. +# * object names — giving all four module extensions the `.m` prefix (as +# mcpp#272 proposed) makes `foo.cppm` and `foo.ccm` both +# `foo.m.o`. Part 2 pins one object per source. +set -e + +# Resolve before cd'ing: $0 is relative and every leg runs from $TMP. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +fixture() { # $1 = dir, $2 = extra mcpp.toml lines + mkdir -p "$1/src" + cat > "$1/mcpp.toml" < void { std::println("from .cppm"); }\n' > "$1/src/a.cppm" + printf 'export module extfix.b;\nimport std;\nexport auto b() -> void { std::println("from .ccm"); }\n' > "$1/src/b.ccm" + printf 'export module extfix.c;\nimport std;\nexport auto c() -> void { std::println("from .cxxm"); }\n' > "$1/src/c.cxxm" + printf 'export module extfix.d;\nimport std;\nexport auto d() -> void { std::println("from .ixx"); }\n' > "$1/src/d.ixx" +} + +# ── Part 1: NOT configured ⇒ only .cppm is a module interface ────────────── +# +# Reverse assertion. Without it, someone "simplifying" the built-in table into +# all four extensions would make every test above pass and break every +# published package that ships a stray .ixx. +fixture nocfg "" +printf 'import std;\nimport extfix.a;\nint main(){ a(); std::println("NOCFG OK"); }\n' > nocfg/src/main.cpp +cd nocfg +"$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL: default build broke"; exit 1; } +grep -q 'sources \[src/\*\*/\*\.{cppm,cpp,cc,c,S,s,asm}\]' b.log || { + cat b.log; echo "FAIL: default source glob changed"; exit 1; } +nj="$(find target -name build.ninja | head -1)" +for stray in b.ccm c.cxxm d.ixx; do + grep -q "$stray" "$nj" && { echo "FAIL: $stray compiled without opt-in"; exit 1; } +done +out="$("$MCPP" run 2>&1)" +[[ "$out" == *"NOCFG OK"* ]] || { echo "FAIL: $out"; exit 1; } +echo " ok: un-configured build ignores .ccm/.cxxm/.ixx" +cd "$TMP" + +# ── Part 2: configured ⇒ all four build, link and RUN ────────────────────── +run_leg() { # $1 = leg name, $2 = extra [toolchain] lines + local dir="leg_$1" + fixture "$dir" " +[build] +module_extensions = [\".ccm\", \".cxxm\", \".ixx\"] +$2" + printf 'import std;\nimport extfix.a;\nimport extfix.b;\nimport extfix.c;\nimport extfix.d;\nint main(){ a(); b(); c(); d(); std::println("ALL OK"); }\n' > "$dir/src/main.cpp" + cd "$dir" + + "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL[$1]: build"; exit 1; } + + # The declared extensions must reach the default glob, or the key would + # change how files are TREATED without changing whether they are FOUND. + grep -q 'sources \[src/\*\*/\*\.{cppm,ccm,cxxm,ixx,cpp,cc,c,S,s,asm}\]' b.log || { + cat b.log; echo "FAIL[$1]: declared extensions missing from default glob"; exit 1; } + + out="$("$MCPP" run 2>&1)" + for want in "from .cppm" "from .ccm" "from .cxxm" "from .ixx" "ALL OK"; do + [[ "$out" == *"$want"* ]] || { echo "FAIL[$1]: missing '$want' in: $out"; exit 1; } + done + + local nj; nj="$(find target -name build.ninja | head -1)" + + # Every module interface must use the MODULE rule. Asserting on the rule — + # not on the BMI path — is the point: the BMI output line is emitted from + # providesModule and stayed correct while the rule was wrong. + for src in a.cppm b.ccm c.cxxm d.ixx; do + grep -qE "^build [^:]*: cxx_module .*${src}\$|^build [^:]*: cxx_module .*${src} " "$nj" \ + || grep -q ": cxx_module .*${src}" "$nj" \ + || { echo "FAIL[$1]: $src did not use the cxx_module rule"; exit 1; } + done + + # One object per source, no two alike. + local objs + objs="$(grep -oE 'obj/[A-Za-z0-9_.]+\.o' "$nj" | sort -u)" + for want in "obj/a.m.o" "obj/b.ccm.o" "obj/c.cxxm.o" "obj/d.ixx.o"; do + grep -qx "$want" <<< "$objs" || { + echo "FAIL[$1]: expected object $want; got:"; echo "$objs"; exit 1; } + done + + echo " ok[$1]: four extensions build, link and run" + cd "$TMP" +} + +run_leg gcc "" + +# ── Part 3: the same, on Clang ───────────────────────────────────────────── +# +# Not optional coverage. `.ixx` is the extension Clang does not know, so this +# leg is the one that would have caught the pick_rule defect. Skipped only +# when no LLVM payload is installed. +source "$SCRIPT_DIR/_llvm_env.sh" +if [[ -d "$LLVM_ROOT" ]]; then + run_leg "clang" " +[toolchain] +default = \"llvm@${LLVM_VERSION}\"" +else + echo " skip: no LLVM payload installed — clang leg not run" +fi + +echo "OK" diff --git a/tests/e2e/218_module_extensions_graph_shape.sh b/tests/e2e/218_module_extensions_graph_shape.sh new file mode 100755 index 00000000..f2937a3a --- /dev/null +++ b/tests/e2e/218_module_extensions_graph_shape.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# requires: gcc +# 218_module_extensions_graph_shape.sh — a declared module extension has to +# behave like a module interface to the two mechanisms that decide WHETHER TO +# REBUILD, not just to the compiler. +# +# * Part 1 — the freshness fast path must sweep it. +# +# `sources_newer_than` asks "could the SHAPE of the graph have changed", +# which is a different question from "did a file change" (ninja answers +# that one). Its extension list used to be hand-written and did not include +# `.ixx`, so adding an `import` to one changed nothing it could see: the +# fast path replayed a stale graph, ninja recompiled the object because its +# mtime moved, the dyndep edges stayed as they were, and NOTHING reported +# anything. That is the worst failure mode in this area — silent, and it +# surfaces later as an unrelated BMI error. +# +# ⚠️ The edit below adds a real `import`. Do NOT reduce it to `touch`: an +# mtime-only change is exactly what a correct implementation is also +# allowed to ignore, so a touch-based test can pass with the bug present. +# ⚠️ And do NOT delete artifacts to force a rebuild: ninja then fails, the +# failure is read as a stale-graph signature, and the fast path falls back +# to a full prepare for the wrong reason — the assertion below would hold +# while proving nothing. +# +# * Part 2 — the key must reach the fingerprint. +# +# `module_extensions` decides which units emit a BMI and which objects link +# unconditionally, i.e. it is a build VARIANT. mcpp.toml's mtime alone only +# protects the fast path inside one output dir; it does not stop a BMI +# cache entry built under one classification from being served under +# another. Changing the key must land in a different `target///`. +# +# (Contrast `[build] build_program_timeout`, which is deliberately NOT +# fingerprinted — it changes no edge, and folding it in would make raising a +# timeout rebuild the whole project.) +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +mkdir -p src + +cat > mcpp.toml <<'EOF' +[package] +name = "gshape" +version = "0.1.0" + +[build] +module_extensions = [".ixx"] +EOF + +printf 'export module gshape.helper;\nimport std;\nexport auto helper() -> int { return 41; }\n' > src/helper.cppm +printf 'export module gshape.face;\nimport std;\nexport auto face() -> int { return 1; }\n' > src/face.ixx +printf 'import std;\nimport gshape.face;\nint main(){ std::println("{}", face()); }\n' > src/main.cpp + +fp_dir() { find target -name build.ninja -printf '%h\n' | head -1; } + +# ── 0. First build, then confirm the fast path actually engages ──────────── +"$MCPP" build > b0.log 2>&1 || { cat b0.log; echo "FAIL: first build"; exit 1; } +FP_BEFORE="$(fp_dir)" + +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: no-change build"; exit 1; } +grep -q "Compiling" b1.log && { + cat b1.log; echo "FAIL: fast path did not engage — the rest proves nothing"; exit 1; } +echo " ok: fast path engages on a no-change build" + +# ── 1. A NEW import inside the .ixx must invalidate the graph ────────────── +printf 'export module gshape.face;\nimport std;\nimport gshape.helper;\nexport auto face() -> int { return helper() + 1; }\n' > src/face.ixx + +"$MCPP" build > b2.log 2>&1 || { cat b2.log; echo "FAIL: build after .ixx edit"; exit 1; } +grep -q "Compiling" b2.log || { + cat b2.log + echo "FAIL: editing a .ixx did not invalidate the fast path" + echo " (the freshness sweep is not classifying it as a graph-shape input)" + exit 1; } +echo " ok: a new import inside a .ixx forces a full prepare" + +out="$("$MCPP" run 2>&1)" +[[ "$out" == *"42"* ]] || { echo "FAIL: expected 42 (41+1), got: $out"; exit 1; } +echo " ok: the new dependency edge is real (41+1 = 42)" + +# ── 2. Changing module_extensions must change the fingerprint ────────────── +cat > mcpp.toml <<'EOF' +[package] +name = "gshape" +version = "0.1.0" + +[build] +module_extensions = [".ixx", ".ccm"] +EOF + +"$MCPP" build > b3.log 2>&1 || { cat b3.log; echo "FAIL: build after key change"; exit 1; } +FP_AFTER="$(fp_dir)" + +[[ "$FP_BEFORE" != "$FP_AFTER" ]] || { + echo "FAIL: module_extensions changed but the output dir did not" + echo " before=$FP_BEFORE after=$FP_AFTER" + echo " (the key is missing from the canonical compile-flags string)" + exit 1; } +echo " ok: the key is fingerprinted ($(basename "$FP_BEFORE") -> $(basename "$FP_AFTER"))" + +# ── 3. A dead entry is reported, not silently ignored ────────────────────── +# +# Otherwise a typo (".ixxx") is indistinguishable from "this project has none +# yet": the build succeeds and the key does nothing. +cat > mcpp.toml <<'EOF' +[package] +name = "gshape" +version = "0.1.0" + +[build] +module_extensions = [".ixx", ".nosuchext"] +EOF +"$MCPP" build > b4.log 2>&1 || { cat b4.log; echo "FAIL: build with a dead entry"; exit 1; } +grep -q "nosuchext" b4.log || { + cat b4.log; echo "FAIL: a module_extensions entry matching nothing was not reported"; exit 1; } +echo " ok: a dead module_extensions entry is reported" + +# ── 4. A reserved extension is a hard error, not a warning ───────────────── +# +# Claiming `.c` would route C files to the C++ module rule and fail somewhere +# that names neither the file nor the key. +cat > mcpp.toml <<'EOF' +[package] +name = "gshape" +version = "0.1.0" + +[build] +module_extensions = [".c"] +EOF +if "$MCPP" build > b5.log 2>&1; then + cat b5.log; echo "FAIL: [build] module_extensions = [\".c\"] was accepted"; exit 1 +fi +grep -q "module_extensions" b5.log || { + cat b5.log; echo "FAIL: the error does not name the offending key"; exit 1; } +echo " ok: claiming a non-module extension is refused, and the error names the key" + +echo "OK" diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index f94b2340..b3da110e 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -354,11 +354,41 @@ TEST(BuildDirectives, FoldIsIdempotentOnIncludeDirs) { // ── Run bound ────────────────────────────────────────────────────────────── -TEST(BuildDirectives, RunTimeoutDefaultsToABoundAndIsOverridable) { +// T-3. The precedence is a PURE function of two optionals, which is the whole +// reason `run_timeout` was split from `env_timeout_override`: this table can +// be walked without touching the process environment. +// +// Nine rows, and the two that matter most are the `nullopt` vs `0` pairs. +// With a plain `int` those two would be the same value, and since 0 MEANS "no +// limit", every project that never mentions the key would silently lose its +// bound. That is why both levels are optionals all the way down. +TEST(BuildDirectives, RunTimeoutPrecedenceIsEnvThenManifestThenDefault) { + using ms = std::chrono::milliseconds; + constexpr auto def = ms(dirs::kDefaultRunTimeoutSecs * 1000); + const std::optional unset; + + // env, manifest, expected + struct Row { std::optional env, manifest; ms want; }; + const Row rows[] = { + { unset, unset, def }, // neither: the built-in bound + { unset, 1800, ms(1800'000) },// manifest alone + { unset, 0, ms(0) }, // manifest asks for no bound + { 30, unset, ms(30'000)}, // env alone + { 30, 1800, ms(30'000)}, // env WINS over the manifest + { 0, 1800, ms(0) }, // env 0 wins too — "no bound, now" + { 30, 0, ms(30'000)}, // ... and in the other direction + { 0, unset, ms(0) }, + { 1, 1, ms(1'000) }, + }; + for (auto const& r : rows) { + EXPECT_EQ(dirs::run_timeout(r.env, r.manifest), r.want) + << "env=" << (r.env ? std::to_string(*r.env) : "unset") + << " manifest=" << (r.manifest ? std::to_string(*r.manifest) : "unset"); + } + // Default: bounded. An unbounded build program is how a build hangs with // no diagnostic at all. - EXPECT_GT(dirs::run_timeout().count(), 0); - EXPECT_EQ(dirs::run_timeout().count(), dirs::kDefaultRunTimeoutSecs * 1000); + EXPECT_GT(dirs::run_timeout(unset, unset).count(), 0); } // ── Glob inputs (#359) ───────────────────────────────────────────────────── diff --git a/tests/unit/test_build_stage.cpp b/tests/unit/test_build_stage.cpp index 192c061b..3768c7ae 100644 --- a/tests/unit/test_build_stage.cpp +++ b/tests/unit/test_build_stage.cpp @@ -1,6 +1,7 @@ #include import std; +import mcpp.source_kind; import mcpp.build.stage; using namespace mcpp::build::stage; diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index c032e491..40a4532b 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -1,6 +1,7 @@ #include import std; +import mcpp.source_kind; import mcpp.build.compile_commands; import mcpp.build.flags; import mcpp.build.plan; diff --git a/tests/unit/test_configure.cpp b/tests/unit/test_configure.cpp index 78ca5c02..4c5df777 100644 --- a/tests/unit/test_configure.cpp +++ b/tests/unit/test_configure.cpp @@ -1,6 +1,7 @@ #include import std; +import mcpp.source_kind; import mcpp.build.configure; import mcpp.build.plan; import mcpp.toolchain.model; diff --git a/tests/unit/test_modgraph.cpp b/tests/unit/test_modgraph.cpp index d74051a5..72234374 100644 --- a/tests/unit/test_modgraph.cpp +++ b/tests/unit/test_modgraph.cpp @@ -6,6 +6,7 @@ import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.modgraph.validate; import mcpp.manifest; +import mcpp.source_kind; using namespace mcpp::modgraph; @@ -36,7 +37,7 @@ TEST(Scanner, ProvidesAndRequires) { "import bar;\n" "export int answer();\n"); - auto u = scan_file(dir / "src" / "foo.cppm", "pkg"); + auto u = scan_file(dir / "src" / "foo.cppm", "pkg", mcpp::builtin_extension_table()); ASSERT_TRUE(u.has_value()) << u.error().format(); ASSERT_TRUE(u->provides.has_value()); EXPECT_EQ(u->provides->logicalName, "foo"); @@ -64,7 +65,7 @@ TEST(Scanner, IgnoresImportsInsideRawStringLiteral) { "import bar;\n" // a real import AFTER the raw string "export void f();\n"); - auto u = scan_file(dir / "src" / "gen.cppm", "pkg"); + auto u = scan_file(dir / "src" / "gen.cppm", "pkg", mcpp::builtin_extension_table()); ASSERT_TRUE(u.has_value()) << u.error().format(); ASSERT_TRUE(u->provides.has_value()); EXPECT_EQ(u->provides->logicalName, "gen"); @@ -88,7 +89,7 @@ TEST(Scanner, IgnoresImportInsideSingleLineRawString) { "const char* s = R\"(import nope;)\";\n" "import real;\n"); - auto u = scan_file(dir / "src" / "one.cppm", "pkg"); + auto u = scan_file(dir / "src" / "one.cppm", "pkg", mcpp::builtin_extension_table()); ASSERT_TRUE(u.has_value()) << u.error().format(); ASSERT_EQ(u->requires_.size(), 1u); EXPECT_EQ(u->requires_[0].logicalName, "real"); @@ -111,7 +112,7 @@ TEST(Scanner, AssemblySourcesSkipModuleScan) { ".text\n.globl asm_copy\nasm_copy:\n ret\n"); for (auto name : { "simd.asm", "copy.S" }) { - auto u = scan_file(dir / "src" / name, "pkg"); + auto u = scan_file(dir / "src" / name, "pkg", mcpp::builtin_extension_table()); ASSERT_TRUE(u.has_value()) << u.error().format(); EXPECT_FALSE(u->provides.has_value()) << name; EXPECT_TRUE(u->requires_.empty()) << name; @@ -377,7 +378,7 @@ TEST(Scanner, PartitionImportFromPrimaryInterface) { write(dir / "src" / "foo.cppm", "export module foo;\n" "import :tls;\n"); - auto u = scan_file(dir / "src" / "foo.cppm", "pkg"); + auto u = scan_file(dir / "src" / "foo.cppm", "pkg", mcpp::builtin_extension_table()); ASSERT_TRUE(u.has_value()) << u.error().format(); ASSERT_EQ(u->requires_.size(), 1u); EXPECT_EQ(u->requires_[0].logicalName, "foo:tls"); @@ -393,7 +394,7 @@ TEST(Scanner, PartitionImportFromAnotherPartition) { "export module foo:http;\n" "import :tls;\n" "import :socket;\n"); - auto u = scan_file(dir / "src" / "http.cppm", "pkg"); + auto u = scan_file(dir / "src" / "http.cppm", "pkg", mcpp::builtin_extension_table()); ASSERT_TRUE(u.has_value()) << u.error().format(); ASSERT_TRUE(u->provides.has_value()); EXPECT_EQ(u->provides->logicalName, "foo:http"); @@ -410,7 +411,7 @@ TEST(Scanner, PartitionImportWithDottedModuleName) { write(dir / "src" / "http.cppm", "export module mcpplibs.tinyhttps:http;\n" "import :tls;\n"); - auto u = scan_file(dir / "src" / "http.cppm", "pkg"); + auto u = scan_file(dir / "src" / "http.cppm", "pkg", mcpp::builtin_extension_table()); ASSERT_TRUE(u.has_value()) << u.error().format(); ASSERT_EQ(u->requires_.size(), 1u); EXPECT_EQ(u->requires_[0].logicalName, "mcpplibs.tinyhttps:tls"); @@ -425,7 +426,7 @@ TEST(Scanner, RejectsConditionalImport) { "import x;\n" "#endif\n" "int main(){}"); - auto r = scan_file(dir / "main.cpp", "pkg"); + auto r = scan_file(dir / "main.cpp", "pkg", mcpp::builtin_extension_table()); EXPECT_FALSE(r.has_value()); EXPECT_NE(r.error().message.find("conditional"), std::string::npos); std::filesystem::remove_all(dir); @@ -437,7 +438,7 @@ TEST(Scanner, RejectsHeaderUnit) { "import std;\n" "import \"x.h\";\n" "int main(){}"); - auto r = scan_file(dir / "main.cpp", "pkg"); + auto r = scan_file(dir / "main.cpp", "pkg", mcpp::builtin_extension_table()); EXPECT_FALSE(r.has_value()); EXPECT_NE(r.error().message.find("header units"), std::string::npos); std::filesystem::remove_all(dir); @@ -449,7 +450,7 @@ TEST(Scanner, ObjectiveCSourceIsCLike) { "import Cocoa;\n" "int answer(void) { return 42; }\n"); - auto u = scan_file(dir / "src" / "window.m", "pkg"); + auto u = scan_file(dir / "src" / "window.m", "pkg", mcpp::builtin_extension_table()); ASSERT_TRUE(u.has_value()) << u.error().format(); EXPECT_FALSE(u->provides.has_value()); EXPECT_TRUE(u->requires_.empty()); diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 2ae7014b..d388b6f8 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -1,6 +1,7 @@ #include import std; +import mcpp.source_kind; import mcpp.build.compile_commands; import mcpp.build.flags; import mcpp.build.ninja; @@ -56,6 +57,7 @@ TEST(NinjaBackend, ObjectiveCSourceUsesCObjectRuleAndCFlags) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/cocoa.m", + .kind = mcpp::SourceKind::C, .object = "obj/cocoa.o", .packageName = "objc_rule_test", .packageCflags = {"-DOBJ_C_BUILD=1"}, @@ -122,6 +124,7 @@ TEST(NinjaBackend, UsesPackageCppStandardForCxxFlags) { plan.cppStandardFlag = "-std=c++26"; plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "cpp26_test", }); @@ -142,6 +145,7 @@ TEST(NinjaBackend, CompileCommandsUsesSameCppStandard) { plan.cppStandardFlag = "-std=c++26"; plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "cpp26_test", }); @@ -211,6 +215,7 @@ TEST(NinjaBackend, LocalIncludeDirsAfterEmitIdirafterAppendedAfterDashI) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "after_test", .localIncludeDirs = {"/dep/include"}, @@ -244,6 +249,7 @@ TEST(NinjaBackend, MsvcDialectEmitsIncludeDirsAfterAsTrailingSlashI) { plan.toolchain.targetTriple = "x86_64-pc-windows-msvc"; plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "after_test", .localIncludeDirs = {"/dep/include"}, @@ -279,6 +285,7 @@ TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "spaced", .localIncludeDirs = {"/opt/my dep/include"}, @@ -349,6 +356,7 @@ TEST(NinjaBackend, NasmUnitEmitsIncludeDirsAfterAsPlainDashI) { plan.nasmPath = "/usr/bin/nasm"; plan.compileUnits.push_back({ .source = "src/scale.asm", + .kind = mcpp::SourceKind::NasmAsm, .object = "obj/scale.asm.o", .packageName = "after_test", .localIncludeDirs = {"/dep/x86"}, @@ -409,6 +417,7 @@ TEST(NinjaBackend, GasSourceUsesAsmObjectRule) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/copy.S", + .kind = mcpp::SourceKind::GasAsm, .object = "obj/copy.S.o", .packageName = "asm_rule_test", // Only the -D/-U/-I subset may reach the assembler: -std/-O/-w on an @@ -441,6 +450,7 @@ TEST(NinjaBackend, NasmSourceUsesNasmRuleWithDerivedFormat) { plan.nasmFormat = "elf64"; plan.compileUnits.push_back({ .source = "src/simd.asm", + .kind = mcpp::SourceKind::NasmAsm, .object = "obj/simd.asm.o", .packageName = "nasm_rule_test", .packageCflags = {"-DHAVE_AVX2=1", "-O2"}, @@ -461,6 +471,7 @@ TEST(NinjaBackend, NoAsmRulesWithoutAsmSources) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "plain_test", }); @@ -477,11 +488,13 @@ TEST(NinjaBackend, CompileCommandsSkipNasmAndCoverGas) { plan.nasmFormat = "elf64"; plan.compileUnits.push_back({ .source = "src/simd.asm", + .kind = mcpp::SourceKind::NasmAsm, .object = "obj/simd.asm.o", .packageName = "cdb_test", }); plan.compileUnits.push_back({ .source = "src/copy.S", + .kind = mcpp::SourceKind::GasAsm, .object = "obj/copy.S.o", .packageName = "cdb_test", }); @@ -507,6 +520,7 @@ TEST(NinjaBackend, QuotesFlagValueWithSpace) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.c", + .kind = mcpp::SourceKind::C, .object = "obj/main.o", .packageName = "quote_test", .packageCflags = {"-DT=long long"}, @@ -531,6 +545,7 @@ TEST(NinjaBackend, PlainFlagsPassThroughUnquoted) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "plain_flag_test", .packageCxxflags = {"-DFOO=1", "-O2"}, @@ -553,6 +568,7 @@ TEST(NinjaBackend, LinkFlagsAreNotReQuoted) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "rpath_test", }); @@ -585,6 +601,7 @@ TEST(NinjaBackend, RawMultiTokenFlagIsNotQuoted) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.c", + .kind = mcpp::SourceKind::C, .object = "obj/main.o", .packageName = "include_flag_test", .packageCflags = {"-include mcpp_lua_platform_config.h"}, @@ -642,6 +659,7 @@ TEST(NinjaBackend, RootPackageCxxflagsAreEmittedOncePerUnit) { plan.manifest.buildConfig.cxxflags = {"-DROOT_FLAG=1"}; plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "root_flag_test", .packageCxxflags = {"-DROOT_FLAG=1"}, @@ -667,6 +685,7 @@ TEST(NinjaBackend, ClangScanRuleWritesViaDashOWithoutShellRedirection) { plan.scanDepsPath = "/usr/bin/clang-scan-deps"; plan.compileUnits.push_back({ .source = "src/m.cppm", + .kind = mcpp::SourceKind::ModuleInterface, .object = "obj/m.o", .packageName = "objc_rule_test", .providesModule = "m", @@ -698,6 +717,7 @@ TEST(NinjaBackend, NoRuleWrapsItsCommandInCmdSlashC) { } plan.compileUnits.push_back({ .source = "src/m.cppm", + .kind = mcpp::SourceKind::ModuleInterface, .object = "obj/m.o", .packageName = "objc_rule_test", .providesModule = "m", @@ -722,12 +742,14 @@ TEST(NinjaBackend, CompileAndScanRulesRouteFlagsThroughRspfileUnderMsvcDialect) plan.toolchain.binaryPath = "cl.exe"; plan.compileUnits.push_back({ .source = "src/m.cppm", + .kind = mcpp::SourceKind::ModuleInterface, .object = "obj/m.o", .packageName = "objc_rule_test", .providesModule = "m", }); plan.compileUnits.push_back({ .source = "src/a.c", + .kind = mcpp::SourceKind::C, .object = "obj/a.o", .packageName = "objc_rule_test", }); @@ -783,6 +805,7 @@ TEST(NinjaBackend, CompileRulesStayInlineOnPosixDrivers) { auto plan = minimal_plan(); // GCC → gnu dialect, non-msvc deps plan.compileUnits.push_back({ .source = "src/a.c", + .kind = mcpp::SourceKind::C, .object = "obj/a.o", .packageName = "objc_rule_test", }); @@ -841,11 +864,13 @@ TEST(NinjaBackend, CAndAsmRulesAlsoTrackHeaderDeps) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/a.c", + .kind = mcpp::SourceKind::C, .object = "obj/a.o", .packageName = "objc_rule_test", }); plan.compileUnits.push_back({ .source = "src/b.S", + .kind = mcpp::SourceKind::GasAsm, .object = "obj/b.o", .packageName = "objc_rule_test", }); @@ -882,11 +907,13 @@ TEST(NinjaBackend, LowercaseAsmHasNoDepfileAndItsOwnRule) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/upper.S", + .kind = mcpp::SourceKind::GasAsm, .object = "obj/upper.o", .packageName = "objc_rule_test", }); plan.compileUnits.push_back({ .source = "src/lower.s", + .kind = mcpp::SourceKind::GasAsm, .object = "obj/lower.o", .packageName = "objc_rule_test", }); @@ -1055,6 +1082,7 @@ TEST(NinjaBackend, CachedUnitEmitsStageEdgeAndNoCompileEdge) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "/store/dep/src/dep.cppm", + .kind = mcpp::SourceKind::ModuleInterface, .object = "obj/dep.m.o", .packageName = "dep", .providesModule = "dep", @@ -1088,6 +1116,7 @@ TEST(NinjaBackend, CachedStageEdgesUseSizeVerification) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "/store/dep/src/a.c", + .kind = mcpp::SourceKind::C, .object = "obj/a.o", .packageName = "dep", .servedFromCache = true, @@ -1105,6 +1134,7 @@ TEST(NinjaBackend, UncachedUnitsStillCompileAlongsideCachedOnes) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "/store/dep/src/dep.c", + .kind = mcpp::SourceKind::C, .object = "obj/dep.o", .packageName = "dep", .servedFromCache = true, @@ -1112,6 +1142,7 @@ TEST(NinjaBackend, UncachedUnitsStillCompileAlongsideCachedOnes) { }); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "objc_rule_test", }); @@ -1129,6 +1160,7 @@ TEST(NinjaBackend, NoStageEdgesWithoutCachedUnits) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "objc_rule_test", }); @@ -1146,6 +1178,7 @@ TEST(NinjaBackend, CachedUnitWithoutCachedObjectPathIsStillCompiled) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "objc_rule_test", .servedFromCache = true, @@ -1164,6 +1197,7 @@ TEST(NinjaBackend, CachedUnitsStillAppearInCompileCommands) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "/store/dep/src/dep.c", + .kind = mcpp::SourceKind::C, .object = "obj/dep.o", .packageName = "dep", .servedFromCache = true, @@ -1171,6 +1205,7 @@ TEST(NinjaBackend, CachedUnitsStillAppearInCompileCommands) { }); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "objc_rule_test", }); @@ -1209,6 +1244,7 @@ TEST(NinjaBackend, NonCachedEdgesOrderAfterEveryStagedArtifact) { // A cached package with a primary interface and a partition. plan.compileUnits.push_back({ .source = "/store/dep/src/dep.cppm", + .kind = mcpp::SourceKind::ModuleInterface, .object = "obj/dep.m.o", .packageName = "dep", .providesModule = "dep", @@ -1218,6 +1254,7 @@ TEST(NinjaBackend, NonCachedEdgesOrderAfterEveryStagedArtifact) { }); plan.compileUnits.push_back({ .source = "/store/dep/src/part.cppm", + .kind = mcpp::SourceKind::ModuleInterface, .object = "obj/part.m.o", .packageName = "dep", .providesModule = "dep:part", @@ -1228,6 +1265,7 @@ TEST(NinjaBackend, NonCachedEdgesOrderAfterEveryStagedArtifact) { // The consumer, which imports only the primary module. plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "objc_rule_test", .imports = {"dep"}, @@ -1266,6 +1304,7 @@ TEST(NinjaBackend, NoStagedPhonyWhenNothingIsCached) { auto plan = minimal_plan(); plan.compileUnits.push_back({ .source = "src/main.cpp", + .kind = mcpp::SourceKind::Cxx, .object = "obj/main.o", .packageName = "objc_rule_test", }); diff --git a/tests/unit/test_source_kind.cpp b/tests/unit/test_source_kind.cpp new file mode 100644 index 00000000..2e5e1f72 --- /dev/null +++ b/tests/unit/test_source_kind.cpp @@ -0,0 +1,227 @@ +#include + +import std; +import mcpp.source_kind; +import mcpp.toolchain.model; +import mcpp.toolchain.detect; + +using mcpp::SourceKind; + +// ─── T-1. classify() ─────────────────────────────────────────────────────── + +TEST(SourceKind, BuiltInTableIsCppmOnly) { + // Load-bearing, not conservatism. Widening the built-in set widens the + // DEFAULT source glob with it, so a published package with a vendored + // MSVC-only `.ixx` under src/ would start compiling it on the next mcpp + // upgrade — a break its author cannot fix, because that version's tarball + // has already shipped. + auto t = mcpp::builtin_extension_table(); + EXPECT_EQ(t.moduleInterface, (std::vector{".cppm"})); + EXPECT_EQ(mcpp::classify("src/a.ixx", t), SourceKind::Other); + EXPECT_EQ(mcpp::classify("src/a.ccm", t), SourceKind::Other); + EXPECT_EQ(mcpp::classify("src/a.cxxm", t), SourceKind::Other); +} + +TEST(SourceKind, ClassifiesEveryBuiltInRole) { + auto t = mcpp::builtin_extension_table(); + struct Row { const char* path; SourceKind want; }; + const Row rows[] = { + {"src/a.cppm", SourceKind::ModuleInterface}, + {"src/a.cpp", SourceKind::Cxx}, + {"src/a.cc", SourceKind::Cxx}, + {"src/a.cxx", SourceKind::Cxx}, + {"src/a.mm", SourceKind::Cxx}, + {"src/a.c", SourceKind::C}, + {"src/a.m", SourceKind::C}, + {"src/a.S", SourceKind::GasAsm}, + {"src/a.s", SourceKind::GasAsm}, + {"src/a.asm", SourceKind::NasmAsm}, + {"src/a.h", SourceKind::Header}, + {"src/a.hpp", SourceKind::Header}, + {"src/a.hh", SourceKind::Header}, + {"src/a.hxx", SourceKind::Header}, + {"src/a.txt", SourceKind::Other}, + {"src/README", SourceKind::Other}, + }; + for (auto const& r : rows) + EXPECT_EQ(mcpp::classify(r.path, t), r.want) << r.path; +} + +TEST(SourceKind, CaseIsNeverFolded) { + // `.S` and `.s` are DIFFERENT LANGUAGES here — `.S` goes through the C + // preprocessor, `.s` does not. Any normalization that lowercases would + // silently merge them, so extensions are compared literally. + auto t = mcpp::builtin_extension_table(); + EXPECT_EQ(mcpp::classify("a.S", t), SourceKind::GasAsm); + EXPECT_EQ(mcpp::classify("a.s", t), SourceKind::GasAsm); + EXPECT_EQ(mcpp::normalize_extension(".IXX"), ".IXX"); + EXPECT_EQ(mcpp::classify("a.CPPM", t), SourceKind::Other); +} + +TEST(SourceKind, ConfiguredExtensionsAreAdditiveAndNormalized) { + // `ixx` without a dot, a duplicate, and one already built in. + auto t = mcpp::extension_table_for( + std::vector{"ixx", ".ccm", ".ccm", ".cppm", " .cxxm "}); + EXPECT_EQ(t.moduleInterface, + (std::vector{".cppm", ".ixx", ".ccm", ".cxxm"})); + EXPECT_EQ(mcpp::classify("src/a.ixx", t), SourceKind::ModuleInterface); + EXPECT_EQ(mcpp::classify("src/a.ccm", t), SourceKind::ModuleInterface); + EXPECT_EQ(mcpp::classify("src/a.cxxm", t), SourceKind::ModuleInterface); + // Built-in roles are untouched by the addition. + EXPECT_EQ(mcpp::classify("src/a.cpp", t), SourceKind::Cxx); + EXPECT_EQ(mcpp::classify("src/a.c", t), SourceKind::C); +} + +TEST(SourceKind, ReservedExtensionsAreRejectedNotSilentlyAccepted) { + // Declaring `.c` a module interface has no legitimate use and would route + // C files to the C++ module rule, failing somewhere that names neither the + // file nor the key. A hard error, not a warning. + for (auto const* bad : {".cpp", ".cc", ".cxx", ".c", ".m", ".mm", + ".h", ".hpp", ".hh", ".hxx", ".S", ".s", ".asm"}) { + auto err = mcpp::validate_module_extensions(std::vector{bad}); + ASSERT_TRUE(err.has_value()) << bad; + EXPECT_NE(err->find(bad), std::string::npos) << *err; + } + // An unusual-but-free extension is fine: mcpp tells the compiler what the + // unit is rather than relying on the driver to recognize the suffix. + EXPECT_FALSE(mcpp::validate_module_extensions( + std::vector{".mpp", ".cppmi"}).has_value()); +} + +TEST(SourceKind, ValidationRejectsNonExtensionShapes) { + for (auto const* bad : {"", " ", ".", "src/*.ixx", "a/b.ixx", + "foo.bar.ixx"}) { + EXPECT_TRUE(mcpp::validate_module_extensions( + std::vector{bad}).has_value()) << "accepted: " << bad; + } +} + +TEST(SourceKind, PredicatesAgreeWithTheKind) { + EXPECT_TRUE(mcpp::produces_bmi(SourceKind::ModuleInterface)); + EXPECT_TRUE(mcpp::links_unconditionally(SourceKind::ModuleInterface)); + for (auto k : {SourceKind::Cxx, SourceKind::C, SourceKind::GasAsm, + SourceKind::NasmAsm, SourceKind::Header, SourceKind::Other}) { + EXPECT_FALSE(mcpp::produces_bmi(k)); + EXPECT_FALSE(mcpp::links_unconditionally(k)); + } + + // Scan-exempt: cannot contain import/module, so no P1689 scan. + EXPECT_TRUE(mcpp::is_scan_exempt(SourceKind::C)); + EXPECT_TRUE(mcpp::is_scan_exempt(SourceKind::GasAsm)); + EXPECT_TRUE(mcpp::is_scan_exempt(SourceKind::NasmAsm)); + EXPECT_FALSE(mcpp::is_scan_exempt(SourceKind::ModuleInterface)); + EXPECT_FALSE(mcpp::is_scan_exempt(SourceKind::Cxx)); + + // The fast path's question: could editing this change the graph's SHAPE? + // Assembly is absent on purpose — it has no import and no scanned include + // graph, so editing one changes its object (ninja tracks that) and nothing + // else. A NEW assembly file is a different question, answered by + // glob_inputs_stale. + EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::ModuleInterface)); + EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::Cxx)); + EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::C)); + EXPECT_TRUE(mcpp::affects_graph_shape(SourceKind::Header)); + EXPECT_FALSE(mcpp::affects_graph_shape(SourceKind::GasAsm)); + EXPECT_FALSE(mcpp::affects_graph_shape(SourceKind::Other)); +} + +TEST(SourceKind, DefaultGlobsAreDerivedFromTheTable) { + // Two hand-maintained copies of this list used to exist and had already + // drifted apart (the staging fallback was missing all three assembly + // extensions). Deriving them is what keeps a declared extension from being + // classified but never FOUND. + auto builtin = mcpp::default_source_globs(mcpp::builtin_extension_table()); + EXPECT_EQ(builtin, (std::vector{ + "src/**/*.cppm", "src/**/*.cpp", "src/**/*.cc", "src/**/*.c", + "src/**/*.S", "src/**/*.s", "src/**/*.asm"})); + + auto wide = mcpp::default_source_globs( + mcpp::extension_table_for(std::vector{".ixx"})); + EXPECT_EQ(wide.front(), "src/**/*.cppm"); + EXPECT_NE(std::ranges::find(wide, "src/**/*.ixx"), wide.end()); + + EXPECT_EQ(mcpp::default_source_globs_note(mcpp::builtin_extension_table()), + "sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}]"); +} + +// ─── T-2. Object naming ──────────────────────────────────────────────────── + +TEST(SourceKind, ObjectNamingIsMonotoneAndCollisionFree) { + // Historical names are FROZEN: an object's name is part of the internal + // layout of a global cache entry, so renaming one without changing the + // cache key produces a HIT on an entry that lacks the object the link then + // asks for — a missing `.o` at link time, not a cache miss. + EXPECT_EQ(mcpp::object_naming_for("foo.cpp"), mcpp::ObjectNaming::Stem); + EXPECT_EQ(mcpp::object_naming_for("foo.cc"), mcpp::ObjectNaming::Stem); + EXPECT_EQ(mcpp::object_naming_for("foo.c"), mcpp::ObjectNaming::Stem); + EXPECT_EQ(mcpp::object_naming_for("foo.cppm"), mcpp::ObjectNaming::StemDotM); + EXPECT_EQ(mcpp::object_naming_for("foo.S"), mcpp::ObjectNaming::FullFilename); + EXPECT_EQ(mcpp::object_naming_for("foo.asm"), mcpp::ObjectNaming::FullFilename); + + // Everything a project can ADD gets the collision-proof form, so a new + // extension can never change an existing object's name. + for (auto const* ext : {"foo.ixx", "foo.ccm", "foo.cxxm", "foo.mpp"}) + EXPECT_EQ(mcpp::object_naming_for(ext), mcpp::ObjectNaming::FullFilename) + << ext; +} + +TEST(SourceKind, SameStemAcrossModuleExtensionsNeverCollides) { + // mcpp#272 proposed giving all four module extensions the `.m` prefix, + // which makes `foo.cppm` and `foo.ccm` BOTH `foo.m.o`. The per-package + // collision prefix cannot help: it mirrors the source DIRECTORY, and these + // two are in the same one. + auto name = [](std::string_view f) { + switch (mcpp::object_naming_for(f)) { + case mcpp::ObjectNaming::Stem: + return std::filesystem::path(f).stem().string() + ".o"; + case mcpp::ObjectNaming::StemDotM: + return std::filesystem::path(f).stem().string() + ".m.o"; + case mcpp::ObjectNaming::FullFilename: + return std::filesystem::path(f).filename().string() + ".o"; + } + return std::string{}; + }; + std::set seen; + for (auto const* f : {"foo.cppm", "foo.ccm", "foo.cxxm", "foo.ixx", + "foo.cpp", "foo.S", "foo.s", "foo.asm"}) { + auto n = name(f); + EXPECT_TRUE(seen.insert(n).second) << "collision: " << f << " -> " << n; + } + + // ⚠️ KNOWN GAP, deliberately pinned rather than asserted away: `foo.c` and + // `foo.cpp` in one directory have always shared `foo.o`. Fixing it renames + // every C object, which is exactly the cache-layout change described + // above and needs a cache-key revision to be safe. Tracked separately — + // this assertion documents the gap so nobody "fixes" the test instead. + EXPECT_EQ(name("foo.c"), name("foo.cpp")) + << "known gap closed? update this test and bump the cache key"; +} + +// ─── T-8. The module-interface language flag ─────────────────────────────── + +TEST(SourceKind, ModuleInterfaceLangFlagIsPerCompilerAndNotInterchangeable) { + // Measured 2026-08-11: `-x c++-module` makes GCC exit with "language + // c++-module not recognized", and `-x c++` makes Clang emit a 174-byte + // stub instead of a module BMI. The spelling is a property of the compiler + // FAMILY, not of the command dialect — gcc and clang share the gnu dialect. + auto traits_for = [](mcpp::toolchain::CompilerId id) { + mcpp::toolchain::Toolchain tc; + tc.compiler = id; + return mcpp::toolchain::bmi_traits(tc); + }; + EXPECT_EQ(traits_for(mcpp::toolchain::CompilerId::GCC).moduleInterfaceLangFlag, + " -x c++"); + EXPECT_EQ(traits_for(mcpp::toolchain::CompilerId::Clang).moduleInterfaceLangFlag, + " -x c++-module"); + EXPECT_EQ(traits_for(mcpp::toolchain::CompilerId::MSVC).moduleInterfaceLangFlag, + " /interface /TP"); + + // Never empty: mcpp tells the driver EVERY time rather than tracking which + // suffix each driver version happens to know. That table would expire with + // every compiler release, and getting it wrong is silent — Clang hands an + // unrecognized suffix to the linker, warns, and exits 0 with no BMI. + for (auto id : {mcpp::toolchain::CompilerId::GCC, + mcpp::toolchain::CompilerId::Clang, + mcpp::toolchain::CompilerId::MSVC}) + EXPECT_FALSE(traits_for(id).moduleInterfaceLangFlag.empty()); +} From 230fc0c98ade33f5639291a4919b73ee81f5b7ff Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:12:35 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(platform):=20=E6=9C=AA=E6=8D=95?= =?UTF-8?q?=E8=8E=B7=E7=9A=84=E6=9C=89=E7=95=8C=E8=BF=90=E8=A1=8C=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E7=BB=A7=E6=89=BF=20stdio,=E8=80=8C=E4=B8=8D=E6=98=AF?= =?UTF-8?q?=E5=85=88=E7=BC=93=E5=86=B2=E5=90=8E=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自审发现的真回归。`run_exec_deadline` 是 `mcpp test` 非 JSON 模式跑测试二进制 的路径,原本继承调用方的 stdio —— 输出实时出现,且子进程的 stdout 是终端。 把它改成「捕获后在结束时一次性回放」有两个后果: 1. 长测试的输出全部憋到退出才出现,恰好抵消了 `mcpp test` 可观察性那一整 轮工作(「只有子进程输出、mcpp 一行没有」正是缓冲问题的指纹); 2. 子进程的 stdout 变成管道而非终端,gtest 之类会静默关掉彩色输出。 两侧启动器现在共用一条契约:`sink == nullptr` 表示「不捕获」,子进程直接继承 调用方的 stdio,但**仍然有界**。POSIX 侧不建管道也不设 dup2 file action; Windows 侧不设 STARTF_USESTDHANDLES,也不加 CREATE_NO_WINDOW(未捕获的运行 本来就是要给人看的)。`dispatch_bounded` 多一个 capture 形参把这个选择传下去。 --- src/platform/process.cppm | 31 ++++++++----- src/platform/unix/bounded_process.cppm | 55 ++++++++++++++--------- src/platform/windows/bounded_process.cppm | 39 +++++++++++----- 3 files changed, 81 insertions(+), 44 deletions(-) diff --git a/src/platform/process.cppm b/src/platform/process.cppm index c31ecd4c..3b13092f 100644 --- a/src/platform/process.cppm +++ b/src/platform/process.cppm @@ -582,11 +582,15 @@ struct BoundedOutcome { std::string output; }; +// `capture == false` runs the child on the caller's stdio: live output, and a +// real terminal for anything that checks. `run_exec_deadline` needs that; the +// capturing variants need the pipe. BoundedOutcome dispatch_bounded( const std::vector& argv, const std::vector>& extraEnv, std::string_view cwd, - std::chrono::milliseconds deadline) + std::chrono::milliseconds deadline, + bool capture) { BoundedOutcome outcome; @@ -603,10 +607,14 @@ BoundedOutcome dispatch_bounded( const char* cwdArg = cwdStore.empty() ? nullptr : cwdStore.c_str(); const auto ms = static_cast(deadline.count()); - // One sink for both, appending into the outcome's own buffer. - const auto sink = +[](void* ctx, const char* data, unsigned long len) { - static_cast(ctx)->append(data, len); - }; + // One sink for both, appending into the outcome's own buffer. Null when the + // caller wants the child on its own stdio. + using Sink = void (*)(void*, const char*, unsigned long); + const Sink sink = capture + ? +[](void* ctx, const char* data, unsigned long len) { + static_cast(ctx)->append(data, len); + } + : nullptr; if constexpr (mcpp::platform::is_windows) { const auto cmd = windows_command_from_argv(argv); @@ -639,12 +647,13 @@ int run_exec_deadline(const std::vector& argv, if (deadline.count() <= 0) return run_exec(argv, extraEnv); if (argv.empty()) return 127; - auto r = dispatch_bounded(argv, extraEnv, {}, deadline); + // capture=false: identical stdio behaviour to `run_exec` — the child writes + // straight to our terminal as it goes. `mcpp test`'s non-JSON path runs + // test binaries through here, and buffering their output until exit would + // undo the observability work that path exists for (and would hide gtest's + // colors by making its stdout a pipe). + auto r = dispatch_bounded(argv, extraEnv, {}, deadline, /*capture=*/false); if (!r.supported) return run_exec(argv, extraEnv); - // `run_exec` streams to the terminal; the bounded launchers capture. The - // output is replayed here rather than dropped — a bounded run that must - // ALSO stream live has no implementation and, today, no caller. - if (!r.output.empty()) std::fputs(r.output.c_str(), stdout); if (timed_out) *timed_out = r.timed_out; return r.exit_code; } @@ -661,7 +670,7 @@ RunResult capture_exec_deadline( RunResult result; if (argv.empty()) { result.exit_code = 127; return result; } - auto r = dispatch_bounded(argv, extraEnv, cwd, deadline); + auto r = dispatch_bounded(argv, extraEnv, cwd, deadline, /*capture=*/true); // `supported == false` means the child COULD NOT BE SPAWNED — not that it // ran and failed. Reporting those the same way would hide a launcher // problem behind a child's exit code, so fall back to the untimed path and diff --git a/src/platform/unix/bounded_process.cppm b/src/platform/unix/bounded_process.cppm index 6a1d066c..75b49091 100644 --- a/src/platform/unix/bounded_process.cppm +++ b/src/platform/unix/bounded_process.cppm @@ -72,8 +72,12 @@ using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); // `cwd` may be null. A non-positive `deadlineMs` is rejected with // supported=false: "no bound" belongs on the caller's untimed path. // -// When `sink` is null the output is discarded but the child is still bounded — -// that is the `run_exec_deadline` shape. +// A NULL `sink` means "do not capture": the child INHERITS the caller's stdio +// and is still bounded. That is not an optimization — it is the +// `run_exec_deadline` contract. Routing an uncaptured run through a pipe would +// (a) delay every line until the child exits, which is the opposite of what a +// bounded `mcpp test` run is for, and (b) make the child's stdout a pipe +// rather than a terminal, so gtest and friends silently drop their colors. DeadlineRun capture_with_deadline(const char* const* argvEntries, unsigned long argvCount, const char* const* envEntries, @@ -149,8 +153,9 @@ DeadlineRun capture_with_deadline(const char* const* argvEntries, cargv.push_back(const_cast(argvEntries[i])); cargv.push_back(nullptr); - int fds[2]; - if (::pipe(fds) != 0) return out; + const bool capture = (sink != nullptr); + int fds[2] = {-1, -1}; + if (capture && ::pipe(fds) != 0) return out; posix_spawn_file_actions_t fa; ::posix_spawn_file_actions_init(&fa); @@ -159,21 +164,26 @@ DeadlineRun capture_with_deadline(const char* const* argvEntries, // silently move where a build program's relative writes go. if (cwd && *cwd) ::posix_spawn_file_actions_addchdir_np(&fa, cwd); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); - ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); - ::posix_spawn_file_actions_addclose(&fa, fds[0]); - ::posix_spawn_file_actions_addclose(&fa, fds[1]); + if (capture) { + ::posix_spawn_file_actions_adddup2(&fa, fds[1], 1); + ::posix_spawn_file_actions_adddup2(&fa, fds[1], 2); + ::posix_spawn_file_actions_addclose(&fa, fds[0]); + ::posix_spawn_file_actions_addclose(&fa, fds[1]); + } + // else: no file actions for stdio at all — the child inherits ours, which + // keeps its output live AND keeps it a terminal. pid_t pid = 0; int sp = ::posix_spawnp(&pid, cargv[0], &fa, nullptr, cargv.data(), envp.data()); ::posix_spawn_file_actions_destroy(&fa); - ::close(fds[1]); - if (sp != 0) { ::close(fds[0]); return out; } + if (capture) ::close(fds[1]); + if (sp != 0) { if (capture) ::close(fds[0]); return out; } // Non-blocking reads so the deadline is still checked while the child is // quiet. A blocking read on a silent, hung child is exactly the hang this // whole mechanism exists to stop. - ::fcntl(fds[0], F_SETFL, ::fcntl(fds[0], F_GETFL, 0) | O_NONBLOCK); + if (capture) + ::fcntl(fds[0], F_SETFL, ::fcntl(fds[0], F_GETFL, 0) | O_NONBLOCK); const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(deadlineMs); @@ -181,22 +191,23 @@ DeadlineRun capture_with_deadline(const char* const* argvEntries, bool killed = false; int status = 0; - for (;;) { + auto drain = [&]() -> bool { + if (!capture) return false; ssize_t n; - bool drained = false; + bool any = false; while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) { - if (sink) sink(ctx, buf.data(), - static_cast(n)); - drained = true; + sink(ctx, buf.data(), static_cast(n)); + any = true; } - if (drained) continue; + return any; + }; + + for (;;) { + if (drain()) continue; pid_t r = ::waitpid(pid, &status, WNOHANG); if (r == pid) { - // Drain the tail: the child is gone, so this terminates. - while ((n = ::read(fds[0], buf.data(), buf.size())) > 0) - if (sink) sink(ctx, buf.data(), - static_cast(n)); + while (drain()) { /* tail — the child is gone, so this ends */ } break; } if (r < 0 && errno != EINTR && errno != ECHILD) break; @@ -209,7 +220,7 @@ DeadlineRun capture_with_deadline(const char* const* argvEntries, struct timespec ts{0, 20'000'000}; // 20ms ::nanosleep(&ts, nullptr); } - ::close(fds[0]); + if (capture) ::close(fds[0]); out.exit_code = normalize_status(status); out.timed_out = killed; diff --git a/src/platform/windows/bounded_process.cppm b/src/platform/windows/bounded_process.cppm index 6a1a141c..a0824a6c 100644 --- a/src/platform/windows/bounded_process.cppm +++ b/src/platform/windows/bounded_process.cppm @@ -76,6 +76,12 @@ struct DeadlineRun { }; // Receives stdout+stderr as it arrives. Called on the calling thread only. +// +// A NULL sink means "do not capture": the child inherits the caller's stdio and +// is still bounded. Same contract as the POSIX peer, and for the same reason — +// an uncaptured bounded run (`run_exec_deadline`) must keep its output LIVE and +// keep the child's stdout a console, or console-detecting children drop their +// colors and every line waits for exit. using OutputSink = void (*)(void* ctx, const char* data, unsigned long len); // `commandLine` is already quoted for CreateProcess (callers pass the output @@ -176,16 +182,20 @@ DeadlineRun capture_with_deadline(const char* commandLine, DeadlineRun out; if (deadlineMs <= 0 || !commandLine || !*commandLine) return out; + const bool capture = (sink != nullptr); + SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof(sa); sa.bInheritHandle = TRUE; Handle readEnd, writeEnd; - if (!::CreatePipe(&readEnd.h, &writeEnd.h, &sa, 0)) return out; - // Only the WRITE end may cross into the child. An inheritable read end - // there would keep the pipe alive past the child's exit and the drain - // below would never see EOF. - if (!::SetHandleInformation(readEnd.h, HANDLE_FLAG_INHERIT, 0)) return out; + if (capture) { + if (!::CreatePipe(&readEnd.h, &writeEnd.h, &sa, 0)) return out; + // Only the WRITE end may cross into the child. An inheritable read end + // there would keep the pipe alive past the child's exit and the drain + // below would never see EOF. + if (!::SetHandleInformation(readEnd.h, HANDLE_FLAG_INHERIT, 0)) return out; + } Handle job; job.h = ::CreateJobObjectA(nullptr, nullptr); @@ -198,11 +208,14 @@ DeadlineRun capture_with_deadline(const char* commandLine, } STARTUPINFOA si{}; - si.cb = sizeof(si); - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdOutput = writeEnd.h; - si.hStdError = writeEnd.h; - si.hStdInput = ::GetStdHandle(STD_INPUT_HANDLE); + si.cb = sizeof(si); + if (capture) { + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = writeEnd.h; + si.hStdError = writeEnd.h; + si.hStdInput = ::GetStdHandle(STD_INPUT_HANDLE); + } + // else: no STARTF_USESTDHANDLES — the child inherits our console. PROCESS_INFORMATION pi{}; std::string cmdBuf(commandLine); // CreateProcessA may modify it @@ -210,9 +223,12 @@ DeadlineRun capture_with_deadline(const char* commandLine, // CREATE_SUSPENDED so the child joins the job BEFORE it can spawn // anything — a grandchild created in that gap would escape the kill. + // CREATE_NO_WINDOW only when capturing: an uncaptured run is meant to be + // seen, and suppressing the console for it would hide the output this + // branch exists to show. BOOL ok = ::CreateProcessA( nullptr, cmdBuf.data(), nullptr, nullptr, /*bInheritHandles=*/TRUE, - CREATE_SUSPENDED | CREATE_NO_WINDOW, + CREATE_SUSPENDED | (capture ? CREATE_NO_WINDOW : 0u), envBlock.data(), (cwd && *cwd) ? cwd : nullptr, &si, &pi); @@ -233,6 +249,7 @@ DeadlineRun capture_with_deadline(const char* commandLine, bool killed = false; auto drain_available = [&]() -> bool { + if (!capture) return false; DWORD avail = 0; if (!::PeekNamedPipe(readEnd.h, nullptr, 0, nullptr, &avail, nullptr)) return false; From 20106305cd98f63b7e7051959d010bc5cd73c817 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:16:38 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(cache):=20module=5Fextensions=20?= =?UTF-8?q?=E8=BF=9B=E4=BE=9D=E8=B5=96=E7=BC=93=E5=AD=98=E9=94=AE=E7=9A=84?= =?UTF-8?q?=20E=20=E8=BD=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 指纹管的是 target///,全局依赖缓存是另一套键。一个声明了 module_extensions 的依赖产出不同的 .o/BMI,它的缓存键必须体现这一点。 今天不可达 —— 默认 glob 会跟着变,sourceGlobs 已经动了;索引包描述符按版本 冻结,version 在 D 轴。但这正是本 PR 在消灭的形状(同一决策漏一处),一行补上 比留着等它以后变成一次错误的缓存命中便宜。 不 bump epoch:老条目命令行里没有 -x c++,而该旗标在已识别后缀上幂等(产物逐 字节相同),沿用安全,不必让全网缓存作废。 --- ...ce-kind-table-and-build-program-timeout.md | 39 +++++++++++++++++++ src/build/cache_key.cppm | 12 ++++++ 2 files changed, 51 insertions(+) diff --git a/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md b/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md index 94da492a..ba9dcbed 100644 --- a/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md +++ b/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md @@ -893,6 +893,45 @@ BMI 坏掉时报错会指向一个和你的改动毫无关系的模块,极难归 --- +## 7.4 深度自审(CI 后)发现并修掉的两条 + +### ① 未捕获的有界运行丢了流式输出 —— 真回归 + +`run_exec_deadline` 是 `mcpp test` **非 JSON 模式**跑测试二进制的路径,原本 +**继承调用方 stdio**。我第一版把它改成「捕获后在结束时回放」,后果两条: + +1. 长测试的输出全部憋到退出才出现 —— 恰好抵消 `mcpp test` 可观察性那一整轮工作 + (「只有子进程输出、mcpp 一行没有」正是缓冲问题的指纹); +2. 子进程 stdout 变成管道而非终端 ⇒ gtest 之类**静默关掉彩色输出**。 + +修法:两侧启动器共用一条契约 —— **`sink == nullptr` 表示不捕获**,子进程直接继承 +调用方 stdio,但仍然有界。POSIX 不建管道不设 dup2;Windows 不设 +`STARTF_USESTDHANDLES`,也不加 `CREATE_NO_WINDOW`(未捕获的运行本来就是给人看的)。 + +### ② 依赖缓存键的 E 轴漏了 `module_extensions` + +指纹管的是 `target///`,**全局依赖缓存是另一套键**。一个声明了 +`module_extensions` 的依赖产出不同的 `.o`/BMI,但它的缓存键此前不含这个字段。 + +**今天不可达**:默认 glob 会跟着变 ⇒ `sourceGlobs` 已经动了;而索引包描述符按版本 +冻结,`package.version` 在 D 轴。⇒ 但这正是本方案要消灭的形状(同一决策漏一处), +一行补上比留着等它以后变成一次「错误的缓存命中」便宜。 + +### 顺带确认:`-x c++` 不需要 bump 缓存 epoch + +老 mcpp 写入的缓存条目(命令行里没有 `-x c++`)会被新 mcpp 读到。因为该旗标在 +已识别后缀上**幂等**(§3.8.1 实测),产物逐字节相同 ⇒ 沿用是安全的,不必让全网 +缓存作废。 + +### 一处**刻意的**行为变化 + +`is_compilable_output`(build.mcpp 生成物能否编译)原本是**唯一含 `.ixx` 的清单**。 +改为按 kind 判定后,`.ixx` 生成物在未声明 `module_extensions` 时不再被自动纳入 +`sources`。这是**修正而非回归**:旧路径接受它进 sources,而后面每一个阶段都会 +错误处理它 —— 没有任何包能靠那条路径正常工作。 + +--- + ## 8.1 本次不做,但已排期 | 项 | 状态 | 说明 | diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index 56691f81..240aa827 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -118,6 +118,15 @@ struct PackageAxes { std::vector includeDirs; // store-relative, ordered std::vector sourceGlobs; // [build] sources, ordered std::vector sources; // package-root-relative, sorted + // [build] module_extensions — decides which of `sources` are module + // interfaces, i.e. which units emit a BMI and which objects link + // unconditionally. Different artifacts, so it belongs in the key. + // + // Not reachable today (a widened default glob already moves `sourceGlobs`, + // and an index descriptor is frozen per version so nothing else can move + // it) — which is exactly why it is easy to leave out and find later as a + // wrong cache hit. Cheap to close now. + std::vector moduleExtensions; // F — keys of direct dependencies, sorted std::vector upstreamKeys; }; @@ -221,6 +230,7 @@ nlohmann::json to_json(const BuildAxes& b, const PackageAxes& p) { {"include_dirs", p.includeDirs}, {"source_globs", p.sourceGlobs}, {"sources", p.sources}, + {"module_extensions", p.moduleExtensions}, }; j["upstream"] = p.upstreamKeys; return j; @@ -262,6 +272,7 @@ std::string key_hex(const BuildAxes& b, const PackageAxes& p) { put_list(s, "includes", p.includeDirs); put_list(s, "srcglobs", p.sourceGlobs); put_list(s, "sources", p.sources); + put_list(s, "modexts", p.moduleExtensions); // F put_list(s, "upstream", p.upstreamKeys); return mcpp::toolchain::hash_string(s); @@ -311,6 +322,7 @@ void fill_package_config(PackageAxes& out, out.ldflags = bc.ldflags; out.defines = bc.defines; out.sourceGlobs = bc.sources; + out.moduleExtensions = bc.moduleExtensions; if (!bc.cStandard.empty()) { // A package may pin its own C standard; it reaches its own C units. From e8b17cf532c2165a8c6bd5cfa62d06d8978dbf05 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:19:26 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(platform/windows):=20=E6=8D=95=E8=8E=B7?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=BF=85=E9=A1=BB=E6=8A=8A=20stdin=20?= =?UTF-8?q?=E5=B0=81=E6=88=90=20NUL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自审发现。被替换掉的 Windows 捕获路径经 _popen 走 shell,命令行尾部带 '< NUL' —— 这个文件的同伴 mcpp.platform.process 头注释就点名了原因:xlings / xim / curl / git 子进程在 bootstrap 期间阻塞在终端 stdin 上,逼用户反复敲回车。 新实现把父进程的 console stdin 直接透传给了被捕获的子进程,会静默把那个挂起 带回来。改为从 NUL 打开;打不开时退回 console 句柄而不是交一个无效句柄 ——「完全没有 stdin」的失败长得一点也不像「stdin 没被封」。 未捕获的子进程保持继承真实 stdin,与 run_exec 一致:mcpp run 就是要把终端交 给程序。 --- src/platform/windows/bounded_process.cppm | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/platform/windows/bounded_process.cppm b/src/platform/windows/bounded_process.cppm index a0824a6c..0b30e914 100644 --- a/src/platform/windows/bounded_process.cppm +++ b/src/platform/windows/bounded_process.cppm @@ -207,13 +207,36 @@ DeadlineRun capture_with_deadline(const char* commandLine, &jeli, sizeof(jeli)); } + // A CAPTURED child gets stdin from NUL, never from the console. + // + // This is not tidiness: mcpp's Windows launchers have always sealed stdin + // (see this file's peer, mcpp.platform.process, and the bug it names — + // xlings / xim / curl / git children blocking on terminal input during + // bootstrap, forcing the user to hammer Enter). The path this replaces + // went through `_popen` with `< NUL` appended, so inheriting the console's + // stdin here would quietly bring that hang back. + // + // An UNCAPTURED child keeps the real stdin, matching run_exec: `mcpp run` + // hands the terminal to the program on purpose. + Handle nulIn; + if (capture) { + nulIn.h = ::CreateFileA("NUL", GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, + OPEN_EXISTING, 0, nullptr); + } + STARTUPINFOA si{}; si.cb = sizeof(si); if (capture) { si.dwFlags = STARTF_USESTDHANDLES; si.hStdOutput = writeEnd.h; si.hStdError = writeEnd.h; - si.hStdInput = ::GetStdHandle(STD_INPUT_HANDLE); + // If NUL could not be opened, fall back to the console handle rather + // than handing the child an invalid one — a child with no stdin at all + // fails in ways that look nothing like "stdin was not sealed". + si.hStdInput = (nulIn.h && nulIn.h != INVALID_HANDLE_VALUE) + ? nulIn.h + : ::GetStdHandle(STD_INPUT_HANDLE); } // else: no STARTF_USESTDHANDLES — the child inherits our console. From 968e06f238ef3e22c6a64b7fa8535a706b31bd35 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:21:46 +0800 Subject: [PATCH 5/8] =?UTF-8?q?refactor(prepare):=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E5=90=8D=E8=A1=A8=E6=8C=89=E5=8C=85=E5=BB=BA=E4=B8=80=E6=AC=A1?= =?UTF-8?q?,=E4=B8=8D=E5=86=8D=E6=AF=8F=E4=B8=AA=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=89=A9=E9=87=8D=E5=BB=BA=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/build/prepare.cppm | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index b8507a8d..ebd95691 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -3159,18 +3159,18 @@ prepare_build(bool print_fingerprint, std::copy(fresh.begin(), fresh.end(), mm.buildConfig.actions.begin() + static_cast(firstNewAction)); + // The package that DECLARED the outputs classifies them: a dependency + // generating a `.ixx` asks its own manifest, not the root project's. + // Built once per package, not once per output. + const auto pkgExtTable = + mcpp::extension_table_for(mm.buildConfig.moduleExtensions); for (auto const& a : fresh) { if (a.role != mcpp::manifest::BuildAction::Role::Source) continue; for (auto const& o : a.outputs) { if (o.find("${mcpp.") != std::string::npos) continue; // Companion outputs (protoc's .pb.h next to its .pb.cc) are // produced by the edge but are NOT translation units. - // The package that DECLARED the output classifies it: a - // dependency generating a `.ixx` is asking its own manifest, - // not the root project's. - if (!mcpp::build::directives::is_compilable_output( - o, mcpp::extension_table_for( - mm.buildConfig.moduleExtensions))) + if (!mcpp::build::directives::is_compilable_output(o, pkgExtTable)) continue; mm.buildConfig.sources.push_back(o); mm.modules.sources.push_back(o); From b50faf5575892b6980239616819cb61139ffd3bf Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:31:42 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(manifest):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8E=A8=E6=96=AD=E7=9A=84=20lib=20=E5=A4=87=E6=B3=A8=E8=A6=81?= =?UTF-8?q?=E8=AF=B4=E5=87=BA=E5=AE=9E=E9=99=85=E6=89=BE=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E9=82=A3=E4=B8=AA=E6=89=A9=E5=B1=95=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS e2e 抓到的:我把备注从「lib from .cppm in src/」改成了「lib from module interface in src/」,25_convention_mode.sh 断言的是前者。 这条我判断为文案改得不够好,而不是测试过时。备注该说的是**实际找到了什么**: .cppm 工程输出与从前逐字不变(那条断言原样通过),而声明了 module_extensions 的工程会看到「lib from .ixx in src/」—— 说 .cppm 才是名不副实。 顺带 CHANGELOG 补 2026.8.11.1 条目。 --- CHANGELOG.md | 79 ++++++++++++++++++++++++++++++++++++++++++ src/manifest/toml.cppm | 13 +++++-- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e54ed5bc..d170cbfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,85 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.11.1] — 2026-08-11 + +### 新增 + +- **`[build] module_extensions` —— 哪些扩展名是模块接口,由工程声明。** + + ```toml + [build] + module_extensions = [".ixx", ".ccm"] + ``` + + 追加到内置的 `.cppm`。声明一个扩展名会**同时**做三件事:默认 sources glob 跟着 + 变宽(文件才能被**找到**)、这些单元走**模块**规则(产 BMI、`.o` 无条件进链接)、 + 新鲜度快路径**扫描**它们(加 `import` 会让构建图作废)。一个键而不是三处配置。 + + 任何扩展名都接受,唯独拒绝已代表其他角色的(`.cpp` `.c` `.h` `.S` …)—— + manifest **错误**而非警告,因为宣称 `.c` 是模块接口会把 C 文件送进 C++ 模块规则, + 最终失败在一个既不提文件也不提这个键的地方。 + + ⚠️ `.ccm`/`.cxxm`/`.ixx` **不进内置默认**。进了的话默认 glob 会跟着变宽, + 于是 `src/` 下躺着 vendored MSVC-only `.ixx` 的**已发布包会在一次 mcpp 升级后 + 突然开始编译它** —— 而包作者改不了已经发出去的 tarball。 + +- **`[build] build_program_timeout` —— `build.mcpp` 的运行上限可配置。** + + 优先级 `MCPP_BUILD_PROGRAM_TIMEOUT` > **该包自己的** manifest > 内置 600s, + 与 `macos_deployment_target` 同构。超时报错**点名要改的那份 `mcpp.toml`** —— + 依赖超时时改自己的那份不会有任何效果,这正是 [#410](https://github.com/mcpp-community/mcpp/issues/410) + 从外面看到的样子。不写这个键与写 `0` 不是一回事:不写=用默认上限,`0`=不设上限。 + +- **`mcpp self doctor` 报告构建策略。** 生效的模块接口扩展名表、生效的超时值 + **及其来源**、以及本平台的 deadline 是否**真的**强制执行。 + 另外 `module_extensions` 里零命中的条目会告警 —— 否则打字错误(`.ixxx`)与 + 「这个工程还没有」无法区分。 + +### 修复 + +- **超时上限在 Windows 上从来就是空操作。** `capture_exec_deadline` 只在 POSIX + 生效,其余平台直接回落到无界启动器 —— 于是 `mcpp test --timeout`、 + `--build-timeout`、以及这个新键在 Windows 上**设了等于没设**。现在两侧各有实现: + Windows 把子进程放进 **Job 对象**并在到期时关闭它,杀掉的是**整棵进程树**而不只是 + 直接子进程(否则一个还攥着捕获管道的孙进程会让杀掉之后的读取一直挂住)。 + +- **`.mm`(Objective-C++)的对象编了但永远不进链接** —— `is_implementation_source` + 的清单漏了它。 + +- **stage 一个含汇编的依赖会静默丢掉那些源文件** —— 兜底 glob 漏了全部三种汇编扩展名。 + +### 架构 + +- **「扩展名 → 角色」此前在 9 个文件 20 处推导,分成 8 份互不一致的清单** + (三份「什么算实现单元」、四份「什么算源文件」)。 + [#272](https://github.com/mcpp-community/mcpp/pull/272) 修了链接侧,却漏了 + `pick_rule` —— 边上**声明**了 BMI 产物(那行读 `providesModule`),命令行却丢了 + `-fmodule-output=`。 + + 收敛的形状不是「大家都调同一个函数」,而是**分类只发生一次**(文件进图时), + 之后当数据传递(`SourceUnit::kind` → `CompileUnit::kind`)。扫描器手里本来就有 + 所属包的 manifest,所以这一步没有新增任何管道。 + +- **mcpp 现在每次都显式告诉编译器某个单元是模块接口**(`-x c++` / `-x c++-module` / + `/interface /TP`),而不是去维护「哪个驱动认哪个后缀」。实测(GCC 16.1 / Clang 22.1): + **Clang 根本不认 `.ixx`** —— 把它当链接输入、警告、**退出码 0 且不产 BMI**; + 而显式旗标在已识别后缀上**幂等**(Clang 的 `.cppm` BMI 逐字节相同)。 + 一张会过期、错了还静默的表不该存在。 + +- `src/platform/` 拆成 `unix/ windows/ linux/ macos/`;`mcpp.platform.process` + 对有界运行**单点 `if constexpr` 分派**,取代此前散落的平台分支。 + +### 兼容性 + +- 未配置时**构建图零差分**:同样的文件被编、同样的 BMI、同样的对象进链接、 + 同样的指纹目录。 +- `module_extensions` **进**指纹(它改图的形态);`build_program_timeout` **不进** + (它不改任何一条边 —— 进了会让「抬高超时」重建全世界)。 +- ⚠️ 旧版 mcpp 遇到 `module_extensions` 会警告+忽略,然后把那些文件当普通翻译单元 + 编译 —— **错误的构建**而不是干净的失败。发布用了这个键的包必须声明 mcpp 版本下限 + (见 `docs/10-publishing-a-library.md`)。 + ## [2026.8.10.3] — 2026-08-10 ### 修复 diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 129269d9..c421f8cf 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -1668,16 +1668,23 @@ void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { // "Is there a module interface under src/" — asked through the table, // so a library whose interfaces are all `.ixx` still infers a lib // target instead of silently having none. - bool hasModuleInterface = false; + // + // The extension that answered is kept for the inferred-note: saying + // ".cppm" when the project's interfaces are `.ixx` would be a note that + // names the wrong thing, and a `.cppm` project still prints exactly + // what it always did. + std::string moduleInterfaceExt; if (std::filesystem::is_directory(root / "src", ec)) { for (auto& e : std::filesystem::recursive_directory_iterator(root / "src", ec)) { if (ec) break; if (e.is_regular_file(ec) && !ec && mcpp::produces_bmi(mcpp::classify(e.path(), extTable))) { - hasModuleInterface = true; break; + moduleInterfaceExt = e.path().extension().string(); + break; } } } + const bool hasModuleInterface = !moduleInterfaceExt.empty(); if (hasMain) { Target t; @@ -1693,7 +1700,7 @@ void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { t.kind = Target::Library; m.targets.push_back(std::move(t)); m.inferredNotes.push_back( - std::format("target {} (lib from module interface in src/)", m.package.name)); + std::format("target {} (lib from {} in src/)", m.package.name, moduleInterfaceExt)); } // If neither, no auto-target — caller will error if it needs one. } From 4eb893753640454a46e390c520f1e6493783ed2e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:48:41 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(e2e):=20218=20=E7=9A=84=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=E6=96=AD=E8=A8=80=E4=B8=8D=E8=83=BD=E9=9D=A0=20find|h?= =?UTF-8?q?ead=20-1=20=E6=8E=A8=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自审 + 全量套件抓到的:第 2 部分之后 target/ 下有两个输出目录, `find target -name build.ninja | head -1` 取到的是 find 恰好先走到的那个 —— 单跑绿、进套件红。改成直接问 mcpp 要指纹值(--print-fingerprint), 断言那个值本身,而不是一个目录列举的副作用。 (这正是「指纹目录随版本变,ls|head -1 会自查到旧产物」那条老坑的同一形状, 在一个专门用来抓这类问题的测试里又踩了一次。) --- tests/e2e/218_module_extensions_graph_shape.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/e2e/218_module_extensions_graph_shape.sh b/tests/e2e/218_module_extensions_graph_shape.sh index f2937a3a..0903960b 100755 --- a/tests/e2e/218_module_extensions_graph_shape.sh +++ b/tests/e2e/218_module_extensions_graph_shape.sh @@ -54,11 +54,21 @@ printf 'export module gshape.helper;\nimport std;\nexport auto helper() -> int { printf 'export module gshape.face;\nimport std;\nexport auto face() -> int { return 1; }\n' > src/face.ixx printf 'import std;\nimport gshape.face;\nint main(){ std::println("{}", face()); }\n' > src/main.cpp -fp_dir() { find target -name build.ninja -printf '%h\n' | head -1; } +# Ask mcpp for the fingerprint instead of inferring it from the build tree. +# +# ⚠️ The obvious `find target -name build.ninja | head -1` is WRONG here and was +# flaky in exactly the way this test is meant to catch: after part 2 there are +# TWO output dirs, and `head -1` picks whichever `find` happened to walk first. +# It passed standalone and failed inside the suite. Assert on the value, not on +# a directory listing. +fingerprint() { + "$MCPP" build --print-fingerprint 2>&1 | sed -n 's/^Fingerprint: //p' | head -1 +} # ── 0. First build, then confirm the fast path actually engages ──────────── "$MCPP" build > b0.log 2>&1 || { cat b0.log; echo "FAIL: first build"; exit 1; } -FP_BEFORE="$(fp_dir)" +FP_BEFORE="$(fingerprint)" +[ -n "$FP_BEFORE" ] || { echo "FAIL: could not read the fingerprint"; exit 1; } "$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: no-change build"; exit 1; } grep -q "Compiling" b1.log && { @@ -91,14 +101,14 @@ module_extensions = [".ixx", ".ccm"] EOF "$MCPP" build > b3.log 2>&1 || { cat b3.log; echo "FAIL: build after key change"; exit 1; } -FP_AFTER="$(fp_dir)" +FP_AFTER="$(fingerprint)" [[ "$FP_BEFORE" != "$FP_AFTER" ]] || { echo "FAIL: module_extensions changed but the output dir did not" echo " before=$FP_BEFORE after=$FP_AFTER" echo " (the key is missing from the canonical compile-flags string)" exit 1; } -echo " ok: the key is fingerprinted ($(basename "$FP_BEFORE") -> $(basename "$FP_AFTER"))" +echo " ok: the key is fingerprinted ($FP_BEFORE -> $FP_AFTER)" # ── 3. A dead entry is reported, not silently ignored ────────────────────── # From f7e53ab73472b33629d00d1b23df05ed453e4146 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:50:33 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20=E8=A1=A5=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=20=E2=80=94=E2=80=94=20CI=2019/19=E3=80=81?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=2012=20=E6=9D=A1=E5=A4=B1=E8=B4=A5=E7=9A=84?= =?UTF-8?q?=E9=80=90=E6=9D=A1=E5=AF=B9=E7=85=A7=E7=BB=93=E8=AE=BA=E3=80=81?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E6=88=91=E5=9C=A8=E8=87=AA=E5=B7=B1=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=87=8C=E9=87=8D=E8=B8=A9=20head=20-1=20=E7=9A=84?= =?UTF-8?q?=E6=95=99=E8=AE=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ce-kind-table-and-build-program-timeout.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md b/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md index ba9dcbed..e52e09e8 100644 --- a/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md +++ b/.agents/docs/2026-08-11-source-kind-table-and-build-program-timeout.md @@ -889,6 +889,8 @@ BMI 坏掉时报错会指向一个和你的改动毫无关系的模块,极难归 | 快路径扫描 `.ixx` | ✅ 加 `import` 后强制全量 prepare(e2e 218) | | 指纹包含 `module_extensions` | ✅ `65e7cc0a` → `497632df` | | manifest 超时键 | ✅ 2s 生效、env=1 覆盖它、负数硬错误(e2e 186) | +| **CI 全绿** | ✅ **19/19,0 失败**(linux/windows/macOS 的 unit+e2e、cross、mingw、hermetic、bare-Windows、xlings 集成) | +| 本机全量 e2e | 198 过 / 12 败;**12 条全部用已发布 2026.8.10.3 跑同样红** ⇒ 环境性(本机共享 gcc payload specs 被历次安装污染),唯一真问题是 218 自身的脆弱断言,已修 | | 超时报错指名 manifest | ✅ 打印该包 `mcpp.toml` 的绝对路径 | --- @@ -932,6 +934,25 @@ BMI 坏掉时报错会指向一个和你的改动毫无关系的模块,极难归 --- +## 7.5 我自己写的测试,又踩了一次同一个坑 + +`218` 的第 2 部分用 `find target -name build.ninja | head -1` 推指纹目录。 +**单跑绿,进全量套件红。** + +原因:第 2 部分改了 `module_extensions` 之后 `target/` 下有**两个**输出目录, +`head -1` 取到的是 `find` 恰好先走到的那个。 + +这与记忆里那条「指纹目录随版本变,`ls | head -1` 会自查到旧二进制」是**同一形状** +—— 而且我在这次实施里已经踩过一次(取错 mcpp 二进制,误以为自己改了指纹), +然后**在一个专门用来抓这类问题的测试里又踩了第二次**。 + +修法:不去推目录,直接问 `mcpp build --print-fingerprint` 要那个值,断言值本身。 + +> **判据**:一个断言如果依赖「目录里恰好只有一个东西」,它就不是断言,是运气。 +> 能问到值就别去列目录。 + +--- + ## 8.1 本次不做,但已排期 | 项 | 状态 | 说明 |