diff --git a/docs/guide/grpc/handlers.md b/docs/guide/grpc/handlers.md index e4d144214..5244f38da 100644 --- a/docs/guide/grpc/handlers.md +++ b/docs/guide/grpc/handlers.md @@ -80,12 +80,17 @@ Both paths feed into the same generated-code pipeline used by Wolverine's messag adapters, so your gRPC services show up in the standard diagnostics: ```bash +# List every handler / HTTP endpoint / gRPC service Wolverine knows about dotnet run -- describe -dotnet run -- describe-routing + +# Preview the generated wrapper for one proto-first gRPC stub +dotnet run -- wolverine-diagnostics codegen-preview --grpc Greeter ``` -If you're debugging discovery, those commands will show the generated handler type name and the -handler method it forwards to. +If you're debugging discovery, `describe` proves Wolverine found the stub; `codegen-preview --grpc` +shows the exact generated override and the handler method each RPC forwards to. See +[`codegen-preview`](/guide/command-line#codegen-preview) for the full set of accepted identifiers +(bare proto service name, stub class name, or short `-g` alias). ## Observability diff --git a/docs/guide/grpc/index.md b/docs/guide/grpc/index.md index af7ec162e..1ea6f0a85 100644 --- a/docs/guide/grpc/index.md +++ b/docs/guide/grpc/index.md @@ -1,9 +1,8 @@ # gRPC Services with Wolverine ::: info -The `WolverineFx.Grpc` package (experimental, shipping alongside gRPC support on the -`feature/grpc-and-streaming-support` branch) lets you expose Wolverine handlers as -ASP.NET Core gRPC services with minimal wiring. It supports both the **code-first** +The `WolverineFx.Grpc` package lets you expose Wolverine handlers as ASP.NET Core gRPC services +with minimal wiring. It supports both the **code-first** ([protobuf-net.Grpc](https://protobuf-net.github.io/protobuf-net.Grpc/)) and **proto-first** ([Grpc.Tools](https://learn.microsoft.com/en-us/aspnet/core/grpc/)) styles. ::: @@ -116,41 +115,31 @@ and comparisons to the official `grpc-dotnet` examples. through `Bus.StreamAsync(item, ct)` — see [Streaming](./streaming) for the pattern and the [RacerWithGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/RacerWithGrpc) sample. - **Exception mapping** of the canonical `Exception → StatusCode` table is not yet user-configurable - (follow-up item). Rich, structured responses are already available — see - [Error Handling](./errors). + on the server side (follow-up item). Rich, structured responses are already available — see + [Error Handling](./errors). On the client side, `WolverineGrpcClientOptions.MapRpcException` + already allows per-client overrides — see [Typed gRPC Clients](./client#per-client-override). +- **`MiddlewareScoping.Grpc` middleware** — the enum value ships and is honored by Wolverine's + discovery primitives, but no code path yet *weaves* `[WolverineBefore(MiddlewareScoping.Grpc)]` + / `[WolverineAfter(MiddlewareScoping.Grpc)]` methods into the generated gRPC service wrappers. + The attribute is safe to apply — it compiles, it is correctly filtered away from message-handler + and HTTP chains, and it will start firing once the codegen path (tracked as M15) lands — but + today nothing runs at RPC time. Until then, middleware that needs to execute on gRPC calls + should live in a custom gRPC interceptor rather than rely on the attribute or on + `services.AddWolverineGrpc(g => g.AddMiddleware())` (both take effect together in M15). ## Roadmap -The gRPC integration is intentionally shipping as a focused, reviewable slice. The items below are -on the roadmap but *not* in the initial drop — they're listed here so contributors can plan around -them and consumers know what's coming. - -### Shipping in this PR - -- **`MiddlewareScoping.Grpc`** — the existing [scoped middleware](/guide/handlers/middleware#applying-middleware-explicitly-by-attribute) - enum grows a `Grpc` value so gRPC-specific middleware can be registered the same way HTTP and - messaging middleware already are. Previously, gRPC service chains reported themselves as - `MessageHandlers`-scoped, which silently over-attached message middleware to gRPC calls. This is - a behavior correction, not an additive feature. -- **`codegen-preview --grpc`** — the [`codegen-preview` CLI](/guide/command-line#codegen-preview) - grows a `--grpc` / `-g` flag (mirroring `--handler` / `-h` and `--route` / `-r`) so you can inspect - the code Wolverine generates for gRPC service chains without dropping into the full `codegen write` - output. -- **Typed gRPC client extension (`AddWolverineGrpcClient()`)** — a thin Wolverine wrapper over - `Grpc.Net.ClientFactory.AddGrpcClient()` that layers envelope-header propagation and - `RpcException` → typed .NET exception translation onto both code-first (`protobuf-net.Grpc` - `[ServiceContract]`) and proto-first (`Grpc.Tools`-generated) typed clients. See - [Typed gRPC Clients](./client) for the full surface. Raw `GrpcChannel` + generated stubs (as used - in the samples today) remain a fully supported path. - -### Deferred to follow-up PRs +The gRPC integration has a handful of deferred items that are known-good fits but haven't shipped +yet. They're listed here so contributors can plan around them and consumers know what's coming. +- **`MiddlewareScoping.Grpc` codegen weaving (M15)** — attribute-based middleware on gRPC stubs + (see Current Limitations above). Phase 0 landed the discovery + options surface; Phase 1 will + wire execution into the generated `GrpcServiceChain` wrappers. - **`Validate` convention → `Status?`** — HTTP handlers already support an opt-in `Validate` method whose non-null return short-circuits the call. The gRPC equivalent would return `Grpc.Core.Status?` (or a richer `google.rpc.Status`) so a handler could express "this call is - invalid, return `InvalidArgument` with these field violations" without throwing. This is - deferred because it lands cleanest on top of the code-first codegen work below — shipping it - against the current runtime path would bake in assumptions we'll want to revisit. + invalid, return `InvalidArgument` with these field violations" without throwing. Deferred because + it lands cleanest on top of the code-first codegen work below. - **Code-first codegen parity** — proto-first services flow through a generated `GrpcServiceChain` with the usual JasperFx codegen pipeline; code-first services (the `WolverineGrpcServiceBase` path) currently resolve dependencies via service location inside each method. Generating per-method code diff --git a/docs/guide/grpc/streaming.md b/docs/guide/grpc/streaming.md index 768d063c5..d5b5f4e30 100644 --- a/docs/guide/grpc/streaming.md +++ b/docs/guide/grpc/streaming.md @@ -122,7 +122,10 @@ but your detached tasks keep running. Always thread the token through. - **Exception timing:** an exception thrown **before** the first `yield return` surfaces on the client via the trailers as expected. An exception thrown **mid-stream** surfaces as a trailer after messages the client has already received — well-behaved clients must still check the final - status even after consuming messages successfully. + status even after consuming messages successfully. Server-side, the OpenTelemetry activity for + the handler is marked `Error` in both cases (including cancellation) — the activity stays open + until the stream fully drains or faults, so dashboards reflect the real terminal state rather + than the moment the handler returned the `IAsyncEnumerable`. ## Related diff --git a/src/Testing/CoreTests/Acceptance/streaming_handler_support.cs b/src/Testing/CoreTests/Acceptance/streaming_handler_support.cs index 68a43a9de..073f2221b 100644 --- a/src/Testing/CoreTests/Acceptance/streaming_handler_support.cs +++ b/src/Testing/CoreTests/Acceptance/streaming_handler_support.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Shouldly; @@ -201,6 +202,37 @@ public async Task handler_exception_after_partial_yield_surfaces_to_caller_with_ items.Select(i => i.Value).ShouldBe([0, 1]); } + [Fact] + public async Task mid_stream_throw_marks_activity_status_error() + { + var capturedActivities = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Wolverine", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => capturedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine() + .StartAsync(); + + var bus = host.Services.GetRequiredService(); + + await Should.ThrowAsync(async () => + { + await foreach (var _ in bus.StreamAsync(new FaultingStreamRequest(2))) + { + } + }); + + var streamingActivity = capturedActivities + .FirstOrDefault(a => a.OperationName.Contains("stream", StringComparison.OrdinalIgnoreCase)); + streamingActivity.ShouldNotBeNull(); + streamingActivity.Status.ShouldBe(ActivityStatusCode.Error); + } + [Fact] public async Task stream_with_delivery_options() { diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreetMessageHandler.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreetMessageHandler.cs new file mode 100644 index 000000000..f7a9d5e13 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreetMessageHandler.cs @@ -0,0 +1,29 @@ +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping.Generated; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Wolverine handler for the unary RPC. Records its invocation against the shared +/// so middleware-ordering tests can assert +/// before/after relative to the handler call. +/// +public static class GreetMessageHandler +{ + public const string Marker = "Handler"; + + public static GreetReply Handle(GreetRequest request, MiddlewareInvocationSink sink) + { + sink.Record(Marker); + return new GreetReply { Message = $"Hello, {request.Name}" }; + } + + public static async IAsyncEnumerable Handle(GreetManyRequest request, MiddlewareInvocationSink sink) + { + sink.Record(Marker); + for (var i = 0; i < 3; i++) + { + yield return new GreetReply { Message = $"Hello #{i}, {request.Name}" }; + await Task.Yield(); + } + } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreeterMiddlewareTestStub.ScopeProbes.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreeterMiddlewareTestStub.ScopeProbes.cs new file mode 100644 index 000000000..53530983f --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreeterMiddlewareTestStub.ScopeProbes.cs @@ -0,0 +1,36 @@ +using Wolverine.Attributes; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Probe methods used by scope_discovery_tests to verify that +/// / +/// respect . Lives on the smoke stub via partial so tests +/// don't need a second proto to exercise the discovery path. These are inert under Phase 0 +/// (no weaving yet); when Phase 1 lands, they'll be the first concrete demonstration that the +/// M15 promise (middleware fires alongside the gRPC handler) actually holds. +/// +public abstract partial class GreeterMiddlewareTestStub +{ + public const string AnywhereMarker = "ScopeProbe.Anywhere"; + public const string GrpcMarker = "ScopeProbe.Grpc"; + public const string MessageHandlersMarker = "ScopeProbe.MessageHandlers"; + + [WolverineBefore] + public static void BeforeAnywhere(MiddlewareInvocationSink sink) => sink.Record(AnywhereMarker); + + [WolverineBefore(MiddlewareScoping.Grpc)] + public static void BeforeGrpc(MiddlewareInvocationSink sink) => sink.Record(GrpcMarker); + + [WolverineBefore(MiddlewareScoping.MessageHandlers)] + public static void BeforeMessageHandlers(MiddlewareInvocationSink sink) => sink.Record(MessageHandlersMarker); + + [WolverineAfter] + public static void AfterAnywhere(MiddlewareInvocationSink sink) => sink.Record(AnywhereMarker + ".After"); + + [WolverineAfter(MiddlewareScoping.Grpc)] + public static void AfterGrpc(MiddlewareInvocationSink sink) => sink.Record(GrpcMarker + ".After"); + + [WolverineAfter(MiddlewareScoping.MessageHandlers)] + public static void AfterMessageHandlers(MiddlewareInvocationSink sink) => sink.Record(MessageHandlersMarker + ".After"); +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreeterMiddlewareTestStub.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreeterMiddlewareTestStub.cs new file mode 100644 index 000000000..fb7958e1f --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/GreeterMiddlewareTestStub.cs @@ -0,0 +1,13 @@ +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping.Generated; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Proto-first Wolverine stub for the M15 () +/// test suite. Intentionally bare — middleware methods carrying +/// [WolverineBefore]/[WolverineAfter] are added per-test via partial-class +/// extensions or test-specific subclasses so each scenario can scope its assertions +/// without polluting the shared stub. +/// +[WolverineGrpcService] +public abstract partial class GreeterMiddlewareTestStub : GreeterMiddlewareTest.GreeterMiddlewareTestBase; diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/MiddlewareInvocationSink.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/MiddlewareInvocationSink.cs new file mode 100644 index 000000000..861b4d3e0 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/MiddlewareInvocationSink.cs @@ -0,0 +1,30 @@ +using System.Collections.Concurrent; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Thread-safe append-only ledger of named events that the M15 integration tests use to +/// assert middleware ordering and scope filtering. Each captured entry records the marker +/// name supplied by a test fixture (typically a stub-class method name like +/// "BeforeGrpc" or a handler name like "Handler") so the test can later +/// assert what fired and in what order without coupling to clock timing. +/// +public sealed class MiddlewareInvocationSink +{ + private readonly ConcurrentQueue _events = new(); + + public void Record(string marker) => _events.Enqueue(marker); + + public IReadOnlyList Events => _events.ToArray(); + + public void Clear() + { + while (_events.TryDequeue(out _)) + { + } + } + + public bool Contains(string marker) => _events.Contains(marker); + + public int CountOf(string marker) => _events.Count(e => e == marker); +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/MiddlewareScopingFixture.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/MiddlewareScopingFixture.cs new file mode 100644 index 000000000..15bb628cc --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/MiddlewareScopingFixture.cs @@ -0,0 +1,73 @@ +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping.Generated; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Dedicated host fixture for the M15 MiddlewareScoping.Grpc integration tests. +/// Booted as a per-class fixture (not collection-shared) so each test class can verify +/// middleware-invocation ordering against a fresh +/// without inter-test interference. +/// +/// +/// Modeled on but isolated from the PingPong/Streaming/Faulting +/// samples so M15 assertions don't drift when those samples evolve. Uses ASP.NET Core's +/// TestHost for an in-process channel — no real network port. +/// +public class MiddlewareScopingFixture : IAsyncLifetime +{ + private WebApplication? _app; + public GrpcChannel? Channel { get; private set; } + public MiddlewareInvocationSink Sink { get; } = new(); + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + + builder.WebHost.UseTestServer(); + + builder.Host.UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(MiddlewareScopingFixture).Assembly; + }); + + builder.Services.AddSingleton(Sink); + builder.Services.AddGrpc(); + builder.Services.AddWolverineGrpc(); + + _app = builder.Build(); + + _app.UseRouting(); + + // Trigger Wolverine's proto-first discovery + code-gen and register the generated + // wrapper. Pre-M15 weaving, this just emits forward-frames; once §7.3 lands the same + // generated code will additionally carry middleware/postprocessor frames per RPC. + _app.MapWolverineGrpcServices(); + + await _app.StartAsync(); + + var handler = _app.GetTestServer().CreateHandler(); + Channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + HttpHandler = handler + }); + } + + public async Task DisposeAsync() + { + Channel?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + public GreeterMiddlewareTest.GreeterMiddlewareTestClient CreateClient() + => new(Channel!); +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/Protos/middleware_scoping_test.proto b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/Protos/middleware_scoping_test.proto new file mode 100644 index 000000000..39924c00a --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/Protos/middleware_scoping_test.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +option csharp_namespace = "Wolverine.Grpc.Tests.GrpcMiddlewareScoping.Generated"; + +package wolverine.grpc.tests.middleware_scoping; + +// Test-only service used by middleware-scoping tests. Two RPC shapes (unary + server-streaming) +// so the same fixture exercises the per-RPC weaving paths from §7.3 of the M15 plan. +service GreeterMiddlewareTest { + // Unary: maps to Wolverine via IMessageBus.InvokeAsync. + rpc Greet (GreetRequest) returns (GreetReply); + + // Server-streaming: maps to Wolverine via IMessageBus.StreamAsync. Uses a + // distinct request type because Wolverine dispatches handlers by message type, so the + // unary and streaming RPCs cannot share GreetRequest. + rpc GreetMany (GreetManyRequest) returns (stream GreetReply); +} + +message GreetRequest { + string name = 1; +} + +message GreetManyRequest { + string name = 1; +} + +message GreetReply { + string message = 1; +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/frame_cloning_spike_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/frame_cloning_spike_tests.cs new file mode 100644 index 000000000..60ca2f652 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/frame_cloning_spike_tests.cs @@ -0,0 +1,67 @@ +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Shouldly; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Pins the invariant Phase-1 codegen relies on: two instances +/// built from one do not share mutable state. +/// If the _discoveredBefores caching on ever became +/// instance caching (e.g. storing a single per method and +/// reusing it across every RPC override), one RPC's argument resolution would contaminate +/// its siblings during GenerateCode. The fix is "fresh MethodCall per emission site" +/// (see §13 of the M15 plan). This test fails loudly if that invariant is broken. +/// +public class frame_cloning_spike_tests +{ + [Fact] + public void method_call_instances_from_one_method_info_do_not_share_mutable_state() + { + var methodInfo = typeof(GreeterMiddlewareTestStub) + .GetMethod(nameof(GreeterMiddlewareTestStub.BeforeGrpc))!; + + var first = new MethodCall(typeof(GreeterMiddlewareTestStub), methodInfo); + var second = new MethodCall(typeof(GreeterMiddlewareTestStub), methodInfo); + + // The reflection handle is immutable — sharing it is expected and safe. + ReferenceEquals(first.Method, second.Method) + .ShouldBeTrue("MethodInfo is an immutable reflection handle; sharing is fine"); + + // The MethodCall wrappers themselves must be distinct objects. + ReferenceEquals(first, second) + .ShouldBeFalse("Phase-1 must instantiate a fresh MethodCall per emission site"); + + // The mutable Arguments array — the one GenerateCode resolves Variables into — must be + // a per-instance array. If this ever becomes a shared reference, resolving one emission + // site would leak into every other emission site built from the same MethodInfo. + ReferenceEquals(first.Arguments, second.Arguments) + .ShouldBeFalse("Arguments[] must be per-instance — GenerateCode mutates it during codegen"); + + // Mutation proof: overwrite one site's arguments and confirm the sibling is unchanged. + // This is the concrete failure mode a caching bug would produce at runtime. + var sentinel = Variable.For("sentinel"); + first.Arguments[0] = sentinel; + + second.Arguments[0] + .ShouldNotBe(sentinel, + "mutating one MethodCall's Arguments must not be visible on a sibling built from the same MethodInfo"); + } + + [Fact] + public void method_call_creates_collection_is_per_instance() + { + var methodInfo = typeof(GreeterMiddlewareTestStub) + .GetMethod(nameof(GreeterMiddlewareTestStub.BeforeGrpc))!; + + var first = new MethodCall(typeof(GreeterMiddlewareTestStub), methodInfo); + var second = new MethodCall(typeof(GreeterMiddlewareTestStub), methodInfo); + + // Creates holds output variables resolved during codegen. Like Arguments, it must be + // a distinct collection per instance so cascading-message detection on one emission + // doesn't leak to another. + ReferenceEquals(first.Creates, second.Creates) + .ShouldBeFalse("Creates collection must be per-instance"); + } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/middleware_scoping_fixture_smoke_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/middleware_scoping_fixture_smoke_tests.cs new file mode 100644 index 000000000..93198dcf8 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/middleware_scoping_fixture_smoke_tests.cs @@ -0,0 +1,53 @@ +using Grpc.Core; +using Shouldly; +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping.Generated; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Sanity-checks for the M15 test harness. These run BEFORE the production weaving lands +/// (Phase 1) so the fixture itself is known-good — when P1 tests start asserting middleware +/// invocations, any failure narrows to "P1 weaving" rather than "harness was never working." +/// +public class middleware_scoping_fixture_smoke_tests : IClassFixture +{ + private readonly MiddlewareScopingFixture _fixture; + + public middleware_scoping_fixture_smoke_tests(MiddlewareScopingFixture fixture) + { + _fixture = fixture; + _fixture.Sink.Clear(); + } + + [Fact] + public async Task unary_rpc_round_trips_through_wolverine_handler() + { + var client = _fixture.CreateClient(); + + var reply = await client.GreetAsync(new GreetRequest { Name = "Erik" }); + + reply.Message.ShouldBe("Hello, Erik"); + _fixture.Sink.Contains(GreetMessageHandler.Marker).ShouldBeTrue( + "the Wolverine handler must run for the unary RPC"); + } + + [Fact] + public async Task server_streaming_rpc_round_trips_through_wolverine_streaming_handler() + { + var client = _fixture.CreateClient(); + + using var call = client.GreetMany(new GreetManyRequest { Name = "Erik" }); + + var replies = new List(); + await foreach (var reply in call.ResponseStream.ReadAllAsync()) + { + replies.Add(reply.Message); + } + + replies.Count.ShouldBe(3); + replies[0].ShouldBe("Hello #0, Erik"); + _fixture.Sink.Contains(GreetMessageHandler.Marker).ShouldBeTrue( + "the Wolverine streaming handler must run for the server-streaming RPC"); + } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/policy_leak_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/policy_leak_tests.cs new file mode 100644 index 000000000..b390d20e0 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/policy_leak_tests.cs @@ -0,0 +1,80 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine.Configuration; +using Wolverine.Middleware; +using Wolverine.Runtime; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// M15 §9.6 — pins the boundary between the message-handler middleware policy +/// () and gRPC chains. The handler-only filter +/// baked into WolverineOptions.Policies.cs:208-216 is the *only* thing keeping +/// handler middleware from leaking into ; if Phase-1 wiring +/// ever bypasses that filter (e.g. by registering as a +/// global IChainPolicy against gRPC chains too), users would suddenly see their +/// bus middleware run on every RPC call. Hence the explicit guard test here. +/// +public class policy_leak_tests +{ + [Fact] + public async Task ipolicies_add_middleware_does_not_attach_to_grpc_chain() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly; + opts.Policies.AddMiddleware(); + }) + .ConfigureServices(services => + { + services.AddSingleton(new MiddlewareInvocationSink()); + services.AddWolverineGrpc(); + }) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + graph.DiscoverServices(); + var chain = graph.Chains.Single(c => c.StubType == typeof(GreeterMiddlewareTestStub)); + + var options = host.Services.GetRequiredService(); + var policy = options.FindOrCreateMiddlewarePolicy(); + var rules = options.CodeGeneration; + var container = host.Services.GetRequiredService(); + + // Simulate the worst-case Phase-1 wiring: forcibly run the handler-only middleware + // policy against a GrpcServiceChain. The HandlerChain-only filter at + // WolverineOptions.Policies.cs:208-216 must reject the chain so no middleware lands. + policy.Apply([chain], rules, container); + + chain.Middleware.ShouldBeEmpty( + "IPolicies.AddMiddleware uses a HandlerChain-only filter — middleware registered " + + "via the message-handler policy must never attach to a GrpcServiceChain. " + + "gRPC users should use AddWolverineGrpc(g => g.AddMiddleware()) instead."); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + +} + +/// +/// A trivial middleware whose only purpose is to be visible in the chain's middleware +/// list IF the filter ever broke. Method named per +/// so it is unambiguously a Before frame. Must be public + top-level — Wolverine rejects +/// nested or non-public middleware types in 's +/// constructor. +/// +public sealed class HandlerOnlyMiddleware +{ + public static void Before(MiddlewareInvocationSink sink) => sink.Record("HandlerOnlyMiddleware.Before"); +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/scope_discovery_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/scope_discovery_tests.cs new file mode 100644 index 000000000..1c830f689 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/scope_discovery_tests.cs @@ -0,0 +1,131 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Verifies the M15 Phase-0 discovery: and +/// walk the user's stub type and honor +/// . These tests pin the contract that +/// Phase-1 codegen will rely on — get this wrong and the eventual weaving will silently +/// attach (or skip) the wrong methods. +/// +public class scope_discovery_tests +{ + [Fact] + public async Task discovers_anywhere_and_grpc_scoped_befores_on_stub_type() + { + var chain = await DiscoverStubChainAsync(); + + var names = chain.DiscoveredBefores.Select(m => m.Name).ToArray(); + + names.ShouldContain(nameof(GreeterMiddlewareTestStub.BeforeAnywhere)); + names.ShouldContain(nameof(GreeterMiddlewareTestStub.BeforeGrpc)); + } + + [Fact] + public async Task does_not_discover_message_handlers_scoped_befores_on_stub_type() + { + var chain = await DiscoverStubChainAsync(); + + var names = chain.DiscoveredBefores.Select(m => m.Name).ToArray(); + + names.ShouldNotContain(nameof(GreeterMiddlewareTestStub.BeforeMessageHandlers), + "[WolverineBefore(MessageHandlers)] must not leak into a gRPC chain's discovered middleware"); + } + + [Fact] + public async Task discovers_anywhere_and_grpc_scoped_afters_on_stub_type() + { + var chain = await DiscoverStubChainAsync(); + + var names = chain.DiscoveredAfters.Select(m => m.Name).ToArray(); + + names.ShouldContain(nameof(GreeterMiddlewareTestStub.AfterAnywhere)); + names.ShouldContain(nameof(GreeterMiddlewareTestStub.AfterGrpc)); + } + + [Fact] + public async Task does_not_discover_message_handlers_scoped_afters_on_stub_type() + { + var chain = await DiscoverStubChainAsync(); + + var names = chain.DiscoveredAfters.Select(m => m.Name).ToArray(); + + names.ShouldNotContain(nameof(GreeterMiddlewareTestStub.AfterMessageHandlers), + "[WolverineAfter(MessageHandlers)] must not leak into a gRPC chain's discovered postprocessors"); + } + + [Fact] + public async Task discovered_befores_are_ordinally_sorted_for_deterministic_codegen() + { + // Reflection's GetMethods() order is unspecified — if Phase-1 emits middleware frames + // in whatever order reflection returns, the generated source is nondeterministic across + // runs, which breaks byte-stable codegen caches and makes diagnostic diffs unreadable. + // PR #2525 established this invariant for RPC-method discovery (GrpcServiceChain.cs:268); + // we match that pattern here. + var chain = await DiscoverStubChainAsync(); + + var names = chain.DiscoveredBefores.Select(m => m.Name).ToArray(); + var sorted = names.OrderBy(n => n, StringComparer.Ordinal).ToArray(); + + names.ShouldBe(sorted); + } + + [Fact] + public async Task discovered_afters_are_ordinally_sorted_for_deterministic_codegen() + { + var chain = await DiscoverStubChainAsync(); + + var names = chain.DiscoveredAfters.Select(m => m.Name).ToArray(); + var sorted = names.OrderBy(n => n, StringComparer.Ordinal).ToArray(); + + names.ShouldBe(sorted); + } + + [Fact] + public async Task discovery_results_are_stable_across_repeated_reads() + { + // Phase 1 codegen will read DiscoveredBefores from inside AssembleTypes which can be + // invoked more than once during dynamic regeneration; cached results must not change + // shape between calls or the generated source becomes nondeterministic. + var chain = await DiscoverStubChainAsync(); + + var first = chain.DiscoveredBefores; + var second = chain.DiscoveredBefores; + + ReferenceEquals(first, second).ShouldBeTrue("DiscoveredBefores should be cached after first access"); + } + + private static async Task DiscoverStubChainAsync() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly; + }) + .ConfigureServices(services => + { + services.AddSingleton(new MiddlewareInvocationSink()); + services.AddWolverineGrpc(); + }) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + graph.DiscoverServices(); + + return graph.Chains.Single(c => c.StubType == typeof(GreeterMiddlewareTestStub)); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/type_name_disambiguation_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/type_name_disambiguation_tests.cs new file mode 100644 index 000000000..841c8a401 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/type_name_disambiguation_tests.cs @@ -0,0 +1,126 @@ +using JasperFx.CodeGeneration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping.Generated; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// Verifies the post-discovery disambiguation pass in . +/// Two proto services sharing a simple name (e.g. a Greeter in each of two bounded contexts) +/// would otherwise both emit GreeterGrpcHandler into the same WolverineHandlers child +/// namespace, and AttachTypesSynchronously would non-deterministically pick one. +/// +public class type_name_disambiguation_tests +{ + [Fact] + public async Task colliding_chains_get_disambiguated_with_stable_hashed_suffix() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly) + .ConfigureServices(services => services.AddWolverineGrpc()) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + + // Two stubs, different FullNames, same proto base → same default TypeName. + var chainA = new GrpcServiceChain(typeof(AlphaCollisionStub), graph); + var chainB = new GrpcServiceChain(typeof(BetaCollisionStub), graph); + + var originalName = chainA.TypeName; + chainB.TypeName.ShouldBe(originalName, + "pre-condition: both chains should emit the same default name so the disambiguator has something to fix"); + + var chains = new List { chainA, chainB }; + GrpcGraph.DisambiguateCollidingTypeNames(chains); + + chainA.TypeName.ShouldNotBe(chainB.TypeName); + chainA.TypeName.ShouldStartWith(originalName + "_"); + chainB.TypeName.ShouldStartWith(originalName + "_"); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + [Fact] + public async Task disambiguation_is_stable_and_idempotent() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly) + .ConfigureServices(services => services.AddWolverineGrpc()) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + + var chainA = new GrpcServiceChain(typeof(AlphaCollisionStub), graph); + var chainB = new GrpcServiceChain(typeof(BetaCollisionStub), graph); + + var chains = new List { chainA, chainB }; + GrpcGraph.DisambiguateCollidingTypeNames(chains); + + var snapshotA = chainA.TypeName; + var snapshotB = chainB.TypeName; + + // Re-running must be a no-op: names are already unique so no collision exists. + GrpcGraph.DisambiguateCollidingTypeNames(chains); + chainA.TypeName.ShouldBe(snapshotA); + chainB.TypeName.ShouldBe(snapshotB); + + // A second pair of freshly-constructed chains from the same stub types must produce + // the same disambiguated names — proves GetDeterministicHashCode is stable within + // a process (which is the invariant Wolverine relies on for generated-code caching). + var chainA2 = new GrpcServiceChain(typeof(AlphaCollisionStub), graph); + var chainB2 = new GrpcServiceChain(typeof(BetaCollisionStub), graph); + GrpcGraph.DisambiguateCollidingTypeNames([chainA2, chainB2]); + chainA2.TypeName.ShouldBe(snapshotA); + chainB2.TypeName.ShouldBe(snapshotB); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + [Fact] + public async Task non_colliding_chains_keep_their_default_type_names() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly) + .ConfigureServices(services => services.AddWolverineGrpc()) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + var chain = new GrpcServiceChain(typeof(AlphaCollisionStub), graph); + var originalName = chain.TypeName; + + GrpcGraph.DisambiguateCollidingTypeNames([chain]); + + chain.TypeName.ShouldBe(originalName, + "a single chain cannot collide with itself — its default name must be preserved"); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + // Internal stubs so reflection-based discovery (which walks GetExportedTypes) ignores them, + // and the absence of [WolverineGrpcService] keeps them out of IsProtoFirstStub. Both safeguards + // matter — otherwise these would land in scope_discovery_tests' chain lists and skew counts. + internal abstract class AlphaCollisionStub : GreeterMiddlewareTest.GreeterMiddlewareTestBase; + + internal abstract class BetaCollisionStub : GreeterMiddlewareTest.GreeterMiddlewareTestBase; +} diff --git a/src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj b/src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj index 2ebc5f3db..5ecfb2f37 100644 --- a/src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj +++ b/src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj @@ -29,8 +29,14 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + @@ -48,4 +54,14 @@ + + + + + diff --git a/src/Wolverine.Grpc.Tests/add_wolverine_grpc_idempotency_tests.cs b/src/Wolverine.Grpc.Tests/add_wolverine_grpc_idempotency_tests.cs new file mode 100644 index 000000000..0c0347536 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/add_wolverine_grpc_idempotency_tests.cs @@ -0,0 +1,123 @@ +using Grpc.AspNetCore.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; + +namespace Wolverine.Grpc.Tests; + +/// +/// Regression tests for PR #2525 self-review §2.2 — without the marker guard, +/// repeat +/// invocations stack the twice, +/// doubling exception translation and log output on every unhandled exception. +/// +public class add_wolverine_grpc_idempotency_tests +{ + [Fact] + public void single_call_registers_exception_interceptor_exactly_once() + { + var services = new ServiceCollection(); + services.AddOptions(); + + services.AddWolverineGrpc(); + + var grpcOptions = services.BuildServiceProvider() + .GetRequiredService>().Value; + + grpcOptions.Interceptors.Count(r => r.Type == typeof(WolverineGrpcExceptionInterceptor)) + .ShouldBe(1); + } + + [Fact] + public void repeat_calls_do_not_stack_the_exception_interceptor() + { + var services = new ServiceCollection(); + services.AddOptions(); + + services.AddWolverineGrpc(); + services.AddWolverineGrpc(); + services.AddWolverineGrpc(); + + var grpcOptions = services.BuildServiceProvider() + .GetRequiredService>().Value; + + grpcOptions.Interceptors.Count(r => r.Type == typeof(WolverineGrpcExceptionInterceptor)) + .ShouldBe(1); + } + + [Fact] + public void repeat_calls_do_not_stack_the_grpc_graph_registration() + { + var services = new ServiceCollection(); + services.AddOptions(); + + services.AddWolverineGrpc(); + services.AddWolverineGrpc(); + + services.Count(d => d.ServiceType == typeof(GrpcGraph)).ShouldBe(1); + } + + [Fact] + public void marker_singleton_is_registered_after_first_call() + { + var services = new ServiceCollection(); + services.AddOptions(); + + services.AddWolverineGrpc(); + + services.Any(d => d.ServiceType == typeof(WolverineGrpcExtensions.WolverineGrpcMarker)) + .ShouldBeTrue(); + } + + [Fact] + public void configure_overload_runs_callback_on_first_call() + { + var services = new ServiceCollection(); + services.AddOptions(); + + var captured = false; + services.AddWolverineGrpc(_ => captured = true); + + captured.ShouldBeTrue(); + } + + [Fact] + public void configure_overload_runs_callback_on_repeat_calls_against_same_options_instance() + { + var services = new ServiceCollection(); + services.AddOptions(); + + WolverineGrpcOptions? first = null; + WolverineGrpcOptions? second = null; + services.AddWolverineGrpc(o => first = o); + services.AddWolverineGrpc(o => second = o); + + first.ShouldNotBeNull(); + second.ShouldNotBeNull(); + first.ShouldBeSameAs(second); + } + + [Fact] + public void options_singleton_is_resolvable_from_container() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddWolverineGrpc(); + + var resolved = services.BuildServiceProvider().GetService(); + resolved.ShouldNotBeNull(); + } + + [Fact] + public void repeat_calls_register_options_singleton_only_once() + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddWolverineGrpc(); + services.AddWolverineGrpc(); + services.AddWolverineGrpc(); + + services.Count(d => d.ServiceType == typeof(WolverineGrpcOptions)).ShouldBe(1); + } +} diff --git a/src/Wolverine.Grpc/GrpcGraph.cs b/src/Wolverine.Grpc/GrpcGraph.cs index 42be3227e..7cc12df02 100644 --- a/src/Wolverine.Grpc/GrpcGraph.cs +++ b/src/Wolverine.Grpc/GrpcGraph.cs @@ -58,6 +58,37 @@ public void DiscoverServices() { _chains.Add(new GrpcServiceChain(stub, this)); } + + DisambiguateCollidingTypeNames(_chains); + } + + /// + /// Post-discovery pass that guarantees unique s + /// across the graph. Two proto services sharing a simple name (e.g., a Greeter + /// in each of two bounded contexts) would otherwise both generate + /// GreeterGrpcHandler into the same WolverineHandlers child namespace, + /// and AttachTypesSynchronously would pick whichever exported type the CLR + /// handed back first — an order that is not guaranteed across assemblies. Mirrors + /// the pattern in HandlerGraph (issue #2004) but uses a stable hash of the + /// stub's as the qualifier, since gRPC stub simple names + /// are not reliably unique (users often pick ergonomic type names). + /// + internal static void DisambiguateCollidingTypeNames(IList chains) + { + var collisions = chains + .GroupBy(c => c.TypeName, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .ToArray(); + + foreach (var group in collisions) + { + foreach (var chain in group) + { + var stubFullName = chain.StubType.FullName ?? chain.StubType.Name; + var hash = (uint)stubFullName.GetDeterministicHashCode(); + chain.ApplyDisambiguatedTypeName($"{chain.ProtoServiceName}GrpcHandler_{hash:x8}"); + } + } } /// diff --git a/src/Wolverine.Grpc/GrpcServiceChain.cs b/src/Wolverine.Grpc/GrpcServiceChain.cs index 3d4f07199..21bb89a8b 100644 --- a/src/Wolverine.Grpc/GrpcServiceChain.cs +++ b/src/Wolverine.Grpc/GrpcServiceChain.cs @@ -8,6 +8,7 @@ using JasperFx.Core.Reflection; using Wolverine.Attributes; using Wolverine.Configuration; +using Wolverine.Middleware; using Wolverine.Persistence; namespace Wolverine.Grpc; @@ -44,9 +45,12 @@ public class GrpcServiceChain : Chain UnaryMethods { get; } /// - /// The C# identifier used for the generated wrapper type. + /// The C# identifier used for the generated wrapper type. Initialised from + /// as {ProtoServiceName}GrpcHandler; rewritten by + /// if two discovered chains end up + /// with the same default name (e.g., two assemblies shipping a Greeter proto service). /// - public string TypeName { get; } + public string TypeName { get; private set; } public GrpcServiceChain(Type stubType, GrpcGraph parent) { @@ -97,8 +101,47 @@ public GrpcServiceChain(Type stubType, GrpcGraph parent) public override MiddlewareScoping Scoping => MiddlewareScoping.Grpc; + private MethodInfo[]? _discoveredBefores; + private MethodInfo[]? _discoveredAfters; + + /// + /// Methods declared on (or its proto base) that qualify as + /// [WolverineBefore] middleware for this chain — i.e., named per + /// or carrying the attribute, and whose + /// scope () admits . + /// Sorted ordinally by method name so Phase-1 generated source is byte-stable across + /// runs (reflection order is not guaranteed). Computed lazily; the returned reference + /// is stable across reads. + /// + public IReadOnlyList DiscoveredBefores + => _discoveredBefores ??= MiddlewarePolicy + .FilterMethods(this, StubType.GetMethods(), MiddlewarePolicy.BeforeMethodNames) + .OrderBy(m => m.Name, StringComparer.Ordinal) + .ToArray(); + + /// + /// Methods declared on (or its proto base) that qualify as + /// [WolverineAfter] postprocessors for this chain. Same scope, name-convention, + /// and ordinal-sort rules as . + /// + public IReadOnlyList DiscoveredAfters + => _discoveredAfters ??= MiddlewarePolicy + .FilterMethods(this, StubType.GetMethods(), MiddlewarePolicy.AfterMethodNames) + .OrderBy(m => m.Name, StringComparer.Ordinal) + .ToArray(); + public override IdempotencyStyle Idempotency { get; set; } = IdempotencyStyle.None; + /// + /// Applies a disambiguated on a chain whose default name collides + /// with another discovered chain. Called exclusively from + /// . + /// + internal void ApplyDisambiguatedTypeName(string disambiguatedName) + { + TypeName = disambiguatedName; + } + /// /// The runtime of the generated wrapper once compiled. Null before compilation. /// diff --git a/src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs b/src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs index 3f72c0b24..9b47577dc 100644 --- a/src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs +++ b/src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs @@ -11,7 +11,7 @@ namespace Wolverine.Grpc; /// /// Server-side gRPC interceptor that translates .NET exceptions thrown inside Wolverine-backed /// gRPC service methods into s. Registered automatically by -/// . +/// . /// /// /// diff --git a/src/Wolverine.Grpc/WolverineGrpcExtensions.cs b/src/Wolverine.Grpc/WolverineGrpcExtensions.cs index f8bdf3837..70751a46c 100644 --- a/src/Wolverine.Grpc/WolverineGrpcExtensions.cs +++ b/src/Wolverine.Grpc/WolverineGrpcExtensions.cs @@ -32,8 +32,39 @@ public static class WolverineGrpcExtensions /// gRPC host separately — use services.AddCodeFirstGrpc() for code-first /// services or services.AddGrpc() for proto-first services. /// + /// + /// The call is idempotent — only the first invocation wires registrations, + /// subsequent calls are no-ops (matching opts.UseGrpcRichErrorDetails()'s + /// marker pattern). Without this guard, repeat calls would stack the + /// twice, doubling exception + /// translation work and log output. + /// public static IServiceCollection AddWolverineGrpc(this IServiceCollection services) + => AddWolverineGrpc(services, configure: null); + + /// + /// Adds Wolverine's gRPC integration to the service collection and applies caller-supplied + /// configuration to the singleton . Idempotent — repeat + /// invocations re-run against the same options instance, so + /// additive registrations (e.g., opts.AddMiddleware<T>()) accumulate but service + /// registrations are not duplicated. + /// + /// The DI service collection. + /// Optional configuration callback for . + public static IServiceCollection AddWolverineGrpc( + this IServiceCollection services, + Action? configure) { + var options = EnsureOptionsRegistered(services); + + if (services.Any(x => x.ServiceType == typeof(WolverineGrpcMarker))) + { + configure?.Invoke(options); + return services; + } + + services.AddSingleton(); + services.AddSingleton(sp => { var runtime = (WolverineRuntime)sp.GetRequiredService(); @@ -42,14 +73,34 @@ public static IServiceCollection AddWolverineGrpc(this IServiceCollection servic }); services.AddSingleton(); - services.Configure(options => + services.Configure(opts => { - options.Interceptors.Add(); + opts.Interceptors.Add(); }); + configure?.Invoke(options); + return services; } + private static WolverineGrpcOptions EnsureOptionsRegistered(IServiceCollection services) + { + // The options instance must be reachable for the configure callback BEFORE the + // marker check returns, so additive customizations on a second AddWolverineGrpc() + // call land on the same singleton rather than silently dropping. + var existing = services.FirstOrDefault(d => d.ServiceType == typeof(WolverineGrpcOptions)) + ?.ImplementationInstance as WolverineGrpcOptions; + if (existing != null) return existing; + + var options = new WolverineGrpcOptions(); + services.AddSingleton(options); + return options; + } + + internal sealed class WolverineGrpcMarker + { + } + /// /// Discovers and maps all gRPC service types found in the assemblies already /// scanned by Wolverine. A type is discovered when: diff --git a/src/Wolverine.Grpc/WolverineGrpcOptions.cs b/src/Wolverine.Grpc/WolverineGrpcOptions.cs new file mode 100644 index 000000000..0b0373d8e --- /dev/null +++ b/src/Wolverine.Grpc/WolverineGrpcOptions.cs @@ -0,0 +1,42 @@ +using Wolverine.Configuration; +using Wolverine.Middleware; + +namespace Wolverine.Grpc; + +/// +/// Wolverine-side configuration for proto-first gRPC services. The gRPC counterpart to +/// WolverineHttpOptions — exposes a dedicated to +/// s so that policy-registered middleware can target gRPC +/// services without leaking through the global opts.Policies.AddMiddleware path +/// (which is intentionally HandlerChain-only). +/// +public sealed class WolverineGrpcOptions +{ + internal MiddlewarePolicy Middleware { get; } = new(); + + /// + /// Register a middleware type that will be applied to every + /// unless excludes it. + /// + /// Optional predicate restricting which gRPC service chains receive the middleware. + /// The middleware class (looked up by convention for Before/After/Finally methods). + public void AddMiddleware(Func? filter = null) + => AddMiddleware(typeof(T), filter); + + /// + /// Register a middleware type that will be applied to every + /// unless excludes it. + /// + /// The middleware class. + /// Optional predicate restricting which gRPC service chains receive the middleware. + public void AddMiddleware(Type middlewareType, Func? filter = null) + { + Func chainFilter = c => c is GrpcServiceChain; + if (filter != null) + { + chainFilter = c => c is GrpcServiceChain g && filter(g); + } + + Middleware.AddType(middlewareType, chainFilter); + } +} diff --git a/src/Wolverine/Runtime/Handlers/Executor.cs b/src/Wolverine/Runtime/Handlers/Executor.cs index 549be8e8c..aa28e19a1 100644 --- a/src/Wolverine/Runtime/Handlers/Executor.cs +++ b/src/Wolverine/Runtime/Handlers/Executor.cs @@ -302,7 +302,6 @@ private async IAsyncEnumerable StreamCoreAsync(Envelope envelope, await context.FlushOutgoingMessagesAsync(); stream = envelope.Response as IAsyncEnumerable; activity?.AddEvent(new ActivityEvent(WolverineTracing.StreamingStarted)); - _tracker.ExecutionFinished(envelope); } catch (Exception e) { @@ -314,20 +313,39 @@ private async IAsyncEnumerable StreamCoreAsync(Envelope envelope, if (stream == null) { - _contextPool.Return(context); activity?.SetStatus(ActivityStatusCode.Ok); + _tracker.ExecutionFinished(envelope); + _contextPool.Return(context); yield break; } + await using var enumerator = stream.GetAsyncEnumerator(cancellation); try { - await foreach (var item in stream.WithCancellation(cancellation)) + while (true) { - yield return item; + T current; + try + { + if (!await enumerator.MoveNextAsync()) + { + activity?.AddEvent(new ActivityEvent(WolverineTracing.StreamingCompleted)); + activity?.SetStatus(ActivityStatusCode.Ok); + _tracker.ExecutionFinished(envelope); + yield break; + } + + current = enumerator.Current; + } + catch (Exception e) + { + activity?.SetStatus(ActivityStatusCode.Error, e.GetType().Name); + _tracker.ExecutionFinished(envelope, e); + throw; + } + + yield return current; } - - activity?.AddEvent(new ActivityEvent(WolverineTracing.StreamingCompleted)); - activity?.SetStatus(ActivityStatusCode.Ok); } finally { diff --git a/src/Wolverine/Runtime/Handlers/TracingExecutor.cs b/src/Wolverine/Runtime/Handlers/TracingExecutor.cs index 6f3eacc6d..63d51e174 100644 --- a/src/Wolverine/Runtime/Handlers/TracingExecutor.cs +++ b/src/Wolverine/Runtime/Handlers/TracingExecutor.cs @@ -268,9 +268,6 @@ private async IAsyncEnumerable StreamCoreAsync(Envelope envelope, await context.FlushOutgoingMessagesAsync(); stream = envelope.Response as IAsyncEnumerable; activity?.AddEvent(new ActivityEvent(WolverineTracing.StreamingStarted)); - _tracker.ExecutionFinished(envelope); - _messageSucceeded(_logger, _messageTypeName, envelope.Id, - envelope.Destination?.ToString() ?? "local", null); } catch (Exception e) { @@ -279,26 +276,52 @@ private async IAsyncEnumerable StreamCoreAsync(Envelope envelope, _messageFailed(_logger, _messageTypeName, envelope.Id, envelope.Destination?.ToString() ?? "local", e); _contextPool.Return(context); + _executionFinished(_logger, envelope.CorrelationId!, _messageTypeName, envelope.Id, null); throw; } if (stream == null) { - _contextPool.Return(context); activity?.SetStatus(ActivityStatusCode.Ok); + _tracker.ExecutionFinished(envelope); + _messageSucceeded(_logger, _messageTypeName, envelope.Id, + envelope.Destination?.ToString() ?? "local", null); + _contextPool.Return(context); _executionFinished(_logger, envelope.CorrelationId!, _messageTypeName, envelope.Id, null); yield break; } + await using var enumerator = stream.GetAsyncEnumerator(cancellation); try { - await foreach (var item in stream.WithCancellation(cancellation)) + while (true) { - yield return item; + T current; + try + { + if (!await enumerator.MoveNextAsync()) + { + activity?.AddEvent(new ActivityEvent(WolverineTracing.StreamingCompleted)); + activity?.SetStatus(ActivityStatusCode.Ok); + _tracker.ExecutionFinished(envelope); + _messageSucceeded(_logger, _messageTypeName, envelope.Id, + envelope.Destination?.ToString() ?? "local", null); + yield break; + } + + current = enumerator.Current; + } + catch (Exception e) + { + activity?.SetStatus(ActivityStatusCode.Error, e.GetType().Name); + _tracker.ExecutionFinished(envelope, e); + _messageFailed(_logger, _messageTypeName, envelope.Id, + envelope.Destination?.ToString() ?? "local", e); + throw; + } + + yield return current; } - - activity?.AddEvent(new ActivityEvent(WolverineTracing.StreamingCompleted)); - activity?.SetStatus(ActivityStatusCode.Ok); } finally { diff --git a/src/Wolverine/Runtime/MessageContext.cs b/src/Wolverine/Runtime/MessageContext.cs index c735df229..0b84759b2 100644 --- a/src/Wolverine/Runtime/MessageContext.cs +++ b/src/Wolverine/Runtime/MessageContext.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Reflection; +using ImTools; using JasperFx.Core; using JasperFx.Core.Reflection; using Microsoft.Extensions.Logging; @@ -658,11 +659,10 @@ public async Task EnqueueCascadingAsync(object? message) // the case above. When ResponseType is set (StreamAsync path), the check above this // switch already captured the sequence; we only reach here during regular InvokeAsync // with a handler that returns a typed async sequence. - var asyncEnumInterface = message.GetType().GetInterfaces() - .FirstOrDefault(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)); - if (asyncEnumInterface != null) + var cascader = ResolveTypedAsyncEnumerableCascader(message.GetType()); + if (cascader != null) { - await CascadeTypedAsyncEnumerableAsync(message, asyncEnumInterface); + await (Task)cascader.Invoke(null, [message, this])!; return; } @@ -685,19 +685,38 @@ public async Task EnqueueCascadingAsync(object? message) private static readonly MethodInfo _cascadeTypedItemsMethod = typeof(MessageContext).GetMethod(nameof(CascadeTypedItemsAsync), BindingFlags.Static | BindingFlags.NonPublic)!; - private static async Task CascadeTypedItemsAsync(IAsyncEnumerable source, MessageContext context) + // Per-message-type cache of the constructed CascadeTypedItemsAsync MethodInfo (or null for + // message types that don't implement IAsyncEnumerable). Eliminates GetInterfaces() and + // MakeGenericMethod() from every cascade after the first for each unique type. ImHashMap is + // lock-free and copy-on-write — appropriate because the set of cascading message types + // stabilizes quickly after startup, making this write-rare/read-heavy. + private static ImHashMap _typedEnumerableCascadeMethods = + ImHashMap.Empty; + + private static MethodInfo? ResolveTypedAsyncEnumerableCascader(Type messageType) { - await foreach (var item in source) + if (_typedEnumerableCascadeMethods.TryFind(messageType, out var cached)) { - await context.EnqueueCascadingAsync(item); + return cached; } + + var asyncEnumInterface = messageType.GetInterfaces() + .FirstOrDefault(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)); + + var method = asyncEnumInterface != null + ? _cascadeTypedItemsMethod.MakeGenericMethod(asyncEnumInterface.GetGenericArguments()[0]) + : null; + + _typedEnumerableCascadeMethods = _typedEnumerableCascadeMethods.AddOrUpdate(messageType, method); + return method; } - private Task CascadeTypedAsyncEnumerableAsync(object asyncEnumerable, Type interfaceType) + private static async Task CascadeTypedItemsAsync(IAsyncEnumerable source, MessageContext context) { - var elementType = interfaceType.GetGenericArguments()[0]; - var method = _cascadeTypedItemsMethod.MakeGenericMethod(elementType); - return (Task)method.Invoke(null, [asyncEnumerable, this])!; + await foreach (var item in source) + { + await context.EnqueueCascadingAsync(item); + } } internal void ClearState()