mcpp 0.0.63 → 0.0.64 — 修复
mcpp test的duplicate symbol: main,优雅支持 「用/不用 gtest × 带/不带 main」全部交叉组合,全平台(Linux/macOS/Windows)。关联:2026-06-25-cdb-test-coverage-design.md (同一轮 test 体验修复链的第三环)。
状态:已实现并全平台 CI 通过。 实施进度见 §8。
重要演进:最初设计为「依赖
kind="lib"→ 静态归档.a按需链接」(标题旧名), 但该方案在 Windows/MSVC lld-link 上两处致命(见 §3.5 / §9),最终改为保持依赖 内联、仅条件性排除依赖自身的 main 对象——等效、最小爆炸半径、全平台可行。
含 [dev-dependencies] gtest 的项目,若测试文件自带 int main()(脚手架默认模板
就是),mcpp test 链接失败:
ld.lld: error: duplicate symbol: main
>>> obj/test_smoke.o:(main) ← 测试自己的 main()
>>> obj/gtest_main.o:(.text+0x0) ← gtest_main.cc 自带的 main()
测试二进制同时链接了测试自身的 main 与 gtest 的 gtest_main.o(也含 main)。
需优雅支持的交叉组合(用户尽量无感):
| 用 gtest? | 自带 main? | 期望 |
|---|---|---|
| 否 | 是 | 链接测试自己的 main(今天 fresh 项目即此,正常) |
| 是 | 是(调用 InitGoogleTest/RUN_ALL_TESTS) |
用测试的 main,不要 gtest_main |
| 是 | 否(只写 TEST(...)) |
用 gtest_main 提供的 main |
| 否 | 否 | 无入口——清晰报错,非崩溃 |
compat.gtest 描述符(mcpplibs/mcpp-index/pkgs/c/compat.gtest.lua)已声明:
mcpp = {
sources = { "*/googletest/src/gtest-all.cc", "*/googletest/src/gtest_main.cc" },
targets = { ["gtest"] = { kind = "lib" } }, -- 已建模为「库」
}mcpp 的 manifest 解析器也已支持 kind="lib" → Target::Library
(manifest.cppm:637)。但 mcpp 的依赖链接模型没有兑现它:
plan.cppm的 link-unit 循环只为根包的[targets.*]建 LinkUnit;- 依赖包(含 dev-dep)的对象通过
append_package_objects/ compileUnit 循环 直接内联进消费者二进制(plan.cppm:486–575); - 于是
gtest-all.o、gtest_main.o作为散对象被无条件灌入测试二进制 →gtest_main.o的main与测试自身main冲突。
这不是 gtest 的缺陷,也不是描述符的缺陷,而是 mcpp 核心 link 模型的缺口。
| 候选 | 评价 |
|---|---|
| ❌ mcpp 里给 gtest 加特判(跳过 gtest_main.o) | 把第三方库名硬编进构建核心,污染架构。否决。 |
❌ 依赖 kind="lib" → 静态归档 .a 按需链接(最初采用) |
理念干净,但 Windows/MSVC lld-link 两处致命(§3.5);否决。 |
✅ 保持依赖内联,仅条件性排除依赖自身的 main 对象(最终) |
等效、最小爆炸半径(只动 main 对象)、全平台可行、通用(扫描依赖源是否定义 main)。采用。 |
最初实现「依赖 lib → .a 归档 → 按需链接」,Linux/macOS/aarch64 全绿,但 Windows CI
连续失败,逐层揭开:
LNK1561: entry point must be defined—— Windows 上 mcpp 走 MSVC 模式 (lld-link),它不会仅为确定入口点而从归档惰性拉取成员。不带 main 的测试 (用 gtest TEST 宏 + gtest_main)拿不到gtest_main.o的main→ 失败。 (--start-lib/--end-lib又不被 Mach-O lld 支持,不能统一替代。)LNK2019: unresolved external __imp_lzma_*(构建 xlings) —— 把常规 lib 依赖(libarchive)也归档后,MSVC 链接器对「归档→另一归档(lzma)」的传递符号 解析顺序处理不同 → 一片未解析外部符号。
两者证明:静态归档在 MSVC 上不可行(既不能供入口,又破坏传递链接)。故回退到内联。
保持所有依赖对象内联(沿用既有链接模型,xlings/libarchive/lzma 等逐字节不变),
仅对「依赖自身定义 main 的对象」(如 gtest 的 gtest_main.o)做条件处理:
| 消费者自带 main? | 依赖的 main 对象(gtest_main.o) | 其余依赖对象 | 结果 |
|---|---|---|---|
| 是 | 排除(不链接) | 内联 | 入口=消费者自己;无 duplicate main ✓ |
| 否 | 内联(直接链接,提供入口) | 内联 | 入口=gtest_main;全平台(含 MSVC)OK ✓ |
- 「依赖的 main 对象」= 扫描 dev-dependency 包(且非 shared)的实现源,
source_defines_main为真者(gtest_main.cc 有 main;gtest-all.cc 没有)。 一次性预扫描存入depEntryMainSources,消费者循环 O(1) 查表。 作用域必须限 dev-dep(见 §4.4):测试框架永远是 dev-dep,而常规库 (libarchive/lzma 等)根本不进扫描——这是保证常规依赖零影响的硬边界, 比"扫了但靠检测返回 false"更强、更安全。 - 「消费者自带 main」=
source_defines_main(entryMain)(对测试即测试文件本身)。 - 直接链接对象(非归档)→ 不依赖任何链接器的归档拉取语义 → Linux/macOS/Windows 一致。
- 仅排除「dev-dep 的 main 对象」→ 其余链接与改动前完全一致,零回归(尤其 xlings)。
判据是「源是否定义 int main(/auto main(」,必须先剥离注释 + 字符串 + 字符 +
raw-string 字面量再匹配——否则测试夹具里的 "int main(){...}" 字符串会假阳性。
test_modgraph.cpp 正是此坑:它在双引号字符串里嵌了 int main(),逐行启发式误判它
「自带 main」→ 早期归档版把 no-main 测试错配 → MSVC LNK1561。现用字符状态机剥离后
再匹配,导出 + 8 个单测守卫(test_main_detection.cpp)。
不识别「哪个依赖、哪个对象」是 gtest_main——只看「依赖对象是否自带 main」+「消费者 是否自带 main」。任何未来测试框架(mcpplibs 生态 / mcpp 原生)其 main-提供对象都被 同样处理,mcpp 零框架知识、零特例。描述符层(mcpp-index gtest)无需改动。
- 先从根 manifest 的
[dev-dependencies]解析出devDepPackages(经dependency_name_candidates匹配到已解析包的限定名)。 - 预扫描:
depEntryMainSources= 仅devDepPackages内的实现源中source_defines_main为真者。 - 每个消费者:
entryDefinesMain = source_defines_main(entryMain)。 - 内联循环新增一行:
if (entryDefinesMain && depEntryMainSources.contains(cu.source)) continue; - 后端无改动;归档相关代码(
StaticDepArchive/LinkUnit.archiveInputs/其 ninja 发射) 已全部移除。
最初的内联版扫描所有依赖的实现源找 main。source_defines_main 在构建 xlings 时
对某个常规依赖(libarchive)源假阳性,把它需要的对象误排除 →
undefined reference to archive_entry_*,Linux/aarch64/macOS 全部构建失败。
根因:在任意大型 C 库上扫 main 太脆。修复 = 把扫描/排除限定到 dev-dependency
包——测试框架(gtest、未来 mcpplibs/原生框架)永远是 dev-dep;常规库永不被扫,
天然零风险。mcpp build(不解析 dev-deps)更是从构造上完全不受影响。
——自带 main 的消费者跳过依赖的 main 对象;其余一切照旧。
source_defines_main导出供单测。无新 LinkUnit、无 ninja 改动(归档相关代码 及archiveInputs字段已全部移除)→ 后端/链接行与改动前一致。
- 仅当「消费者自带 main」且「某依赖对象自身定义 main」时,该对象被排除——这是 唯一的行为变化点。
- 所有其余链接(纯模块依赖、shared 依赖、根包、常规 C/C++ lib 如 libarchive/lzma、 无 main 的测试)逐字节不变 → xlings 等复杂工程零回归(这正是放弃归档换来的)。
- 无 main 且无框架的测试:无入口 → 链接器报 undefined
main(真实用户错误);mcpp test已透出诊断(本轮新增,见 cdb 修复链)。 - 自带 main 且不用 gtest(但 gtest 是 dev-dep):依赖对象不被引用 → 链接器本就不 纳入(内联对象未引用即不产生符号需求);gtest_main 对象被显式排除 → 无冲突。
- 多个依赖各自提供 main:自带 main 消费者全部排除;无 main 消费者会拉多个 main → duplicate(罕见,清晰报错)。
- 单元
test_main_detection.cpp:source_defines_main对真实 main / 带参 main /auto main判真;对字符串字面量、raw-string、注释里的int main()判假;对mainHelper判假。(8 例) - e2e
78_test_main_combinations.sh(三平台):含gtestdev-dep 的项目, 自带main+用gtest / 无main+用gtest宏 / 自带main+不用gtest 三组合mcpp test全绿, 且断言「自带 main 测试不链接 gtest_main.o、无 main 测试链接之」。 - 回归:15/16/17/18/31/07/08 + 全量单测;Windows CI 构建 xlings(libarchive/ lzma 传递链接)——这是放弃归档后必须确认恢复的关键。
- P1 plan 模型:
source_defines_main(剥离注释/字符串/raw-string)+ 导出;depEntryMainSources预扫描;消费者按「自带 main」排除依赖 main 对象。 (归档方案 staticDep/archiveInputs 已回退移除。) - P2 后端:无改动(回退归档发射);
mcpp test透出diagnosticOutput(可见性)。 - P3 单元测试:
MainDetection(8 例)+ 全量 25 单测绿。 - P4 e2e:
78_test_main_combinations.sh三组合全绿 + 链接行断言。 - P5 回归:25 单测 + e2e 全套绿;全平台 CI 全绿(Linux/Windows/aarch64/
macOS/e2e)——含 Windows 构建 xlings(libarchive/lzma)、Windows e2e 78 三组合。
e2e 78 断言历经
.exe后缀 + 反斜杠路径两次跨平台适配。 - P6 版本 + 文档:bump 0.0.63→0.0.64;CHANGELOG;本文件。
- 历程:归档 → MSVC LNK1561/LNK2019(§3.5)→ 回退内联+条件排除。
- P7 发布闭环:PR → CI 全平台 → squash --admin 合入 → tag v0.0.64 →
release → 镜像 xlings-res(gh+gtc,4 平台)→ xim-pkgindex mcpp.lua bump(PR)→
索引产物自动发布 →
xlings install mcpp@0.0.64验证 → bootstrap pin bump。 (mcpp-index/gtest 描述符不改 → 无需重发 mcpp-index。)
- 由既有元数据驱动,而非新增特例:
kind="lib"早在描述符里;本设计只是让 mcpp 兑现它。符合「约定优于配置 / 用户无感」。 - 单归档 + 链接器语义 已覆盖全部 main 交叉组合,无需拆
gtest_main、无需让 用户选链接哪个目标——最大化「无感」。 - 模块对象不归档 是关键安全边界:避免全局初始化被归档式丢弃,且让纯模块依赖 零回归。
- 面向未来框架:任何测试框架在其描述符声明
kind="lib"即自动获得正确入口 语义;mcpp 永不需要认识具体框架。