Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- **Generated `FooOwnedView` wrapper types.** When views are generated, each
message now also gets a `FooOwnedView` — re-exported at the package root
next to `Foo` and `FooView` (canonical path `__buffa::view::FooOwnedView`):
a self-contained `'static` handle wrapping `OwnedView<FooView<'static>>`
with one accessor method per field (`owned.name()`, `owned.id()`, …). Every
accessor borrows from `&self`, so field data can never outlive the
underlying buffer, and the handle stays `Send + Sync` for async handlers and
spawned tasks. The wrapper forwards `decode` / `decode_with_options` /
`from_owned` / `to_owned_message` / `bytes` / `into_bytes`, exposes the full
view via `view()`, converts to and from the raw `OwnedView`, and serializes
to protobuf JSON when `generate_json` is enabled. A field or oneof whose
name collides with one of the wrapper's reserved method names keeps working
through `view()`; its accessor is skipped with a build warning
(`CodeGenWarning::OwnedViewAccessorSuppressed`).
- **Vtable reflection mode.** Generated types now implement
`buffa_descriptor::reflect::ReflectMessage` directly — on both the owned
structs and the zero-copy view types — so `foo.reflect()` borrows `foo` in
Expand Down Expand Up @@ -36,6 +50,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Changed

- **`OwnedView<V>` no longer implements `Deref<Target = V>`.** **Breaking.**
The `Deref` impl exposed the inner view as `FooView<'static>`, so borrowed
fields appeared `'static` to the compiler and could be held past the point
where the `OwnedView` (and the buffer they point into) was dropped — safe
code could end up reading freed memory. In practice this required the
calling application to deliberately store a field reference beyond the
handle's lifetime, so the practical exposure is limited, but the API should
not allow it at all. Field access now goes through `reborrow()` (one extra
call per scope: `let person = owned.reborrow(); person.name`) or, more
conveniently, the new generated `FooOwnedView` accessor methods, both of
which tie every borrow to the handle. Serializing the handle directly
(`serde_json::to_string(&owned_view)`) is unaffected.
- **`generate_reflection(true)` now selects vtable mode** (previously bridge).
The reflective API is unchanged (`foo.reflect().get(fd)`), so call sites do not
change, but generated code grows by one `impl ReflectMessage` per type. Opt
Expand Down
11 changes: 7 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,16 @@ let owned: Person = request.to_owned_message();

**`OwnedView<V>` — views across async boundaries:**

The scoped `'a` lifetime on `MyMessageView<'a>` prevents it from satisfying `'static` bounds, which tower services, `BoxFuture<'static, _>`, and `tokio::spawn` all require. `OwnedView<V>` solves this by storing the `bytes::Bytes` buffer alongside the decoded view in a self-referential struct. Internally it extends the view's lifetime to `'static` via `transmute`, which is sound because `Bytes` is reference-counted (its heap data pointer is stable across moves), immutable, and a manual `Drop` impl ensures the view is dropped before the buffer. `OwnedView` implements `Deref<Target = V>`, so field access is identical to using the raw view — no `.get()` calls required.
The scoped `'a` lifetime on `MyMessageView<'a>` prevents it from satisfying `'static` bounds, which tower services, `BoxFuture<'static, _>`, and `tokio::spawn` all require. `OwnedView<V>` solves this by storing the `bytes::Bytes` buffer alongside the decoded view in a self-referential struct. Internally it extends the view's lifetime to `'static` via `transmute`, which is sound because `Bytes` is reference-counted (its heap data pointer is stable across moves), immutable, and a manual `Drop` impl ensures the view is dropped before the buffer. The synthetic `'static` is never exposed: there is no `Deref<Target = V>` impl (that would let field borrows escape the handle's scope), and access goes through `reborrow()`, which returns the view with its lifetime tied to the `OwnedView`. For ergonomics, codegen also emits a per-message `FooOwnedView` wrapper with one `&self`-tied accessor method per field.

```rust,ignore
use buffa::view::OwnedView;

// In an RPC handler — bytes arrives as Bytes from hyper
let view = PersonOwnedView::decode(bytes)?;
println!("name: {}", view.name()); // accessor, zero-copy, 'static + Send

