Skip to content

client: take an async Stream for client-streaming requests#227

Open
iainmcgin wants to merge 4 commits into
mainfrom
iain/client-stream-async
Open

client: take an async Stream for client-streaming requests#227
iainmcgin wants to merge 4 commits into
mainfrom
iain/client-stream-async

Conversation

@iainmcgin

@iainmcgin iainmcgin commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Client-streaming calls now take an async Stream of requests instead of a synchronous iterator.

call_client_stream and the generated client-streaming methods accept impl ClientRequestStream<Req> — a sealed trait implemented automatically for every Stream<Item = Req> + Send + 'static — so request messages can be produced as they become available — paced by timers, read from sockets, forwarded from channels — without buffering the whole upload or blocking a thread. The synchronous-iterator shape could not express this: there was no way to await between messages, which ruled out pump-style uploads (e.g. forwarding chunks under a per-chunk idle deadline).

Breaking change, targeted at 0.9.0. Migration for a ready collection is one line, with no new dependency:

// before
client.sum(requests).await?;
// after
client.sum(connectrpc::client::stream_iter(requests)).await?;

Stream (the futures 0.3 trait) and stream_iter (futures::stream::iter) are re-exported from connectrpc::client, and every generated client-streaming method carries the migration hint in its rustdoc. The bound trait carries #[diagnostic::on_unimplemented], so passing a collection directly produces compiler output that names stream_iter and explains the Send + 'static requirement, rather than a bare unmet-trait error.

Design: the stream is the request body

The stream is not pumped by a library-side drain loop — it backs the HTTP request body directly (EncodingBody, which envelope-encodes each message in poll_frame). The transport polls for the next frame only while it can send, so:

  • backpressure is HTTP/2 flow control (peak memory ~one envelope, previously a 32-envelope channel);
  • a server that ends the RPC mid-upload produces a response and the call returns, even while the caller's stream is idle — previously unobservable until the deadline;
  • a server that sends response headers early while continuing to read the upload keeps receiving messages (a drain loop racing the response future would truncate here — caught by review and pinned with a regression test).

An encode failure is emitted through the body (aborting the request) and mirrored into the call's return value so the caller sees the precise error. The stream is held in a sync_wrapper::SyncWrapper (new tiny zero-dep dependency, the axum pattern) so the boxed body is Sync without requiring Sync of the caller's stream. The previous channel + detached-send-task machinery for client-streaming is deleted; bidi is unchanged (its push-style send() API keeps the channel).

Changes

  • connectrpc/src/client/mod.rs: signature, EncodingBody, Stream/stream_iter re-exports, rewritten docs with # Errors.
  • connectrpc-codegen/src/codegen.rs: generated arg type + doc hint; checked-in generated dirs regenerated.
  • Call sites updated: conformance client, benches, streaming-tour example, e2e tests; docs/guide.md shows both stream_iter and a channel-backed producer.
  • New e2e tests: async producer paced by a channel; early-response-headers-do-not-truncate-upload; early-server-response-unblocks-pending-stream.

Testing

  • task lint / task test green; clippy all-targets -D warnings clean; docs with broken-links denied; MSRV (1.88), wasm32, and minimal-features checks pass.
  • Conformance client suites: Connect 2580, gRPC 1454, gRPC-Web 2838 — all passing.
  • Live socket drives (HTTP/1.1 and HTTP/2): a producer pacing 3 messages 100 ms apart aggregates correctly (elapsed ≥ 300 ms proves incremental sending); a handler rejecting without draining returns in ~2 ms against a never-yielding stream (previously an unbounded hang).
  • cargo semver-checks vs 0.8.1 passes (the break is in an impl Trait argument bound, outside its lint coverage); declared in the changelog fragment.

iainmcgin added 3 commits July 9, 2026 11:14
Client-streaming calls previously took a synchronous iterator, which
cannot express uploads whose messages become available over time
(paced producers, socket readers, channel consumers) without buffering
the whole request or blocking a thread. call_client_stream and the
generated client methods now take impl Stream<Item = Req> + Send +
'static.

The stream backs the HTTP request body directly: the new EncodingBody
envelope-encodes each message in poll_frame, replacing the previous
bounded-channel-plus-detached-send-task pump. This hands upload
liveness to the HTTP layer: backpressure is flow control, a server
that ends the RPC mid-upload surfaces immediately even while the
caller's stream is idle, and a server that sends response headers
early while continuing to read the upload keeps receiving messages.
An encode failure aborts the request and is surfaced as the call's
error even when a success response races the abort.

The Stream trait and a stream_iter adapter (futures::stream::iter) are
re-exported from connectrpc::client, so migrating a ready collection
is client.sum(connectrpc::client::stream_iter(requests)) with no new
caller dependency. Adds sync_wrapper so the boxed body is Sync without
requiring Sync of the caller's stream.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
Replace the raw impl Stream<Item = Req> + Send + 'static bound on
client-streaming calls with impl ClientRequestStream<Req> - a sealed
trait implemented automatically for every qualifying stream. Its
#[diagnostic::on_unimplemented] attribute turns the common misuse
(passing a collection directly) into actionable compiler output that
names the stream_iter adapter and explains the Send + 'static
requirement, and a borrowing stream's lifetime error now points at a
documented, named bound. Named ClientRequestStream to avoid colliding
with the server-side dispatcher::RequestStream byte-stream alias.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
client_stream_many_small (1000 small messages) isolates the per-message
path from call setup; client_stream_large (10 x 1 MiB) makes the
per-message encode/copy cost significant relative to the wire. Both
were used to compare the stream-backed request body against the
previous channel design on dedicated hardware. iter_batched keeps the
input clone out of the timed section.

No-Verification-Needed: bench-only change, no runtime surface
Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
@iainmcgin

Copy link
Copy Markdown
Collaborator Author

[claude code] Benchmarked the stream-as-body design against the previous channel-based client-streaming (base 4da239d) to answer whether the one-envelope-at-a-time body needs a buffer. Two independent criterion compare runs on a dedicated bare-metal host (c7i.metal-24xl, no shared tenancy), baseline and candidate binaries interleaved on the same box; untouched paths (unary/small, server_stream) as drift controls, which stayed ≤ ~1.7% throughout. Positive = this PR slower.

bench run 1 run 2
client_stream (10 small msgs) — connect / grpc / grpc-web −2.0% / −2.2% / −2.0% −1.2% / −1.6% / −0.9%
client_stream_many_small (1000 msgs) — connect / grpc / grpc-web −3.5% / −2.0% / −3.2%
client_stream_large (10 × 1 MiB) — connect +9.4% +6.1%
client_stream_large — grpc p=0.77 (±13%) p=0.20 (±10%)
client_stream_large — grpc-web −6.2% +5.8%

Reading:

  • Small-message streaming got 1–3.5% faster, consistent across six benches and both runs: the per-message cross-task hop (bounded channel + wakeups + oneshot bridge) is gone, and the transport pulls frames directly. No buffering needed to keep the wire busy — HTTP/2 flow-control windows and socket buffers already do that.
  • 1 MiB-message uploads over HTTP/1.1 + Connect regressed ~6–9%, reproducibly. The magnitude matches lost encode/send overlap: each message costs ~2 MiB of encode+envelope memcpy that previously ran on the caller's task concurrently with the send task's socket writes, and now serializes on one task. The other two large benches are dominated by allocator bimodality at 1 MiB payloads (grpc-web flipped sign between runs; grpc never reached significance).
  • The regression's substance is exactly the copy work already queued for 0.9.0: server: pass large response payloads through by refcount instead of copying #219's refcount-chained framing removes the envelope memcpy for ≥16 KiB payloads, and the upstream encode work removes the encode-side copy. Re-running client_stream_large stacked on server: pass large response payloads through by refcount instead of copying #219 should confirm most of it comes back.

The two new bench groups (client_stream_many_small, client_stream_large) are added in this PR so the comparison stays reproducible.

#226 fixed the channel-era client-stream leak by racing the detached
send task against receiver closure. The stream-as-body design has no
detached task — dropping the call future drops the transport send and
the request body directly — so the mechanism is superseded while the
guarantees are kept:

- the # Cancellation rustdoc sections are ported (client-stream text
  adapted to the direct-drop mechanism; server-stream text unchanged)
- all five #226 tests are ported to the stream API; the mid-drain
  backpressure case becomes an unread-request-body case, since there
  is no drain loop or channel to park in
- tokio/macros stays on the base dependency per #226's consolidation,
  with the comment narrowed to the server accept loop now that the
  client-stream path no longer uses select!
- the #226 changelog fragment is reworded to describe the behavior
  (abandonment stops the in-flight send) rather than the superseded
  background-task mechanism

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
@iainmcgin iainmcgin marked this pull request as ready for review July 12, 2026 17:49
@iainmcgin

Copy link
Copy Markdown
Collaborator Author

[claude code] Rebased onto main via merge: #226's abandonment fix targeted the channel-era detached send task, which this PR deletes — dropping the call future now drops the transport send and request body directly, so the guarantee holds structurally. All five #226 tests are ported to the stream API (the mid-drain backpressure case becomes an unread-request-body case, since there is no drain loop to park in), the # Cancellation rustdoc is kept with the mechanism description updated, tokio/macros stays consolidated on the base dependency (comment narrowed to the server accept loop), and the #226 changelog fragment is reworded to describe the behavior rather than the superseded mechanism. Full battery re-run post-merge: 562 all-features unit tests, Connect client conformance 2580/2580, lint/docs/MSRV/wasm/minimal clean.

@lovesegfault lovesegfault 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.

Took a close look at the redesign — really nice work; no correctness or safety concerns from me. Comments below are all non-blocking: a few doc notes on correct-but-surprising behavior, plus small polish ideas.

// the precise encode error instead of the generic transport failure —
// unconditionally, because a server's early response can race the abort
// and produce an `Ok` result for a truncated, encode-aborted upload.
if let Some(err) = encode_error

@lovesegfault lovesegfault Jul 13, 2026

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.

This covers the early-Ok race, but there's a second direction: with a background-driven body, an encode error can land after this check and is then never read — the call returns Ok for a truncated tail. I think that's fine (the server already finished its response), but a sentence saying it's intentional would help a future reader.

#[doc = " are sent as the stream yields them. It backs the request"]
#[doc = " body, so yield owned messages or feed the call from a"]
#[doc = " channel-backed stream. For a collection that is already in"]
#[doc = " hand, wrap it with `::connectrpc::client::stream_iter(...)`."]

@lovesegfault lovesegfault Jul 13, 2026

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.

Could we add a cancellation note here? This is the doc users actually read, and dropping the returned future (e.g. a timeout firing) now silently drops unsent messages. Something like: "Dropping the returned future cancels the call; unsent messages are never delivered." A matching sentence in the guide would be nice too.

/// so the compiler can point at the two usual fixes when the bound is not
/// met: wrap a ready collection with [`stream_iter`], and make a borrowing
/// stream yield owned messages (the stream backs the request body, which can
/// outlive the call frame and move across threads — hence `Send + 'static`).

@lovesegfault lovesegfault Jul 13, 2026

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.

Worth a sentence here on panics: the stream is polled on the transport's task now, so a panic in poll_next surfaces as a generic transport error instead of propagating (and can take down a shared connection). Would save someone a confusing debug session.

Req: buffa::Message + crate::codec::JsonSerialize,
{
type Data = Bytes;
type Error = ConnectError;

@lovesegfault lovesegfault Jul 13, 2026

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.

Might as well add:

fn is_end_stream(&self) -> bool {
    self.done
}

The default is always false, so downstream layers need an extra poll_frame to notice exhaustion — done already means exactly this.

// can surface the precise error instead of a generic transport failure.
let encode_error: std::sync::Arc<std::sync::Mutex<Option<ConnectError>>> =
std::sync::Arc::default();
let body: ClientBody = EncodingBody {

@lovesegfault lovesegfault Jul 13, 2026

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.

Nothing forces a construction site to also drain the mirror — the pairing is convention only. Maybe EncodingBody::new(...) -> (Self, EncodeErrorSlot) with a #[must_use] slot? Small change, makes the contract compiler-visible.

service: &str,
method: &str,
requests: impl IntoIterator<Item = Req>,
requests: impl ClientRequestStream<Req>,

@lovesegfault lovesegfault Jul 13, 2026

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.

Style nit, take it or leave it: the other call_* fns name all their generics; S: ClientRequestStream<Req> would match them and show the bound in rustdoc. (Not about turbofish — that already works; naming it would just add a _ to the conformance call.)

/// Re-export of [`futures::stream::iter`]: adapts a collection that is
/// already in hand into a request stream for a client-streaming call,
/// without a direct `futures` dependency.
pub use futures::stream::iter as stream_iter;

@lovesegfault lovesegfault Jul 13, 2026

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.

Small one: we re-export UnaryResponse/ServerStream/BidiStream at the crate root, but not stream_iter, which most callers will want. pub use client::stream_iter; in lib.rs would shorten the common call site.

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