Skip to content

Latest commit

 

History

History
184 lines (153 loc) · 11.3 KB

File metadata and controls

184 lines (153 loc) · 11.3 KB

依赖入口对象的条件链接(fix mcpp test duplicate main)设计方案

mcpp 0.0.63 → 0.0.64 — 修复 mcpp testduplicate 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 对象——等效、最小爆炸半径、全平台可行。

1. 问题

[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
无入口——清晰报错,非崩溃

2. 根因

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::Librarymanifest.cppm:637)。但 mcpp 的依赖链接模型没有兑现它

  • plan.cppm 的 link-unit 循环只为根包[targets.*] 建 LinkUnit;
  • 依赖包(含 dev-dep)的对象通过 append_package_objects / compileUnit 循环 直接内联进消费者二进制(plan.cppm:486–575);
  • 于是 gtest-all.ogtest_main.o 作为散对象被无条件灌入测试二进制 → gtest_main.omain 与测试自身 main 冲突。

这不是 gtest 的缺陷,也不是描述符的缺陷,而是 mcpp 核心 link 模型的缺口。

3. 架构决策:修复归属(回答「改 mcpp-index 还是 mcpp」)

候选 评价
❌ mcpp 里给 gtest 加特判(跳过 gtest_main.o) 把第三方库名硬编进构建核心,污染架构。否决。
❌ 依赖 kind="lib" → 静态归档 .a 按需链接(最初采用) 理念干净,但 Windows/MSVC lld-link 两处致命(§3.5);否决
保持依赖内联,仅条件性排除依赖自身的 main 对象(最终) 等效、最小爆炸半径(只动 main 对象)、全平台可行、通用(扫描依赖源是否定义 main)。采用。

3.5 为何放弃静态归档(Windows/MSVC 两处致命)

最初实现「依赖 lib → .a 归档 → 按需链接」,Linux/macOS/aarch64 全绿,但 Windows CI 连续失败,逐层揭开:

  1. LNK1561: entry point must be defined —— Windows 上 mcpp 走 MSVC 模式 (lld-link),它不会仅为确定入口点而从归档惰性拉取成员。不带 main 的测试 (用 gtest TEST 宏 + gtest_main)拿不到 gtest_main.omain → 失败。 (--start-lib/--end-lib 又不被 Mach-O lld 支持,不能统一替代。)
  2. LNK2019: unresolved external __imp_lzma_*(构建 xlings) —— 把常规 lib 依赖(libarchive)也归档后,MSVC 链接器对「归档→另一归档(lzma)」的传递符号 解析顺序处理不同 → 一片未解析外部符号。

两者证明:静态归档在 MSVC 上不可行(既不能供入口,又破坏传递链接)。故回退到内联。

4. 核心设计:依赖入口对象的条件链接

保持所有依赖对象内联(沿用既有链接模型,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)。

4.1 source_defines_main 的健壮性(关键)

判据是「源是否定义 int main(/auto main(」,必须先剥离注释 + 字符串 + 字符 + raw-string 字面量再匹配——否则测试夹具里的 "int main(){...}" 字符串会假阳性。 test_modgraph.cpp 正是此坑:它在双引号字符串里嵌了 int main(),逐行启发式误判它 「自带 main」→ 早期归档版把 no-main 测试错配 → MSVC LNK1561。现用字符状态机剥离后 再匹配,导出 + 8 个单测守卫(test_main_detection.cpp)。

4.2 通用性 / 面向未来

不识别「哪个依赖、哪个对象」是 gtest_main——只看「依赖对象是否自带 main」+「消费者 是否自带 main」。任何未来测试框架(mcpplibs 生态 / mcpp 原生)其 main-提供对象都被 同样处理,mcpp 零框架知识、零特例。描述符层(mcpp-index gtest)无需改动

4.3 实现位置(plan.cppm,make_plan)

  • 先从根 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 发射) 已全部移除。

4.4 为何作用域必须限 dev-dependency(一段踩过的坑)

最初的内联版扫描所有依赖的实现源找 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 字段已全部移除)→ 后端/链接行与改动前一致。

5. 不破坏既有行为(回归边界)

  • 仅当「消费者自带 main」「某依赖对象自身定义 main」时,该对象被排除——这是 唯一的行为变化点。
  • 所有其余链接(纯模块依赖、shared 依赖、根包、常规 C/C++ lib 如 libarchive/lzma、 无 main 的测试)逐字节不变 → xlings 等复杂工程零回归(这正是放弃归档换来的)。

6. 边界用例

  • 无 main 且无框架的测试:无入口 → 链接器报 undefined main(真实用户错误); mcpp test 已透出诊断(本轮新增,见 cdb 修复链)。
  • 自带 main 且不用 gtest(但 gtest 是 dev-dep):依赖对象不被引用 → 链接器本就不 纳入(内联对象未引用即不产生符号需求);gtest_main 对象被显式排除 → 无冲突。
  • 多个依赖各自提供 main:自带 main 消费者全部排除;无 main 消费者会拉多个 main → duplicate(罕见,清晰报错)。

7. 验证策略(TDD)

  • 单元 test_main_detection.cpp:source_defines_main 对真实 main / 带参 main / auto main 判真;对字符串字面量、raw-string、注释里的 int main() 判假;对 mainHelper 判假。(8 例)
  • e2e 78_test_main_combinations.sh(三平台):含 gtest dev-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 传递链接)——这是放弃归档后必须确认恢复的关键。

8. 实施计划

  • 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。)

9. 决策备注

  1. 由既有元数据驱动,而非新增特例:kind="lib" 早在描述符里;本设计只是让 mcpp 兑现它。符合「约定优于配置 / 用户无感」。
  2. 单归档 + 链接器语义 已覆盖全部 main 交叉组合,无需拆 gtest_main、无需让 用户选链接哪个目标——最大化「无感」。
  3. 模块对象不归档 是关键安全边界:避免全局初始化被归档式丢弃,且让纯模块依赖 零回归。
  4. 面向未来框架:任何测试框架在其描述符声明 kind="lib" 即自动获得正确入口 语义;mcpp 永不需要认识具体框架。