Protobuf Code Generation in Crubit’s Cargo Build
crubit.rs/overview/cargo_build_protobuf
In Crubit’s Cargo build for rs_bindings_from_cc, protoc is invoked in two
distinct places:
- Externally, before Cargo runs:
cargo/build/generate_proto_headers.py(invoked bycargo/build/setup_bazel_env.pyor external orchestrators like Chromium’sbuild_crubit.py) generates C++ Protobuf headers and source files (.pb.hand.pb.cc). - Internally, during
cargo build: Thebuild.rsscripts of Rust Protobuf crates (cargo/rs_bindings_from_cc/ir_rust_proto/build.rsandcargo/rs_bindings_from_cc/generate_bindings/generate_bindings_rust_proto/build.rs) invokeprotocviaprotobuf-codegento generate Rust bindings.
At first glance, invoking protoc in two separate mechanisms might seem
redundant or fragmented. This document explains why this separation exists, the
technical constraints that necessitate it, and the alternative designs that were
evaluated.
Core Reasons for the Separation
1. C++ Protobufs: Preventing Race Conditions in Parallel Cargo Builds
rs_bindings_from_cc is composed of many modular C++ support crates
(ast_consumer_sys, cc_ir_sys, decl_importer_sys, importer_sys,
ir_from_cc_sys, src_code_gen_sys, etc.). Many of these crates compile C++
code that directly or transitively #include "rs_bindings_from_cc/ir.pb.h".
When Cargo builds these crates in parallel:
- If each crate’s
build.rsattempted to generate.pb.hheaders into a shared directory, parallelprotocinvocations would race against each other, resulting in file locking contention, truncated files, and flaky build failures. - If each crate instead generated
.pb.hheaders into its own isolated$OUT_DIR,protocwould execute redundantly dozens of times. Furthermore, crates cannot easily reference headers generated in sibling crates’ private$OUT_DIRpaths.
Generating all C++ Protobuf headers into a shared directory upfront before Cargo starts ensures that all C++ compilation units find stable, fully-generated headers without any parallel file-system races.
2. Rust Protobufs: Relying on protobuf-codegen and Native Cargo Serialization
Unlike C++ headers, Rust Protobuf code generation does not suffer from parallel build races:
- Rust Protobuf code is generated inside dedicated, isolated crates
(
cargo/rs_bindings_from_cc/ir_rust_protoandcargo/rs_bindings_from_cc/generate_bindings/generate_bindings_rust_proto). - Cargo’s dependency graph guarantees that
ir_rust_protobuilds sequentially before any dependent Rust crates. - The generation is driven by
protobuf-codegen(the official Protobuf crate code generator), which handles:- Emitting the required
crate_mappingconfiguration so imported types resolve across crate boundaries. - Passing toolchain-specific flags (e.g.
--rust_opt=experimental-codegen=enabled,kernel=upb). - Post-processing generated code (e.g. inner/outer attribute adjustments).
- Emitting the required
Keeping Rust code generation in build.rs allows Crubit to rely on standard
Cargo idioms and upstream tooling rather than reverse-engineering
protobuf-codegen in custom scripts.
3. External Preparation is Already Required
rs_bindings_from_cc depends on C++ libraries (LLVM/Clang LibTooling, Abseil,
and Protobuf runtime libraries) that cannot be built from crates.io. An
external build system (Bazel in Crubit’s GitHub CI/CQ, CMake in Chromium
Toolchain Builds) must build these C++ dependencies first, and a Python script
must discover them and configure environment variables (CLANG_..., ABSL_...,
PROTOBUF_..., PROTOC) before cargo build can succeed.
Because an external preparation phase is already mandatory, running
generate_proto_headers.py as part of that step adds no additional phases or
overhead to the build.
Alternative Approaches Evaluated
Alternative A: Move Everything to Python (Pre-generate Rust Code Upfront)
- Concept: Have
generate_proto_headers.py(or a unified script) generate both C++ headers and Rust source files before invoking Cargo. - Why it was rejected:
- Fragility & upstream coupling: Replicating
protobuf-codegenin Python requires manually constructing crate mappings, passing experimentalprotocflags, and applying post-processing text replacements. Any upstream update to theprotobufcrate or its code generator would risk silently breaking our custom Python generator. - Cargo
$OUT_DIRisolation:ir_rust_proto/lib.rsincludes generated code viainclude!(concat!(env!("OUT_DIR"), "/ir.rs"));. Because Cargo assigns unpredictable, hash-suffixed$OUT_DIRpaths per build, Python cannot write directly to$OUT_DIR. Abuild.rsscript would still be needed to copy pre-generated files into$OUT_DIR, offering little benefit over generating them in place withprotobuf-codegen. - Generator consistency: The automated Cargo package generator
constructs Cargo packages for
rust_proto_librarytargets using standardprotobuf-codegeninbuild.rs. Diverging from this model would require special-casing proto crates in the generator.
- Fragility & upstream coupling: Replicating
Alternative B: Move Everything to Cargo via links in Cargo.toml
- Concept: Declare
links = "ir_cc_proto"inrs_bindings_from_cc_ir_cc_proto_sys/Cargo.tomland have itsbuild.rsexportcargo:includemetadata to dependent crates. - Why it was rejected:
- Cargo’s non-transitive metadata limitation: Cargo only exposes
DEP_<NAME>_<KEY>metadata to immediate, direct dependencies. In Crubit, dozens of crates#include "rs_bindings_from_cc/ir.pb.h"transitively. Every intermediate crate’sbuild.rswould have to manually forward these environment variables along the entire dependency tree. This is fragile, verbose, and difficult for the Cargo package generator to maintain.
- Cargo’s non-transitive metadata limitation: Cargo only exposes
Alternative C: Move Everything to Cargo via Ambient File Locking
- Concept: Have C++
_syscrates share an output directory, using an ambient inter-process file lock (e.g.flockon Unix,LockFileExon Windows) incargo/buildso the first crate generates the C++ headers and subsequent crates wait and reuse them. - Why it was rejected:
- Violation of Cargo build invariants: Cargo’s documentation
explicitly advises that
build.rsshould only write inside its own$OUT_DIR. Writing to shared directories outside$OUT_DIRcan interfere with Cargo’s caching, fingerprinting, rebuild detection,cargo clean, etc. - Cross-platform locking complexity: Robust cross-process file locking
across Linux, macOS, and Windows (accounting for stale locks after
aborted builds, permission nuances, and Windows sharing modes) adds
non-trivial complexity to
cargo/build. PROTOCis still needed: Becauseprotocis typically hermetic and not installed on system$PATH, Cargo still requires thePROTOCenvironment variable. Ambient locking would not eliminate the need forsetup_bazel_env.pyto discover and exportPROTOC.
- Violation of Cargo build invariants: Cargo’s documentation
explicitly advises that