Skip to content

client: return one-frame response bodies without copying - #265

Merged
iainmcgin merged 3 commits into
connectrpc:mainfrom
allada:collect-body-zero-copy
Sep 26, 2026
Merged

iainmcgin merged 3 commits into
connectrpc:mainfrom
allada:collect-body-zero-copy

Conversation

@allada

@allada allada commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

collect_body_bounded copied every frame into a BytesMut, including the common case of a body that arrives whole in one frame. Hold the first frame by reference count and return it directly when no second frame follows; promote to a buffer only when one does.

Using http_body_util::Limited here instead would need B::Error to be std::error::Error rather than Display, which propagates to ServerStream and BidiStream and the transport trait's body. Not worth a public API break for this.

collect_body_bounded copied every frame into a BytesMut, including the
common case of a body that arrives whole in one frame. Hold the first
frame by reference count and return it directly when no second frame
follows; promote to a buffer only when one does.

Using http_body_util::Limited here instead would need B::Error to be
std::error::Error rather than Display, which propagates to ServerStream
and BidiStream and the transport trait's body. Not worth a public API
break for this.

Signed-off-by: Blaise Bruer <github.blaise@allada.com>

@iainmcgin iainmcgin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[claude code] Thanks — the rewrite is correct on the points that matter: the running len equals the old buf.len() at every check (empty frames add zero), the > max_size boundary is unchanged so a body of exactly max_size still succeeds, nothing is retained before the limit passes, and trailer frames behave as before. Full hands out its stored Bytes unchanged, so the pointer-equality test is valid and can't false-pass while src is alive.

Two things before it can merge:

1. It no longer compiles after a rebase onto main. main gave collect_body_bounded a third deadline: Option<Instant> argument (post-deadline body errors are classified as deadline_exceeded, #238), and the merge is textually clean because the hunks don't touch that arm — but the two new tests call the two-argument form and fail with E0061. , None on both fixes it locally; the rest of the gate then passes. Please also keep the classify_body_read_error arm intact when you resolve, since taking the PR side of that region would silently revert it.

2. Say that the returned Bytes may alias the transport buffer. For a one-frame body the value handed back is hyper's frame slice — a piece of the h2 codec buffer, or of the h1 adaptive read buffer, which grows to several hundred KiB — and the Connect unary path keeps it for the lifetime of the decoded response. That matches what http_body_util::Collected::to_bytes does, and we're going with zero-copy by default rather than a size threshold or a config option; the caller-local remedy is to copy what you retain. So it needs to be in the doc comment on collect_body_bounded, and the changelog should say it rather than naming collect_body_bounded itself, which is private and not something a user can look up. (The changelog's "exactly-sized buffer" is also only exact for a two-frame body; a third frame reallocates. "Sized for the frames seen so far", or drop the clause.)

Smaller:

  • The data.is_empty() { continue } branch has no test. With ChannelBody, frames ["", "foo"] should return "foo" by refcount, and ["foo", ""] should not promote to a BytesMut.
  • The multi-frame test's non-aliasing assertion compares against a from_static pointer in rodata, which a heap buffer can never equal, so it doesn't catch anything the b"foobarbaz" equality doesn't already; fine to drop.
  • The final match relies on head being None whenever joined is Some (true, via head.take()); a debug_assert! pins it.
  • "nothing else pins it" in the test doc isn't quite right — src holds the second refcount, which is what makes the assertion safe.
  • Fragment timestamp is hand-typed; task changelog-new generates one.

This stacks cleanly on #264. Iain will do the final review and merge.

The merge with main gave collect_body_bounded a deadline argument; pass
None in the tests that predate it.

Document that a one-frame body is returned without a copy and so may be
a slice of the transport's read buffer, say so in the changelog fragment
instead of naming the private function, and drop the claim of an
exactly-sized buffer.

Cover an empty frame before and after the only data frame, and pin that
a multi-frame body is joined into a new buffer. Assert that head and
joined are never both set.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
@iainmcgin

Copy link
Copy Markdown
Collaborator

[claude code] Pushed 4711503 onto your branch with the changes from the review, so this is ready to merge once CI passes:

  • Merged main. The two new tests now pass None for the deadline argument (E0061), and the classify_body_read_error arm is intact.
  • The doc comment on collect_body_bounded now says a one-frame body may be a slice of the transport's read buffer.
  • Added tests for an empty frame before and after the only data frame, and for a multi-frame body being joined into a new buffer. Dropped the from_static pointer assertion and added a debug_assert! that head and joined are never both set.
  • Regenerated the changelog fragment (Changed-20260924-190142.yaml). It is scoped to Connect unary and client-streaming responses, names OwnedView, says to_owned_message() still aliases bytes fields, and links this PR.

Thanks for the change.

@iainmcgin
iainmcgin added this pull request to the merge queue Sep 26, 2026
Merged via the queue into connectrpc:main with commit fb5f5aa Sep 26, 2026
14 checks passed
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