Skip to content

Latest commit

 

History

History
184 lines (117 loc) · 19.3 KB

CONTRIBUTING.md

File metadata and controls

184 lines (117 loc) · 19.3 KB

Contributing to buffa

See DESIGN.md for the architectural overview and README.md for the workspace layout.

Prerequisites

  • Rust toolchain — pinned by rust-toolchain.toml; rustup installs it automatically on the first cargo invocation. This keeps local cargo fmt/cargo clippy output identical to the CI lint job (rustfmt formatting drifts across releases, so an unpinned toolchain can disagree with CI). Bump the pin together with the lint-and-test toolchain in .github/workflows/ci.yml.
  • protoc v27+ — the test suite includes editions-syntax protos (edition = "2023"). The editions_2024.proto test requires protoc v32+ (the first release that accepts edition 2024 without --experimental_editions) — it's skipped with a cargo warning on older versions. Ubuntu's apt protobuf-compiler (v21.12) is too old. Run task install-protoc to download the CI-matched version into .local/ (requires gh authenticated), then use task test-local.

Changelog Entries

CHANGELOG.md is a generated file — do not edit it directly. Each user-visible change adds a fragment file under .changes/unreleased/ instead, so concurrent PRs never conflict on the changelog. Create one with:

task changelog-new

This prompts for a kind (Breaking changes, Added, Changed, Deprecated, Removed, Fixed, Security) and a multi-line body, then writes .changes/unreleased/<kind>-<timestamp>.yaml. Commit that file with your change. The body is the full Keep a Changelog bullet text (the bullet marker and two-space hanging indent are added at batch time, so write the body flush-left). Write it as flowing prose — one line per paragraph, no hard line-wraps. Hard-wrapped text has to be re-flowed by hand on every later edit and produces noisy diffs; the renderer wraps it for you, and MD013 is off so there is no line-length limit to satisfy.

Non-interactive (the prompt requires a TTY, so use this form from scripts and agent sessions; -b accepts a multi-line body):

task changelog-new -- -k Fixed -b '**Short description of the fix** (#NNN).
Further detail goes on its own line, however long — the bullet and its hanging indent are added at batch time.'

At release time, task changelog-batch -- <version> rolls the fragments into .changes/<version>.md and task changelog-merge regenerates CHANGELOG.md. CI verifies that CHANGELOG.md matches changie merge output, so a direct edit will fail the check-changelog job.

Adding a Publishable Crate

A new workspace crate is published to crates.io unless its Cargo.toml sets publish = false. If it should be released, add it to .github/publish-order.txt after the crates it depends on — that file is the single source of truth for what the release publishes and in what order.

task check-publish-coverage verifies that the list matches the workspace and that each crate is listed after the workspace crates it depends on. CI runs the same check on every pull request, so the PR that introduces the problem fails: a missing entry would otherwise be skipped silently at release time, and a misplaced one would fail the publish part-way through. (buffa 0.9.0 shipped with buffa-remote-derive announced in the changelog but absent from crates.io, because nothing compared the list to the workspace.)

Note that the first publish of any new crate cannot use Trusted Publishing — crates.io cannot configure it for a crate that does not exist yet — so it needs a one-time API token, after which trusted publishing is configured and later releases run over OIDC.

Change Size

Keep each change to ≤ 250 lines net (additions minus deletions, excluding test files) wherever possible. If a task naturally exceeds that, split it into focused, self-contained PRs or commits.

Test Coverage

Every change must include unit tests. Target ≥ 80% line coverage for new code. Reaching 100% is not required when the remaining paths would require artificial or contrived tests, but coverage gaps must be intentional and justified. Tests live in #[cfg(test)] modules colocated with the code they test; integration tests go under tests/.

Rust Conventions

  • Run task lint and task test before every commit (or task verify to also run the conformance suite).
  • Prefer thiserror for library error types; avoid .unwrap() in non-test, non-provably-safe code.
  • Every unsafe block requires a // SAFETY: comment explaining the invariant.
  • Public API items require doc comments (///); include # Errors / # Panics / # Safety sections where applicable.
  • Keep dependencies minimal; use Cargo feature flags to avoid pulling in unnecessary transitive deps.

Code Generation (buffa-codegen)

All code generation uses quote! blocks rather than string manipulation. prettyplease formats the final TokenStream into readable Rust source.

Key rules:

  • Regular // comments are not tokens and are silently dropped by quote!. Only use quote! for code structure. Inject comments as raw strings before or after the formatted output (e.g. the // @generated file header in lib.rs).
  • Doc comments (///) survive because quote! treats them as #[doc = "..."] attributes. Use let doc = format!("..."); quote! { #[doc = #doc] ... } when the content is dynamic.
  • Identifiers must go through format_ident! (or Ident::new_raw for Rust keywords). Never interpolate raw strings as identifiers.
  • Type paths from the descriptor context (e.g. "my_pkg::MyMessage") must be parsed with syn::parse_str::<syn::Type> before interpolation into quote!.
  • The pipeline in lib.rs is: accumulate TokenStream → syn::parse2::<syn::File> → prettyplease::unparse → prepend file-header string.
  • no_std-safe type paths: Generated code must compile in both std and no_std contexts. String, Vec, and Box are not in the no_std prelude (even with extern crate alloc) — they must always be emitted as ::buffa::alloc::string::String, ::buffa::alloc::vec::Vec, ::buffa::alloc::boxed::Box. Use the ImportResolver methods (resolver.string(), resolver.vec(), resolver.boxed()) which handle this. Only core prelude types (Option, Result, Default, etc.) can be emitted as bare names. See imports.rs for details.
  • Re-exports for codegen: buffa re-exports alloc, bytes, and (under the json feature) serde_json as #[doc(hidden)] items so generated code can reference ::buffa::alloc::*, ::buffa::bytes::*, and ::buffa::serde_json::* without the consumer crate declaring those deps. These re-exports are load-bearing for every downstream crate's build — do not remove them or change their visibility. serde is the deliberate exception: the #[derive(::serde::Serialize)] macro emits extern crate serde as _serde;, so consumers of json=true codegen must depend on serde directly regardless.

Conformance Tests

The protobuf conformance suite runs via Docker. Use task conformance (or task verify to run lint + tests + conformance). It requires Docker and uses the pre-built tools image ghcr.io/anthropics/buffa/tools:v33.5 which bundles conformance_test_runner (from protobuf v33.5) and the test .proto files.

If the tools image pull fails (403 Forbidden from GHCR), build it locally first:

task tools-image-local   # builds for local platform only, ~5 min
task conformance         # now uses the locally-built image

Without Docker: the suite can also run natively. conformance_test_runner talks to the testee over stdin/stdout pipes, so no container plumbing is needed — the runner just has to be built from protobuf source once:

task conformance-tools-local   # one-time: cmake-builds the runner into .local/bin,
                               # populates conformance/protos/ (~10-20 min)
task conformance-local         # eight runs, same failure lists as the Docker path

Requires cmake, a C++ toolchain, and protoc v30+ on PATH or $PROTOC (task install-protoc). Set CONFORMANCE_OUT=<dir> to tee per-run logs.

Understanding the output: The conformance runner executes eight runs (std, no_std, via-view, via-lazy, view-json, via-reflect, via-vtable, via-table), each producing two suites:

  1. Binary + JSON suite — expects thousands of successes (~5500 std and no_std; 5,549 table). The via-view and via-lazy runs only handle binary→binary (~2800); the view-json, via-reflect, and via-vtable runs handle binary→JSON (and via-reflect also binary→binary).
  2. Text format suite — 883 successes for std, no_std and table (the full suite); via-view, via-lazy, view-json, via-reflect, and via-vtable show 0 successes, 883 skipped (those modes have no TextFormat path).

So a healthy run shows 16 CONFORMANCE SUITE PASSED lines.

The Dockerfile builds three binaries: one with default features (std), one with --no-default-features (no_std) and one with --features table (table). The std binary is reused for the view/reflect runs by setting an env var:

  • via-view (BUFFA_VIA_VIEW=1) — binary input through decode_view → to_owned_message → encode, verifying owned/view decoder parity.
  • via-lazy (BUFFA_VIA_LAZY=1) — binary input through decode_lazy → to_owned_message → encode on the lazy view family (lazy_views(true)), verifying the lazy decoder (record arms, fragment merge, budget capture) against the corpus.
  • view-json (BUFFA_VIEW_JSON=1) — binary→JSON through decode_view → serde_json::to_string(&view), verifying the generated view Serialize impls (and the hand-written WKT view Serialize impls in buffa-types).
  • via-reflect (BUFFA_VIA_REFLECT=1) — binary/JSON I/O through DynamicMessage's descriptor-driven codec and reflective serde, verifying the runtime reflection codec independently of any generated type.
  • via-vtable (BUFFA_VIA_VTABLE=1) — binary→JSON: decode the view, walk its vtable ReflectMessage surface to rebuild a DynamicMessage, then serialize to JSON. Verifies the generated impl ReflectMessage for FooView. It reuses DynamicMessage's JSON serializer (which passes the corpus cleanly under via-reflect), so any failure isolates a bug in the vtable get/has/for_each_set surface. Requires the conformance crate's reflect feature, so it is absent from the no_std binary.

via-table is a separate binary, built with --features table (no env var), that runs the whole std run — binary, JSON and text — against test messages generated with CodecStrategy::Table. It exercises the table codec's decode, size and encode paths, oneofs, maps and the bridge to well-known-type children on the proto3, editions and nested proto2 messages. The messages the table cannot handle run through the unrolled codec instead: messages with extension ranges under JSON, MessageSet messages, group types and the messages that hold them. That includes the top-level TestAllTypesProto2, which via-table therefore does not exercise on the table. buffa-build prints a cargo:warning naming the messages that fell back, and a compile-time check in conformance/src/main.rs fails the build if the messages that must use the table stop doing so. The table feature needs Rust 1.77; conformance/Cargo.toml says why.

Expected failures are listed in conformance/known_failures.txt (std binary+JSON), conformance/known_failures_nostd.txt (no_std binary+JSON), conformance/known_failures_view.txt (via-view), conformance/known_failures_lazy.txt (via-lazy), conformance/known_failures_view_json.txt (view-json), conformance/known_failures_reflect.txt (via-reflect), conformance/known_failures_view_vtable.txt (via-vtable), conformance/known_failures_table.txt (via-table; currently empty), and conformance/known_failures_text.txt (text format — shared between std, no_std and table; currently empty). The text list is passed via --text_format_failure_list since the runner validates each suite's list independently. When a previously-failing test starts passing, remove it from the relevant file; when a new test is expected to fail, add it.

Capturing output: To save per-run logs for analysis, mount a directory and set CONFORMANCE_OUT:

docker run --rm -v /tmp/conf:/out -e CONFORMANCE_OUT=/out buffa-conformance
# logs: /tmp/conf/conformance-{std,nostd,view,lazy,view-json,reflect,vtable,table}.log

Upgrading the protobuf version: bump TOOLS_IMAGE in Taskfile.yml and PROTOC_VERSION in .github/workflows/ci.yml, then:

task tools-image               # rebuild and push the multi-arch tools image
task vendor-bootstrap-protos   # re-fetch buffa-descriptor/protos/ from the new release tag
task gen-bootstrap-types       # regenerate checked-in descriptor types
task gen-option-types             # regenerate buffa-proto-options, which embeds descriptor.proto

Commit the refreshed buffa-descriptor/protos/, buffa-descriptor/src/generated/, and buffa-proto-options/src/generated/ alongside the version bump.

Checked-In Generated Code

These sets of generated code are checked into the repo and must be regenerated whenever codegen output changes (e.g. changes to imports.rs, message.rs, oneof.rs, etc.):

  1. Bootstrap descriptor types (buffa-descriptor/src/generated/): Used by codegen itself to parse .proto descriptors. Regenerate with task gen-bootstrap-types. The source protos are vendored in buffa-descriptor/protos/ (pinned; refresh with task vendor-bootstrap-protos when bumping the protobuf version), so output is independent of your local protoc's bundled includes — only a protoc binary ≥ v27 is needed. Only needs regeneration when a codegen change affects the descriptor types themselves — most changes don't.

  2. Well-known types (buffa-types/src/generated/): Timestamp, Duration, Any, Struct/Value, FieldMask, Empty, wrappers, plus Api/Type/Enum/SourceContext. Checked in (rather than generated at build time) so that consumers of buffa-types don't need protoc or the buffa-build/buffa-codegen toolchain. Regenerate with task gen-wkt-types. The WKT .proto sources are vendored in buffa-types/protos/ (not read from the protoc installation) so the output is pinned. This is the one most likely to need regeneration — WKTs use views, unknown-field preservation, and the arbitrary derive, so almost any codegen output-format change touches them. If in doubt, run it and check git status.

  3. Custom option types (buffa-proto-options/src/generated/): the messages and extensions of buffa-proto-options/protos/buffa/ext/options.proto. Checked in so that buffa-codegen can depend on buffa-proto-options without first generating it. Regenerate with task gen-option-types (requires protoc v27 or later on PATH or in $PROTOC; descriptor.proto is read from buffa-descriptor/protos/). Run it after an edit to options.proto, after a codegen output-format change, and after task vendor-bootstrap-protos. The types are generated with every impl kind (views, JSON, text, arbitrary, reflection), so most output changes reach them, and the reflection impls embed descriptor.proto.

  4. Logging example (examples/logging/src/gen/): Regenerate with task gen-logging-example (requires buf on PATH).

The bsr-quickstart example's examples/bsr-quickstart/src/gen/ is the exception: it holds output from the published BSR plugin, so codegen changes do not regenerate it. Once the buf.build/anthropics/buffa plugin for a new release is published, bump the plugin pin in its buf.gen.yaml and the buffa / buffa-types versions in its Cargo.toml together, then run task gen-bsr-quickstart-example.

CI (check-generated-code job) will fail if checked-in generated code is stale.

Cross-Target Checks

task check-nostd adds the bare-metal thumbv7em-none-eabihf target on demand (it runs rustup target add itself, which is idempotent), so it needs no separate setup.

For the 32-bit tasks, run task install-targets first to install the additional rustup target:

  • i686-unknown-linux-gnu — 32-bit x86 Linux (for task check-32bit / task test-32bit; test-32bit also needs gcc-multilib)

task install-targets also installs thumbv7em-none-eabihf for convenience; the check-32bit / test-32bit tasks have preconditions that print a clear error if the 32-bit target is missing.

Continuous Integration

GitHub Actions CI (.github/workflows/ci.yml) runs on every push to main and on all pull requests. Jobs:

  • lint-and-test — clippy, cargo test --workspace, clippy and tests for buffa-test with its arbitrary feature (task test-arbitrary locally), and strict rustdoc (task doc locally), on the pinned toolchain
  • lint-markdown — markdownlint over all *.md (config: .markdownlint.json)
  • msrv-check — cargo check --workspace on Rust 1.75 (the declared rust-version), then the table codec's tests on Rust 1.77, the oldest compiler that builds it
  • check-nostd — no_std (host + bare-metal ARM) and 32-bit compilation checks
  • check-generated-code — regenerates the bootstrap descriptor types, the well-known types, and the buffa-proto-options option types, and fails if any checked-in copy is stale
  • conformance — builds the tools and conformance Docker images, runs the full protobuf conformance suite

Benchmarks

task bench runs the combined benchmark suite for the dev loop — all message shapes in one binary, with criterion comparing against your previous run. task bench-prost / task bench-prost-bytes run the prost equivalents on the same machine for buffa-vs-prost comparison.

For the per-shape performance history (benchmarks/history/), each message is measured in isolation — built with only its own decoder compiled — so that adding or removing a benchmark message cannot perturb another's numbers through the compiler's global inlining decisions. Run one shape isolated with task bench-iso -- <message> (for example task bench-iso -- media_frame); it builds only that message at the reproducible, layout-normalized profile (lto=true, codegen-units=1, plus 64-byte block alignment via -align-all-nofallthru-blocks=6, which removes the build-time code-layout lottery — see benchmarks/history/annotations.md). The available shapes are the per-message features in benchmarks/buffa/Cargo.toml.

Isolation needs one proto file per message, so each shape is also defined under benchmarks/proto/iso/. These mirror the message blocks in bench_messages.proto, which stays the single shape source for every other consumer (prost, the cross-implementation harnesses, dataset generation). task check-iso-protos asserts the two stay field-identical — run it after editing any benchmark message.

Reproducible benchmark roots per release

The history is reproducible because every release has a committed branch carrying the exact harness used to measure it. When you tag a release vX.Y.Z, create and push historical-benchmark/vX.Y.Z from that tag:

git branch historical-benchmark/vX.Y.Z vX.Y.Z
git push origin historical-benchmark/vX.Y.Z

Releases cut from main already carry the isolated harness, so this branch is just a stable pointer to measure from. The pre-isolation back-catalogue (v0.1.0–v0.7.1) has the harness retrofitted onto each of these branches instead. Either way, historical-benchmark/* is the source of truth for rebuilding any cell of the per-shape history — see benchmarks/history/DESIGN.md.