// Or, with the generic handle:
let view = OwnedView::<PersonView>::decode(bytes)?;
println!("name: {}", view.name); // Deref, zero-copy, 'static + Send
println!("name: {}", view.reborrow().name);
```

**Generated code layout — the `__buffa::` sentinel tree:**
Expand All @@ -232,6 +234,7 @@ Ancillary generated items (views, oneof enums, file-level extensions, the per-pa
<pkg>::Foo # owned struct (unchanged)
<pkg>::foo::Bar # nested owned (unchanged)
<pkg>::__buffa::view::FooView<'a> # view struct
<pkg>::__buffa::view::FooOwnedView # 'static owned-view wrapper (accessor methods)
<pkg>::__buffa::view::foo::BarView<'a> # nested view (mirrors owned tree)
<pkg>::__buffa::view::oneof::foo::Kind<'a> # view oneof enum (no suffix)
<pkg>::__buffa::oneof::foo::Kind # owned oneof enum (no suffix)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,8 @@ let view = MyMessageView::decode_view(&bytes).unwrap();
println!("name: {}", view.name); // &str, no allocation

// Decode (owned view — zero-copy + 'static, for async/RPC use)
let owned_view = OwnedView::<MyMessageView>::decode(bytes.into()).unwrap();
println!("name: {}", owned_view.name); // still zero-copy, but 'static + Send
let owned_view = MyMessageOwnedView::decode(bytes.into()).unwrap();
println!("name: {}", owned_view.name()); // still zero-copy, but 'static + Send
```

### JSON serialization (with `json` feature)
Expand Down
36 changes: 36 additions & 0 deletions buffa-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub(crate) mod impl_text;
pub(crate) mod imports;
pub(crate) mod message;
pub(crate) mod oneof;
pub(crate) mod owned_view;
pub(crate) mod reflect;
pub(crate) mod reflect_owned;
pub(crate) mod reflect_view;
Expand Down Expand Up @@ -790,6 +791,18 @@ pub enum CodeGenWarning {
/// Proto values that would convert to an invalid Rust identifier.
invalid: Vec<String>,
},
/// A field or oneof accessor on a generated `FooOwnedView` wrapper was
/// suppressed because the proto name collides with one of the wrapper's
/// reserved method names (`decode`, `view`, `bytes`, …). The field stays
/// fully accessible through `view()` on the wrapper (or
/// `OwnedView::reborrow`).
#[non_exhaustive]
OwnedViewAccessorSuppressed {
/// The Rust name of the wrapper type (e.g. `FooOwnedView`).
wrapper_name: String,
/// The proto field or oneof name whose accessor was suppressed.
field_name: String,
},
}

impl core::fmt::Display for CodeGenWarning {
Expand Down Expand Up @@ -821,6 +834,16 @@ impl core::fmt::Display for CodeGenWarning {
}
Ok(())
}
Self::OwnedViewAccessorSuppressed {
wrapper_name,
field_name,
} => {
write!(
f,
"`{wrapper_name}`: accessor for field `{field_name}` suppressed \
(collides with a reserved wrapper method); use `.view().{field_name}` instead"
)
}
}
}
}
Expand Down Expand Up @@ -1252,6 +1275,19 @@ fn generate_proto_content(
ctx.config.feature_gates().views,
),
});
// The owned-view wrapper gets the same natural-path treatment as
// the view struct, so `pkg::FooOwnedView` works out of the box.
let owned_view_ident = format_ident!("{top_level_name}OwnedView");
root_reexports.push(message::ReexportCandidate {
name: owned_view_ident.to_string(),
tokens: feature_gates::cfg_block(
quote! {
#[doc(inline)]
pub use self :: #sentinel :: view :: #owned_view_ident;
},
ctx.config.feature_gates().views,
),
});
}
}

Expand Down
10 changes: 10 additions & 0 deletions buffa-codegen/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,7 @@ fn collect_natural_reexports(
if ctx.config.generate_views {
let views_gate = ctx.config.feature_gates().views;
// Nested-message views: `__buffa::view::<msg>::BarView` → `BarView`.
// The owned-view wrapper rides along: `BarOwnedView` → `BarOwnedView`.
for nested in non_map_nested {
let view_ident = format_ident!("{}View", nested.name.as_deref().unwrap_or(""));
candidates.push(ReexportCandidate {
Expand All @@ -921,6 +922,15 @@ fn collect_natural_reexports(
views_gate,
),
});
let owned_view_ident =
format_ident!("{}OwnedView", nested.name.as_deref().unwrap_or(""));
candidates.push(ReexportCandidate {
name: owned_view_ident.to_string(),
tokens: crate::feature_gates::cfg_block(
quote! { #inline pub use #view_prefix #owned_view_ident; },
views_gate,
),
});
}
// View oneof enums: `__buffa::view::oneof::<msg>::Kind` → `KindView`.
for (_, enum_ident) in &oneof_pairs {
Expand Down
Loading
Loading