Skip to content

Proto-first gRPC client streaming via new StreamAsync<TRequest, TResponse> overload#3459

Open
erikshafer wants to merge 3 commits into
JasperFx:mainfrom
erikshafer:grpc-client-streaming-codegen
Open

Proto-first gRPC client streaming via new StreamAsync<TRequest, TResponse> overload#3459
erikshafer wants to merge 3 commits into
JasperFx:mainfrom
erikshafer:grpc-client-streaming-codegen

Conversation

@erikshafer

@erikshafer erikshafer commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Closes the gap the gRPC work has been carrying since the streaming milestones (#2525, #2565): client streaming (stream TRequest → TResponse) was classified by GrpcServiceChain and then deliberately fail-fasted — the error text said "no adapter path yet", and WolverineGrpcExceptionInterceptor had a matching "deferred pending the matching IMessageBus overloads" note. This PR is that deferred work: the missing bus overload plus the codegen that consumes it. All four canonical RPC shapes are now generated on the proto-first path.

The core primitive

ICommandBus gains the inverse overload of StreamAsync<T> — same name, per that comment's original "overloads" intent; arity separates the two directions (one type argument streams responses out, two stream requests in):

Task<TResponse> StreamAsync<TRequest, TResponse>(
    IAsyncEnumerable<TRequest> messages, CancellationToken cancellation = default, TimeSpan? timeout = default);
// + DeliveryOptions overload

The pleasant surprise: almost no new machinery was needed. Handler discovery already accepts IAsyncEnumerable<TRequest> as a message type (first-parameter rule, no filter excludes closed generic interfaces), the chain compiles untouched, and MessageFrame's cast is legal. The only thing actually missing was dispatch — message.GetType() on a stream is a compiler-generated iterator type, so the new method keys Runtime.FindInvoker(typeof(IAsyncEnumerable<TRequest>)) explicitly and then reuses Executor.InvokeAsync<T> verbatim (envelope pooling, correlation, tracing, cascading flush all inherited). Handlers consume the live stream incrementally — nothing is materialized. Local invocation only, same contract as StreamAsync; a missing handler throws a NotSupportedException that spells out the expected handler signature instead of falling into remote routing.

I considered the alternative of draining to TRequest[] inside the generated wrapper and riding the existing InvokeAsync<T> — zero core change, and T[] is already a routable message type via the batching subsystem. Rejected it for semantics: the handler couldn't see item 1 until item N arrived, memory scales with stream length, and Handle(TRequest[]) would silently overload the batching subsystem's meaning of that signature.

Heads-up on the one breaking edge: two new ICommandBus members, so custom IMessageBus implementors need a recompile-plus-implement (same shape as when StreamAsync landed). In-repo that was TestMessageContext and SendingEnvelopeLifecycle; called out in the CHANGELOG.

The gRPC side

  • AssertNoUnsupportedStreamingKinds is deleted outright — with client streaming generatable there are no unsupported kinds left. ClientStreamingMethods joins the classified lists, and the new ForwardClientStreamToMessageBusFrame emits a one-liner override:

    return await _bus.StreamAsync<EchoRequest, EchoReply>(
        WolverineGrpcStreamAdapters.ReadAllAsync(requestStream, context.CancellationToken),
        context.CancellationToken);
  • WolverineGrpcStreamAdapters.ReadAllAsync exists because the stock Grpc.Core.AsyncStreamReaderExtensions is declared by both Grpc.Core.Api and Grpc.Net.Common — generated code calling it is instantly CS0433 in any app referencing both. Wolverine's own trivial adapter sidesteps the collision.

  • Before/after middleware and the Validate convention are not woven into client-streaming methods — same rationale as bidi, no single TRequest in scope. Tenant detection is covered (it only needs the trailing ServerCallContext, which every proto-first shape has).

  • WolverineGrpcExceptionInterceptor gains the ClientStreamingServerHandler override its own doc comment was deferring — without it, a cancelled client-stream surfaced as Unknown instead of Cancelled (there's a test pinning that now). Bidi interception stays deferred; its wrapper streams responses incrementally, so trailing translation is a different problem.

  • GrpcRpcStreamKind.ClientStreaming is appended (append-only, in case CritterWatch persists values) and the endpoint manifest surfaces the descriptors: RequestType is the per-item element type, response unwrapped from Task<TResponse>.

Tests, sample, docs

  • New GrpcClientStreaming end-to-end suite + invoke_stream_async_support core acceptance suite (empty stream, mid-drain fault, cancellation, cascading tuple, no-handler error, DeliveryOptions). The old fail-fast tests are inverted into construction-succeeds assertions. CoreTests 1931/1931 on net9.0 + net10.0, Wolverine.Grpc.Tests 281/281, wolverine.slnx Release build clean.
  • GreeterProtoFirstGrpc gains CollectGreetings (stream HelloRequest) → GreetingSummary across proto/handler/client/README, verified by running server + client. While doing that I found the sample crashing at startup on 6.0 — it never picked up the WolverineFx.RuntimeCompilation reference during the Roslyn decoupling (the F# samples did). Fixed here for this sample; the other gRPC sample servers likely share the defect and are left for a follow-up sweep.
  • Docs updated across the gRPC guide (streaming gets a full client-streaming section; the "unsupported" statements in index/streaming/contracts are gone), message-bus.md gets a "Streaming Requests" section beside "Streaming Responses", and the CHANGELOG gets its first gRPC entries.

Not in scope

The code-first (protobuf-net.Grpc) generated-implementation path still skips IAsyncEnumerable<TRequest>-parameter methods — and its classifier would call that shape bidi regardless of return type. Documented as a limitation; fixing it is a separate, smaller change if anyone asks for it.

Smoke numbers, for flavor (Release, localhost, one connection): ~88k items/sec sustained through a 100k-item stream at a flat ~11 µs/item, and a complete 2-item client stream costs 212 µs vs 203 µs for a bare unary SayHello — the whole generated-wrapper + bus hop adds single-digit microseconds over the cheapest possible RPC.

🤖 Generated with Claude Code

Adds the fourth canonical gRPC RPC shape (stream TRequest -> TResponse) to
the proto-first codegen path, backed by a new core streaming-input primitive:

- IMessageBus.InvokeStreamAsync<TRequest, TResponse>(IAsyncEnumerable<TRequest>)
  dispatches a whole inbound stream to one handler invocation keyed on the
  closed IAsyncEnumerable<TRequest> message type, awaiting a single response.
  Local invocation only; reuses the existing Executor.InvokeAsync path.
- GrpcServiceChain drops the client-streaming fail-fast, classifies onto a new
  ClientStreamingMethods list, and emits ForwardClientStreamToMessageBusFrame.
  WolverineGrpcStreamAdapters.ReadAllAsync bridges IAsyncStreamReader<T> to
  IAsyncEnumerable<T> (the stock extension collides across Grpc.Core.Api and
  Grpc.Net.Common).
- The server exception interceptor now covers client-streaming handlers, and
  IGrpcEndpointManifest surfaces GrpcRpcStreamKind.ClientStreaming.
- GreeterProtoFirstGrpc gains a CollectGreetings RPC (plus the runtime
  compilation reference the sample lost in the 6.0 Roslyn decoupling), docs
  updated across the gRPC guide, and CHANGELOG gets its first gRPC entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 18, 2026 02:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new core message-bus primitive to support stream-in / single-response request handling (IAsyncEnumerable<TRequest> → Task<TResponse>), and wires proto-first gRPC codegen to use it so client-streaming RPCs (stream TRequest → TResponse) are now generated end-to-end (alongside unary, server-streaming, and bidi).

Changes:

  • Introduces ICommandBus.InvokeStreamAsync<TRequest, TResponse>(IAsyncEnumerable<TRequest>, …) (+ DeliveryOptions overload) and implements it in MessageBus (local-only dispatch with a clear no-handler failure).
  • Extends proto-first gRPC wrapper generation to forward client-streaming methods via a new WolverineGrpcStreamAdapters.ReadAllAsync() bridge (avoids ReadAllAsync ambiguity between Grpc.Core.Api and Grpc.Net.Common).
  • Updates endpoint manifests, exception interception, tests, samples, docs, and changelog to reflect/support client streaming.

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/Wolverine/Transports/Sending/SendingEnvelopeLifecycle.cs Implements the new InvokeStreamAsync overloads by delegating to the inner bus.
src/Wolverine/TestMessageContext.cs Adds InvokeStreamAsync support to the test bus/context for stubbing and assertions.
src/Wolverine/Runtime/MessageBus.cs Implements InvokeStreamAsync with explicit dispatch on typeof(IAsyncEnumerable<TRequest>) and a local-only guard.
src/Wolverine/IMessageBus.cs Adds the new InvokeStreamAsync overloads to ICommandBus (and thus IMessageBus).
src/Wolverine/Configuration/GrpcEndpointManifest.cs Extends GrpcRpcStreamKind with ClientStreaming and updates descriptor docs.
src/Wolverine.Grpc/WolverineGrpcStreamAdapters.cs Adds Wolverine-owned ReadAllAsync adapter to avoid CS0433 ambiguity in generated code.
src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs Intercepts client-streaming RPCs for exception translation (keeps bidi deferred).
src/Wolverine.Grpc/GrpcServiceChain.cs Enables proto-first client-streaming codegen and emits the new forward frame.
src/Wolverine.Grpc/GrpcEndpointManifest.cs Surfaces proto-first client-streaming endpoints in the gRPC endpoint manifest.
src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj Adds the new proto for client-streaming test coverage.
src/Wolverine.Grpc.Tests/ProtoFirst/proto_first_grpc_tests.cs Adds a proto-first client-streaming round-trip test and updates classification assertions.
src/Wolverine.Grpc.Tests/GrpcClientStreaming/Protos/grpc_client_stream_test.proto New client-streaming test contract (stream NumberRequest → SumReply).
src/Wolverine.Grpc.Tests/GrpcClientStreaming/grpc_client_streaming_tests.cs New end-to-end suite validating forwarding + cancellation behavior.
src/Wolverine.Grpc.Tests/GrpcClientStreaming/CollectStub.cs New proto-first stub to trigger Wolverine wrapper generation for client streaming.
src/Wolverine.Grpc.Tests/GrpcClientStreaming/CollectHandler.cs New handler folding an inbound stream into a single response.
src/Wolverine.Grpc.Tests/GrpcClientStreaming/ClientStreamingFixture.cs New in-proc gRPC host fixture for client-streaming tests.
src/Wolverine.Grpc.Tests/GrpcBidiStreaming/Protos/grpc_bidi_test.proto Updates test proto comments now that client-streaming is supported.
src/Wolverine.Grpc.Tests/GrpcBidiStreaming/grpc_bidi_streaming_tests.cs Inverts prior “fail-fast” assertions into “construction succeeds + classified” tests.
src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3265.cs Adds manifest assertions for proto-first client-streaming descriptors.
src/Wolverine.Grpc.Tests/grpc_capabilities_descriptor_source_3267.cs Extends capabilities coverage to include client-streaming origins.
src/Testing/CoreTests/TestMessageContextTests.cs Adds unit coverage that TestMessageContext records and responds to InvokeStreamAsync.
src/Testing/CoreTests/Acceptance/invoke_stream_async_support.cs New core acceptance suite covering stream invocation semantics and edge cases.
src/Samples/GreeterProtoFirstGrpc/Server/Server.csproj Fixes sample runtime codegen dependency via Wolverine.RuntimeCompilation reference.
src/Samples/GreeterProtoFirstGrpc/Server/GreeterHandler.cs Adds a client-streaming handler folding requests into GreetingSummary.
src/Samples/GreeterProtoFirstGrpc/Server/GreeterGrpcService.cs Updates sample stub docs to include client streaming support.
src/Samples/GreeterProtoFirstGrpc/README.md Documents the new client-streaming sample call and behavior.
src/Samples/GreeterProtoFirstGrpc/Messages/Protos/greeter.proto Adds CollectGreetings (stream HelloRequest) → GreetingSummary.
src/Samples/GreeterProtoFirstGrpc/Client/Program.cs Demonstrates client streaming from the sample client.
docs/guide/samples.md Updates sample listing text to mention server + client streaming.
docs/guide/messaging/message-bus.md Adds “Streaming Requests” documentation for InvokeStreamAsync.
docs/guide/grpc/streaming.md Adds proto-first client-streaming guide section and updates limitations.
docs/guide/grpc/samples.md Updates GreeterProtoFirstGrpc documentation to include client streaming.
docs/guide/grpc/multi-tenancy.md Updates multi-tenancy notes to include proto-first client streaming coverage.
docs/guide/grpc/index.md Updates overview and limitations to reflect client streaming support (proto-first).
docs/guide/grpc/handlers.md Updates Validate caveat to include client-streaming methods.
docs/guide/grpc/contracts.md Updates generated-implementation limitations + adds client-streaming section.
CHANGELOG.md Adds release notes for the new bus primitive and proto-first client-streaming codegen.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@erikshafer

erikshafer commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author
  1. I am not sure why I let the LLM start going with Task<TResponse> InvokeStreamAsync<TRequest, TResponse> instead of just adding the second arg such as StreamAsync<TRequest, TResponse>, like the StreamAsync<T> introduced a couple months ago. I think I'll change the name tomorrow. For now, gonna hit the hay and then give this another look Saturday or Sunday. Completed.
  2. I am using WolverineFx.Grpc more and more heavily in CritterCab ( https://github.com/erikshafer/CritterCab ). I had a need for this feature I meant to get to earlier. That's the out-of-repo test bed I have for a lot of the gRPC capabilities.

Conform the streaming-request primitive to the existing StreamAsync<T>
overload family rather than introducing a new method-name family. Arity
disambiguates the two directions (one type argument streams responses out,
two stream requests in), and the exception interceptor comment that
originally deferred client streaming spoke of "the matching IMessageBus
overloads" - this restores that recorded intent. Test file/class/method
names follow suit (streaming_request_support).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikshafer erikshafer changed the title Proto-first gRPC client streaming via new IMessageBus.InvokeStreamAsync Proto-first gRPC client streaming via new StreamAsync<TRequest, TResponse> overload Jul 18, 2026
@erikshafer
erikshafer marked this pull request as ready for review July 18, 2026 17:57
…decoupling

Documentation comb-through of every gRPC surface (guide pages, sample
READMEs, secondary mentions), verifying each claim against source:

- Eight gRPC sample servers crashed at startup with 'No IAssemblyGenerator
  is registered' since the 6.0 runtime-codegen decoupling; add the
  Wolverine.RuntimeCompilation ProjectReference (mirroring the fix
  GreeterProtoFirstGrpc/Server got in JasperFx#3459). All nine servers boot.
- samples.md + RacerWithGrpc README still said StreamAsync<TRequest,
  TResponse> hadn't landed; it has, as the client-streaming primitive,
  not a bidi loop. Reworded both.
- errors.md now states which call shapes the exception interceptor
  covers (unary, server-, client-streaming; bidi deferred).
- contracts.md/handlers.md claimed hand-written delegation wrappers are
  named {ClassName}GrpcHandler; the GrpcService suffix is stripped first
  (HandWrittenGrpcServiceChain.ResolveTypeName).
- guide/samples.md listed only six of the eight gRPC samples; added
  GreeterCodeFirstGrpc and ProgressTrackerWithGrpc rows.
- handlers.md codegen-preview identifier list matched to the CLI.

Wolverine.Grpc.Tests 281/281, full wolverine.slnx Release build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.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