Skip to content

feat(codegen): 生成产物不再签入 —— protoc / grpc_cpp_plugin 由 mcpp 自建,用户 build.mcpp 只剩 3 行 - #1

Merged
Sunrisepeak merged 5 commits into
mainfrom
feat/codegen-host-tools
Aug 5, 2026
Merged

feat(codegen): 生成产物不再签入 —— protoc / grpc_cpp_plugin 由 mcpp 自建,用户 build.mcpp 只剩 3 行#1
Sunrisepeak merged 5 commits into
mainfrom
feat/codegen-host-tools

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

What

Generated protobuf/gRPC stubs are no longer checked in. mcpp builds protoc and grpc_cpp_plugin for your machine, out of the same packages you link, and generates the stubs during the build.

A project's entire build.mcpp is now three lines:

import mcpp;
import grpcgen;
int main() { return grpcgen::generate({"helloworld"}) ? 0 : 1; }

Why this shape

The mcpp.toml in this repo used to say:

gen/ holds protoc output, CHECKED IN on purpose. gRPC's codegen needs two host tools — protoc and grpc_cpp_plugin — and mcpp has no way to hand a dependency's built binaries to a consumer.

That stopped being true in mcpp 2026.8.5.1. Three properties follow, and none of them are achievable with hand-managed codegen:

before now
edit a .proto re-run protoc by hand; forget and you ship stale stubs a ninja edge re-runs, and only the affected one
protoc vs runtime version your problem, and a mismatch fails at runtime not expressible — a tool's version is its dependency's version
cross-compiling find a host protoc yourself correct by construction — tools always build for the build machine

The industry works hard for that middle row: Conan carries a protobuf/<host_version> placeholder, xmake deletes protoc outright when cross-compiling, and protobuf's own CMake still has an open issue where Protobuf_PROTOC_EXECUTABLE is ignored in CONFIG mode. Here it needs no machinery, because there is only one version axis.

Three packages, one tag

package what it is consumers write
mcpplibs.grpc the gRPC runtime grpc = "1.83.0"
mcpplibs.grpc-plugin grpc_cpp_plugin { version = "1.83.0", tools = ["grpc_cpp_plugin"] }
mcpplibs.grpcgen the codegen rule { version = "1.83.0", host-module = true }

A new CI job package-versions-match proves all three never drift — a 1.83.0 runtime with a differently-versioned plugin is exactly the mismatch this design exists to eliminate.

Why the plugin is its own package

This is the one decision I got wrong first and had to be corrected by data. The initial attempt put grpc_cpp_plugin in the main package behind a feature. It parses fine — and fails, because mcpp compiles a package into one object pool and a kind = "bin" target links all of it. Upstream's plugin links grpc_plugin_support + protobuf and touches the gRPC runtime not at all:

add_executable(grpc_cpp_plugin src/compiler/cpp_plugin.cc)
target_link_libraries(grpc_cpp_plugin grpc_plugin_support)

In the main package it would inherit OpenSSL, re2, c-ares and zlib. It failed outright:

error: xlings install_packages failed for 'compat.openssl@3.5.1'

A code generator failing because it cannot install TLS is not "big and slow" — it is the wrong dependency graph. Split out, it is 3 TUs + libprotoc: no TLS, no DNS, no regex engine.

Why the rule is its own package

Without it, every project copies ~60 lines of build.mcpp — which means copying bugs that then have to be fixed N times. At least one of those bugs is guaranteed to bite everyone: protoc does not embed the well-known types, so import "google/protobuf/timestamp.proto" is read from disk, and real services use Timestamp/Duration/Any constantly. The path is derivable from mcpp::dep_dir("protobuf") — 8 lines that belong in one versioned place, not in every project.

Rules ship as ordinary mcpp packages written in C++, so there is no second language here the way xmake has Lua rules and Bazel has Starlark.

