Skip to content

buffa-codegen: configurable Box wrapping for message-type oneof variants #126

@christopherwxyz

Description

@christopherwxyz

Current behavior

In buffa-codegen/src/oneof.rs:

pub(crate) fn is_boxed_variant(ty: Type) -> bool {
    matches!(ty, Type::TYPE_MESSAGE | Type::TYPE_GROUP)
}

Every message-typed oneof variant is unconditionally wrapped in ::buffa::alloc::boxed::Box<T>:

pub enum Body {
    Foo(::buffa::alloc::boxed::Box<FooBody>),
    Bar(::buffa::alloc::boxed::Box<BarBody>),
    // ...
}

The doc comment near is_boxed_variant notes this is "consistent with MessageField<T> being Option<Box<T>> for singular message fields." Sound rationale, but no opt-out exists today.

Pain point

In code that constructs oneof variants frequently — test fixtures, builders, codegen consumers — every site becomes:

parent.body = Some(message_data::Body::Foo(
    ::buffa::alloc::boxed::Box::new(FooBody { ... })
));

The From<T> impl buffa-codegen emits helps a little (Body::from(foo)Body::Foo(Box::new(foo))), but it's not always applicable — for example when multiple variants share a Rust type (the codegen explicitly skips From for those, per the comment around line 220 of oneof.rs).

For small messages or hot construction paths, the indirection costs more than it saves: an extra allocation per variant and noisier code at every construction site.

Prior art

prost-build defaulted to not boxing and let callers opt in per-field:

prost_build::Config::new()
    .boxed(".my.package.MyMessage.deep_nested_field")
    .compile_protos(&[...], &[...])?;

This put the size/indirection tradeoff in the user's hands.

Proposal

Add a config knob — one of:

Option A: global toggle (default keeps current behavior).

Config::new().box_message_oneof_variants(false)

Option B: prost-style per-path opt-out.

Config::new().unbox(".my.package.MyMessage.body.small_variant")

Option C: a single "box only when X" heuristic — e.g. box only if the variant struct is statically known to be ≥ N bytes, where N is configurable.

(A) is simplest and matches Config::preserve_unknown_fields(false)'s style. (B) is more granular and matches the existing prost migration story. Either feels in keeping with the existing Config builder.

Default should probably stay as today (always-box) to preserve the documented "consistent with MessageField<T>" invariant without breaking existing code.

Why this matters

MessageField<T> is Option<Box<T>> because it might be unset; the Box there is paying for absence. A oneof variant is present when matched — there's no absence to encode. The "consistency" argument is therefore a code-shape consistency, not a semantic necessity. For consumers who'd rather take the size hit on the enum to win construction ergonomics, the opt-out is worth having.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions