deps: move to buffa 0.9 - #233
Merged
Merged
Conversation
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
marked this pull request as ready for review
July 20, 2026 18:57
iainmcgin
enabled auto-merge
July 20, 2026 18:57
lovesegfault
approved these changes
Jul 20, 2026
This was referenced Jul 20, 2026
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>
This was referenced Aug 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 au32where the runtime now takes au64and does not compile. Theextern_pathcontract 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_messageinfallible off the back of this crate unwrapping it internally, soStreamMessage::to_owned_messageandUnaryResponse::into_owned_partsnow just return the message, and the doc paragraphs that existed to justify the unwrap go with them. Neither signature changes.StreamMessage::from_messageadditionally 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.