The design doc originally deferred this, reasoning that a rule package "cannot carry its own tools". That reasoning was wrong — consumers declare grpc/grpc-plugin/protobuf in their own manifest anyway, so the tool env vars are already in the consumer's process. The real blockers were two bugs in mcpp 2026.8.5.1 (a rule could use neither import std; nor import mcpp;), fixed in 2026.8.5.2 — mcpp#357. Hence this repo's CI floor.

Two examples, deliberately

examples/greeter templates/greeter instantiated — 3-line build.mcpp via grpcgen
examples/helloworld the same program with the rule written out by hand, so the mechanism stays legible

CI builds both. Building examples/greeter is how the template gets tested — otherwise a broken template is only discovered by a user running mcpp new. A template-matches-example job byte-diffs the two build.mcpp files so they cannot drift.

Verification

  • Output fidelity: the four files this self-built toolchain generates are byte-identical to those produced by official protoc 35.1 + the official gRPC plugin — verified for both the hand-written build.mcpp and the grpcgen rule.
  • grpc = false path: full chain — generate → compile → link → run.
  • grpc = true path: all four stubs generated.
  • ✅ Incremental: editing a .proto re-runs only that edge (1.38s vs a 0.02s no-op), and a .proto that imports a sibling re-runs too.
  • ✅ Tools land in the global store and are reused across projects.
  • ⏳ The two examples' real RPC over loopback: not verifiable locally — this sandbox cannot install compat.openssl, which gRPC hard-requires. CI's linux + macOS legs cover it.

Ordering

mcpp-index #155 must land first — it adds compat.protobuf's protoc target. This is measured, not assumed: against the published index the plugin fails at link with

undefined symbol: typeinfo for google::protobuf::compiler::CodeGenerator

and succeeds against the index branch. After this merges and a release is tagged, a follow-up index PR adds mcpplibs.grpc-plugin and mcpplibs.grpcgen.

本仓库的 mcpp.toml / README 里一直写着「mcpp 没有把依赖的构建产物交给消费者的
机制,所以生成产物签入仓库」。mcpp 2026.8.5.1 之后那句话不成立了。

## 最重要的验证结果

用 mcpp 从源码构建出的 protoc 与 grpc_cpp_plugin,对 helloworld.proto 生成的
**四个文件与仓库里签入的逐字节相同**:

    helloworld.pb.h / .pb.cc            ✓ 逐字节相同
    helloworld.grpc.pb.h / .grpc.pb.cc  ✓ 逐字节相同

也就是说这条自建工具链的产物 ≡ 官方 protoc 35.1 + 官方 gRPC 1.83.0 插件的产物。
签入的 gen/ 因此可以删掉,而不是「大概等价」。

## 新增 plugin/ 包 —— 为什么不是 grpc 里的一个 target

初版就是在主包里加 [features.codegen] + [targets.grpc_cpp_plugin],语法上全部成立。
不行的原因是 mcpp 把一个包编成**一个对象池**(Target 没有 sources 字段),所以
kind="bin" 会链接该包的**全部对象**。而上游的插件只链 grpc_plugin_support + protobuf:

    add_executable(grpc_cpp_plugin  src/compiler/cpp_plugin.cc)
    target_link_libraries(grpc_cpp_plugin  grpc_plugin_support)

放主包里意味着这个代码生成器要链进 gRPC 的 ~1000 个 TU,并继承整套依赖 ——
OpenSSL、re2、c-ares、zlib。实测直接失败:

    error: xlings install_packages failed for 'compat.openssl@3.5.1'

**一个代码生成器因为装不上 TLS 库而构建失败** —— 这不只是大和慢,是依赖图错了。

拆成独立包后恢复上游的真实依赖图:3 个 TU + libprotoc,没有 TLS/DNS/正则。
实测构建 **2.19s**(protobuf 命中全局缓存)。

## 插件只取 C++ 的那条闭包

上游的 grpc_plugin_support 带全部 8 个语言 generator,因为它同时支撑
grpc_php_plugin / grpc_python_plugin 等。本包只建 grpc_cpp_plugin,所以只取
cpp_plugin.cc 真正够得到的 cpp_generator.cc + proto_parser_helper.cc。

**这不是抄近路而是正确的闭包,并且它 MATTERS**:php 与 objective-c 的 generator 引用
libprotoc 内部符号(compiler::objectivec::FileClassPrefix 等),而 compat.protobuf 编译
的源码集不导出它们 —— 带上它们会在**链接期**因为「没人要求生成的语言」而失败。实测。

## 用户侧

    grpc            = "1.83.0"
    grpc-plugin     = { version = "1.83.0", tools = ["grpc_cpp_plugin"] }
    compat.protobuf = { version = "35.1",   tools = ["protoc"] }

改 .proto 然后重新构建,就这样。三个手工管理给不了的性质:

- **版本错配不可表达** —— 工具版本就是依赖版本。protoc 与运行时对不上是**运行期**
  才炸、也是这类问题里最难查的,现在语法上无法发生。(Conan 为近似这一点专门引入了
  <host_version>;protobuf 自己的 CMake 至今有 open issue:CONFIG 模式下
  Protobuf_PROTOC_EXECUTABLE 被忽略。)
- **增量** —— 生成是一条 ninja 边,.proto 变了才重跑。
- **交叉编译构造上就对** —— --target 下工具仍为构建机器构建,用户零操作。

## 改动

- 新增 plugin/(独立包)+ 3 个 vendored gRPC 公开头(自包含;vendor 在本包内而不是
  去主包 third_party 取 —— 它会作为自己的 tarball 发布)
- examples/helloworld 与 templates/greeter:删掉签入的 gen/,改由 build.mcpp 声明
  一条 action 生成
- CI:MCPP_VERSION → 2026.8.5.1(这是**下限**不是例行 pin:tools=[...] 与
  mcpp:action= 在它之前不存在);新增「grpc-plugin 版本 == grpc 版本」校验 ——
  两个索引条目出自同一个 tag,除了校验没有别的东西能让它们保持相等,而版本漂移正是
  本设计要消灭的那类错配
- .agents/docs/2026-08-05-codegen-ecosystem-design.md:完整方案与实测数据

## 顺序依赖 / 未在本地验证的部分

- 需要 **mcpp-index 先合入 compat.protobuf 的 protoc target**,否则拿不到 protoc。
- 模板里的 `grpc-plugin = { version = ... }` 需要索引里有 mcpplibs.grpc-plugin 条目,
  那要等本仓库打出 tag 之后再发一个索引 PR。example 用的是 path 依赖,不受此限。
- **example 的完整构建没有在本地跑通**:本机 compat.openssl 的从源码 install() 钩子
  跑不起来(与本次改动无关,主包一直如此),由 CI 覆盖。已在本地验证的是:两个工具
  都能建出来、生成产物与签入的逐字节相同。
上一个 commit 让 codegen 不再签入仓库,代价是每个工程都要抄一份约 60 行的
build.mcpp。抄一份就是**复制一份将来要各自修的 bug**,而其中至少有一处是所有人
都会踩的:protoc **不内嵌 well-known types**,`import "google/protobuf/timestamp.proto"`
是从磁盘读的,真实服务几乎必用 Timestamp / Duration / Any —— 用户写第二个 .proto
时必然撞墙,而路径要从 `mcpp::dep_dir("protobuf")` 里探出来。

那 60 行现在装进 `rules/`,以普通 mcpp 包 `grpcgen` 发布。用户侧:

    [dependencies]
    grpcgen = { version = "1.83.0", host-module = true }

    // build.mcpp —— 全文如此
    import mcpp;
    import grpcgen;
    int main() { return grpcgen::generate({"helloworld"}) ? 0 : 1; }

规则有版本、能测试、能发布,而且是 C++ 写的 —— 不像 xmake 的 Lua rule 与 Bazel 的
Starlark,这里没有第二门语言。

── 落地时改掉的三处判断 ─────────────────────────────────────────────────────
1. **设计文档说规则包「带不动自己的 tools」,那条理由不成立。** 消费者本来就要在
   自己的 manifest 里声明 grpc / grpc-plugin / protobuf,`tools = [...]` 写在同一处,
   环境变量本来就在消费者进程里,规则直接 `mcpp::dep_bin()` 就读到了。

2. **真正挡路的是另外两个缺口**,都在 mcpp 2026.8.5.1:规则里 `import std;` 与
   `import mcpp;` 都编不过(host module 在 std 建好前就编译;`host-module = true`
   没把包移出普通依赖图,于是又被当普通库编一遍)。已在 mcpp 2026.8.5.2 修掉
   (mcpp#357),所以 CI floor 抬到 2026.8.5.2。

3. **包名必须是合法 C++ 模块名。** mcpp 用裸 `package.name` 注册 host 模块,所以
   包名就是模块名 —— `grpc-rules` 不行(连字符),故名 `grpcgen`。

── 两个示例,是刻意的 ───────────────────────────────────────────────────────
- `examples/greeter`(新)= `templates/greeter` 的实例化,三行 build.mcpp。
  CI 构建它**就是**在测试模板 —— 否则模板坏了只有用户 `mcpp new` 时才发现。
  新增 job `template-matches-example` 机器校验两者的 build.mcpp 逐字节相同。
- `examples/helloworld` 保留手写版本,把规则摊开写,让机制保持可读。

`package-versions-match`(原 plugin-version-matches)扩到三个包:grpc / grpc-plugin
/ grpcgen 同 tag 同版本,版本漂开正是本方案要消灭的那类错配。
- `grpc = false` 全链路跑通;`grpc = true` 四个产物齐全。
- **规则产出与官方 protoc 35.1 + 官方插件的产物逐字节相同** —— 之前验证的是手写
  build.mcpp 的产物,这次验证的是规则包的。
- §4.1 的「索引先合」不是推测:插件包在 `compat.protobuf` 没有 `protoc` feature 时
  链接期报 `undefined symbol: typeinfo for google::protobuf::compiler::CodeGenerator`。
一个构建程序就是普通的 C++23,没有理由成为整个模块化工程里唯一退回 `#include`
的地方。mcpp 会为它构建 host 的 std 模块,复用的正是工程自己的 TU 所 import 的
那份 BMI;宿主工具链若没有 std 模块,mcpp 会明确报错并给出修法。

两处:
- `build.mcpp`(库自身,设置私有 include 目录与 `ares` 的关闭态)
- `examples/helloworld/build.mcpp`(手写长版本的 codegen 示例)

`templates/greeter` 与 `examples/greeter` 只有三行,本来就不碰标准库;
`rules/src/grpcgen.cppm` 一开始就是 `import std;`。

── 一个会咬人的细节 ─────────────────────────────────────────────────────────
`import std;` **不导出 C 的 `stderr`**:它是 `<cstdio>` 里的**宏**,不属于 `std::`
命名空间,没有模块会导出它。于是

    std::fprintf(stderr, ...)      // 编译不过

    /usr/include/stdio.h:151:14: error: ...
    extern FILE *stderr;

改用 `std::println(std::cerr, ...)` —— `std::cerr` 在 `std::` 里,由 `import std;`
正常导出。库自身那份本来就只往 stdout 写(指令流的注释里写明了「诊断走 stdout,
绝不走 stderr,后者会插进被缓冲的指令流」),所以不受影响。

两种写法都用等价工程实测编译通过。
我在 codegen 那节写的是 `mcpp new --template greeter` —— **错的**,而且错在用户会
敲的第一条命令上。`--template` 的文法是 `pkg | pkg:tmpl | pkg@ver | pkg@ver:tmpl`
(src/cli/cmd_new.cppm),接的是**包名**,不是模板目录名。`greeter` 会被当成一个
不存在的包。

同一篇 README 的 Quick Start 一直写的是 `--template grpc`,是对的 —— 两处自相
矛盾,现在统一,并写明一个包带多个模板时怎么显式指定(`grpc:greeter`)。中英同步。
@Sunrisepeak
Sunrisepeak merged commit 66628ac into main Aug 5, 2026
8 of 10 checks passed
@Sunrisepeak
Sunrisepeak deleted the feat/codegen-host-tools branch August 5, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant