Skip to content

deps: move to buffa 0.9 - #233

Merged
iainmcgin merged 1 commit into
mainfrom
iain/buffa-0.9
Jul 20, 2026
Merged

deps: move to buffa 0.9#233
iainmcgin merged 1 commit into
mainfrom
iain/buffa-0.9

Conversation

@iainmcgin

@iainmcgin iainmcgin commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Moves the workspace to buffa 0.9 and regenerates the checked-in generated code.

The floor is hard rather than a preference: buffa 0.9 widens its size arithmetic to u64, so code emitted by a 0.8.x codegen passes a u32 where the runtime now takes a u64 and does not compile. The extern_path contract in the codegen docs moves to 0.9 along with it — those doc strings are written into generated output, so leaving them would have had every regenerated file advertise a floor the release notes contradict.

Source changes are small

Three call sites lose an unwrap. buffa made OwnedView::to_owned_message infallible off the back of this crate unwrapping it internally, so StreamMessage::to_owned_message and UnaryResponse::into_owned_parts now just return the message, and the doc paragraphs that existed to justify the unwrap go with them. Neither signature changes.

StreamMessage::from_message additionally lifts buffa 0.9's new element-memory budget. That budget bounds the footprint a decode may materialize in repeated, map, string and bytes elements — an amplification defence, charged on element footprint rather than contents. It cannot apply to bytes this process just encoded from a message it already holds, and leaving it in place made a legitimate message of many small elements panic on the constructor's own decode.

Worth knowing before upgrading

That element-memory budget (32 MiB by default) applies to received messages too, and rejects a message carrying very many small elements where buffa 0.8 accepted it. A large payload is unaffected however big it grows, because contents are not charged — only element footprint is. The regression test carries a control asserting the test bytes really are over the default, so a future retune of the budget fails the test rather than quietly hollowing it out.

This release does not yet let a server raise or lower that budget for received messages; exposing it as a configurable limit is a follow-up change.

Testing

56 test suites, lint, docs, and a live socket drive (40 KiB payloads round-tripping byte-identically, streaming order preserved, empty payload clean). Regeneration used the CI-pinned toolchain — protoc 33.5, buf 1.69.0, and a clean buffa v0.9.0 sibling — so the checked-in output matches what the generated-code CI job will produce.

buffa 0.9 widens its size arithmetic to u64, which makes the floor hard
rather than a preference: code emitted by a 0.8.x codegen passes a u32
where the runtime now takes a u64, so it does not compile. The
checked-in generated directories are regenerated here, and the
`extern_path` contract in the codegen docs moves to 0.9 with them —
those doc strings are written into generated output, so leaving them
would have had every regenerated file advertise a floor the release
notes contradict.

Three call sites lose an unwrap. buffa made `OwnedView::to_owned_message`
infallible off the back of this crate unwrapping it internally, so
`StreamMessage::to_owned_message` and `UnaryResponse::into_owned_parts`
now just return the message, and the doc paragraphs that existed to
justify the unwrap go with it. Neither signature changes.

`StreamMessage::from_message` also lifts the new element-memory budget.
That budget bounds the footprint a decode may materialize in repeated,
map, string and bytes elements — an amplification defence, charged on
element footprint rather than contents. It cannot apply to bytes this
process just encoded from a message it already holds, and leaving it in
place made a legitimate message of many small elements panic on the
constructor's own decode. The regression test carries a control
asserting those bytes really are over the default, so a future retune of
the budget fails the test instead of quietly hollowing it out.

Note for users upgrading: that budget applies to received messages, and
rejects a message carrying very many small elements where 0.8 accepted
it. A large payload is unaffected however big it grows.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
@iainmcgin
iainmcgin marked this pull request as ready for review July 20, 2026 18:57
@iainmcgin
iainmcgin enabled auto-merge July 20, 2026 18:57
@iainmcgin
iainmcgin added this pull request to the merge queue Jul 20, 2026
Merged via the queue into main with commit 7a268c3 Jul 20, 2026
14 checks passed
@iainmcgin
iainmcgin deleted the iain/buffa-0.9 branch July 20, 2026 19:06
EffortlessSteven pushed a commit to EffortlessSteven/connect-rust that referenced this pull request Aug 3, 2026
…nectrpc#232)

## Summary

Carries buffa 0.9's reference-counted encode segments from the encoder
all the way to the HTTP body, so a gRPC or gRPC-Web unary response whose
body is an `OwnedView` never copies its large fields.

A view's fields are slices into the buffer the view was decoded from. A
rope told about that buffer takes a large field by reference instead of
copying it into the encode buffer, and the segments then become their
own HTTP data frames. Past the framing threshold the encode stops
growing with the payload, because only the framing is still being
written.

Measured on a dedicated bare-metal instance
(`benches/rpc/view_rope_encode`, four-string message, contiguous vs
segmented):

| bytes per field | contiguous | segmented |
|---|---|---|
| 256 B | 171 ns | 184 ns |
| 1 KiB | 210 ns | 204 ns |
| 16 KiB | 2.29 µs | 682 ns |
| 256 KiB | 48.6 µs | 709 ns |
| 1 MiB | 363 µs | 643 ns |

The control is the part worth reading: an *unbacked* rope at 1 MiB costs
363 µs, identical to the contiguous encode. The rope machinery is free;
the reference-count capture is the entire effect.

## Where this deliberately does nothing

Segmenting is skipped wherever it would not pay, each guarded in code
rather than left to chance:

- **Owned-message bodies.** Their `string` and `bytes` fields are
`String` and `Vec<u8>`, which cannot be handed over by reference.
Routing them through a rope only adds cost.
- **Responses below the framing threshold.** Anything smaller is copied
into the framing buffer downstream regardless.
- **A response much smaller than the request it borrows from.**
Capturing would keep the whole request buffer alive until the response
finishes flushing — answering a 64 MiB upload with a 32 KiB summary
would hold 64 MiB per in-flight response. Copying is cheaper than that.
- **Connect unary, streaming, and any call through an interceptor.** See
below.

## Reach, stated plainly

This lands on gRPC and gRPC-Web unary responses. Connect unary still
flattens because its body type hardcodes `Full<Bytes>`; multiple HTTP/2
DATA frames concatenate to the same Connect body, so that path *could*
carry segments and is worth doing — Connect being the primary protocol.
Streaming re-envelopes per item and is the next piece of work. A call
with an interceptor configured takes the contiguous path, because an
interceptor may replace the whole body.

## Breaking change, confined to custom dispatch

`EncodedResponse` is now `Response<EncodedBody>` rather than
`Response<Bytes>`. This is reachable through the public `Dispatcher`
trait, so anyone implementing custom dispatch is affected: construct
with `Bytes::into()`, and recover a single buffer with
`EncodedBody::into_contiguous()`.

Interceptor authors need no change — `UnaryResponse` still carries a
contiguous `Payload`. That seam is what keeps the blast radius to
dispatch rather than every extension point.

## Builds on

Both prerequisites are now merged, and this branch is rebased onto
`main`: connectrpc#233 (the buffa 0.9 adoption, which supplies the rope and
`EncodeSink`) and connectrpc#219, whose framing emission path this reuses rather
than reimplementing.

## Testing

Server conformance 3600 passed / 0 failed, 56 test suites, lint, docs,
and `task generate:all` (no drift) all green — re-run after rebasing
onto `main`, not carried over from the stacked base.
`Envelope::encode_body_parts` has a table test sweeping empty,
sub-threshold contiguous, sub-threshold segmented, over-threshold
contiguous, two-segment and many-segment bodies, asserting in each that
the envelope header declares exactly the byte count that follows it and
that reassembly equals the contiguous envelope.

A high-effort review pass found four defects that produced correct bytes
and so would never have failed a test — buffer pinning, a gate that
compared whole-message size against a per-field threshold, tiny rope
fragments becoming their own frames, and `MaybeBorrowed` silently not
forwarding the segmented encode. All four are fixed in the final commit,
"response: only segment where it pays, and merge the fragments", whose
message carries the reasoning.

One known limitation left in place: a message that clears the gate while
none of its individual fields do lands everything in a rope tail that
grows by doubling rather than one sized allocation. buffa 0.9 exposes no
way to pre-size the tail, so it is documented where it bites rather than
worked around.

---------

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
EffortlessSteven pushed a commit to EffortlessSteven/connect-rust that referenced this pull request Aug 3, 2026
…c#235)

## Summary

buffa 0.9 bounds the memory a single decode may commit to repeated, map,
string and bytes elements, defaulting to 32 MiB. It is charged on
element *footprint* rather than contents, which is what makes it catch
the amplification a size limit cannot: a few bytes on the wire can ask
the decoder to materialize a very large number of small elements, each
with its own allocation. A single large payload is unaffected however
big it grows.

Since connectrpc#233 that budget has reached every received request as a constant.
A peer that legitimately sends many small elements — and that 0.8
accepted — is rejected, and there is nothing an operator can do about
it. This makes it configuration.

```rust
ConnectRpcService::new(router)
    .with_limits(Limits::default().element_memory_limit(128 * 1024 * 1024))
```

`Limits::unlimited()` lifts it along with the other two.

## Both decode paths, not just the obvious one

The value travels to the decoder on `RequestContext` for the view paths
and on `Payload` for the owned-message ones.

The second half is the part worth reviewing. Generated dispatch is
view-based throughout, so threading only `decode_borrowed_request_view`
and `decode_message_request_stream` looks complete and passes every
test. But `Router::route_unary` and the `*_handler_fn` registrations
decode through `Payload::take_message` and `decode_request`, and those
would have kept buffa's default silently — on exactly the handlers where
a fully materialized owned message costs most. `Payload` carries the
limits so its public `take_message` signature is unchanged.

## The rejection says what to do about it

`invalid_argument: failed to decode proto request: element memory limit
exceeded; if this peer is trusted, raise Limits::element_memory_limit`

Exceeding the budget is the one decode failure a server operator can fix
without the peer changing anything, so it names the limit. Every other
`DecodeError` keeps the bare message — pointing at a limit there would
send someone chasing a setting that cannot help. A test pins both
directions.

## Breaking, for generated code only

`decode_borrowed_request_view` and `decode_message_request_stream` take
the decode limits. Both are `#[doc(hidden)]` and called only from
generated dispatch, so regenerating is the whole migration — which 0.9.0
already requires for buffa 0.9. Doing it now costs nothing; doing it
later costs a second forced regeneration.

`Limits` also becomes `#[non_exhaustive]`, so the next limit is not
another break. Struct-literal construction becomes
`Limits::default().max_request_body_size(a).max_message_size(b)`; fields
stay public for reads.

## Incidental: the generated-code check was missing two directories

CI diffed four generated directories, `CONTRIBUTING` listed five, and
there are six. `connectrpc-health/src/generated` and
`connectrpc-reflection/src/generated` were unverified — and this change
regenerates both, so the gap was live rather than theoretical. The check
now covers all six and the doc is corrected.

## Not in scope

Clients still decode *responses* under buffa's defaults, with no
override. The server is where the budget bites first, so this ships
server-only; the `Limits` docs now say so rather than leaving the
asymmetry to be discovered.

## Testing

Clippy on the pinned 1.95 toolchain, `cargo test -p connectrpc
--no-default-features` (419), 56 test suites, lint, docs, server
conformance 3600/0, and `task generate:all` regenerated with the
CI-pinned protoc 33.5 / buf 1.69.0 and verified idempotent by hashing
every generated file across two runs.

Driven live over a socket against a generated service with a repeated
field, two servers differing only in this limit: default returns HTTP
400 naming the knob, `element_memory_limit(usize::MAX)` returns HTTP
200. The unit tests assert both halves too — rejected at the default,
accepted when raised — so wiring the limit to nothing cannot pass.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
sachaw added a commit to sachaw/protovalidate-buffa that referenced this pull request Aug 10, 2026
buffa 0.9.1 across the runtime, protos, codegen and conformance crates.
No source changes were needed — the 0.8 -> 0.9 delta does not touch any
API these crates use.

The `connect` feature needs a connectrpc built against buffa 0.9;
connectrpc/connect-rust#233 merged that on 2026-07-18 but the newest
release (0.8.1, 2026-07-02) predates it and still requires buffa
^0.8.1. main is still versioned 0.8.1, so a `[patch.crates-io]` on the
workspace satisfies the existing `connectrpc = "0.8"` requirement
without changing it.

That patch is for this fork only and must come out before this goes
upstream — replace it with a plain version bump once connect-rust cuts
a release.

Signed-off-by: Sacha Weatherstone <sachaw100@hotmail.com>
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.

2 participants