From 0686cc7974f38e2d1a928e69c886bd5ff1692432 Mon Sep 17 00:00:00 2001 From: Austin Harris Date: Thu, 24 Sep 2026 18:50:37 -0600 Subject: [PATCH] Container lifetimes for JSON-RPC services ServiceBinder.BindService(sessionId, serviceType, resolve) binds a type whose receiver is resolved per call from the RPC context, after the arguments are read and before the method runs. The core keeps no container dependency. AddJsonRpcService(ServiceLifetime, sessionId) resolves scoped and transient services from HttpContext.RequestServices on HTTP and from a scope the raw connection handler opens per document, published as IServiceProvidersFeature and disposed once the document is answered; a batch shares one scope. JsonRpcOptions.ServiceProviderSelector locates the provider from a custom context. Lifetime conflicts with the container, a non-singleton JsonRpcService subclass and a ContextFactory without a selector are refused at registration or startup. LifetimeBenchmarks measures the opt-in cost. --- .../JsonRpcConnectionHandler.Async.cs | 46 +- .../JsonRpcConnectionHandler.cs | 49 +- .../JsonRpcOptions.cs | 22 + .../JsonRpcServiceCollectionExtensions.cs | 175 ++++- AustinHarris.JsonRpc.AspNetCore/README.md | 51 +- AustinHarris.JsonRpcTestN/DiLifetimeTests.cs | 647 ++++++++++++++++++ CHANGELOG.md | 2 + Json-Rpc/Invocation/RpcMethod.cs | 57 ++ Json-Rpc/ServiceBinder.cs | 52 +- README.md | 4 +- .../Micro/AustinHarris.JsonRpc.Micro.csproj | 2 + benchmarks/Micro/LifetimeBenchmarks.cs | 110 +++ benchmarks/Micro/README.md | 1 + 13 files changed, 1169 insertions(+), 49 deletions(-) create mode 100644 AustinHarris.JsonRpcTestN/DiLifetimeTests.cs create mode 100644 benchmarks/Micro/LifetimeBenchmarks.cs diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs index 21c923f..14b3cd6 100644 --- a/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs @@ -2,6 +2,8 @@ using System.Threading.Tasks; using AustinHarris.JsonRpc.Serialization; using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; namespace AustinHarris.JsonRpc.AspNetCore { @@ -35,26 +37,42 @@ private async Task RunAsynchronousMethodsAsync(ConnectionContext connection) return; } reply.Clear(); - var pending = JsonRpcProcessor.ProcessAsync(session, document, reply, connection, _options.Serializer, token); - if (!pending.IsCompleted && wrote) + // The document's service scope, when a scoped or transient service is bound: published for + // the duration of the document and disposed asynchronously after its last response is + // written, which is after the running operation has been awaited on every path below. + var scope = _scopes == null ? default : _scopes.CreateAsyncScope(); + if (_scopes != null) connection.Features.Set(new ServiceProvidersFeature { RequestServices = scope.ServiceProvider }); + try { - bool closed = false; - try + var pending = JsonRpcProcessor.ProcessAsync(session, document, reply, connection, _options.Serializer, token); + if (!pending.IsCompleted && wrote) { - var flush = await output.FlushAsync(token).ConfigureAwait(false); - closed = flush.IsCompleted || flush.IsCanceled; - wrote = false; + bool closed = false; + try + { + var flush = await output.FlushAsync(token).ConfigureAwait(false); + closed = flush.IsCompleted || flush.IsCanceled; + wrote = false; + } + finally + { + // Even a failed flush cannot release the input or reply while invocation runs. + await pending.ConfigureAwait(false); + } + if (closed) return; } - finally + else await pending.ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + if (reply.WrittenCount != 0) { reply.CopyTo(output); wrote = true; } + } + finally + { + if (_scopes != null) { - // Even a failed flush cannot release the input or reply while invocation runs. - await pending.ConfigureAwait(false); + connection.Features.Set(null); + await scope.DisposeAsync().ConfigureAwait(false); } - if (closed) return; } - else await pending.ConfigureAwait(false); - token.ThrowIfCancellationRequested(); - if (reply.WrittenCount != 0) { reply.CopyTo(output); wrote = true; } } if (wrote) { diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs index d664604..027c5dd 100644 --- a/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs @@ -1,8 +1,11 @@ using System; using System.Buffers; +using System.Collections.Generic; using System.Threading.Tasks; using AustinHarris.JsonRpc.Serialization; using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace AustinHarris.JsonRpc.AspNetCore @@ -12,14 +15,55 @@ namespace AustinHarris.JsonRpc.AspNetCore /// back (optionally whitespace / newline separated) and receive responses in order. Wire it up with /// kestrel.ListenLocalhost(port, l => l.UseConnectionHandler<JsonRpcConnectionHandler>()). /// The connection's is the RPC context for every call. + /// When a scoped or transient service is bound to the handler's session, each document runs inside one + /// host-owned service scope, published on the connection as and disposed + /// once the document's response is written; the scope never spans documents. /// public partial class JsonRpcConnectionHandler : ConnectionHandler { private readonly JsonRpcOptions _options; + /// Set only when a scoped or transient service is bound to this handler's session: the cost of a scope per document is opt-in. + private readonly IServiceScopeFactory _scopes; - public JsonRpcConnectionHandler(IOptions options) + public JsonRpcConnectionHandler(IOptions options, IServiceProvider services = null) { _options = options?.Value ?? new JsonRpcOptions(); + _scopes = NeedsDocumentScope(services) ? services.GetService() : null; + } + + private bool NeedsDocumentScope(IServiceProvider services) + { + var registrations = services?.GetService>(); + if (registrations == null) return false; + string session = _options.SessionId ?? Handler.DefaultSessionId(); + foreach (var r in registrations) + { + if (r.Lifetime == ServiceLifetime.Singleton) continue; + if ((r.SessionId ?? _options.SessionId ?? Handler.DefaultSessionId()) == session) return true; + } + return false; + } + + /// + /// One scope per document: created from , handed to the methods through the + /// connection's (what the default + /// reads), removed and disposed once the whole document, batch included, has been answered. Every call of a + /// batch shares it; a transient service is still created per call. + /// + private void ProcessInScope(string session, in ReadOnlySequence document, IBufferWriter output, ConnectionContext connection) + { + using (var scope = _scopes.CreateScope()) + { + connection.Features.Set(new ServiceProvidersFeature { RequestServices = scope.ServiceProvider }); + try + { + JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer); + } + finally + { + connection.Features.Set(null); + } + } } /// Processes a connection using the hosting mode selected in options. @@ -50,7 +94,8 @@ private async Task RunSynchronousMethodsAsync(ConnectionContext connection) connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes.")); return; } - JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer); + if (_scopes == null) JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer); + else ProcessInScope(session, in document, output, connection); wrote = true; } } diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs index eb9f86d..353ac64 100644 --- a/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs @@ -1,6 +1,8 @@ using System; using AustinHarris.JsonRpc.Serialization; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; namespace AustinHarris.JsonRpc.AspNetCore { @@ -28,6 +30,26 @@ public class JsonRpcOptions /// public Func ContextFactory { get; set; } + /// + /// Finds the that scoped and transient services (see + /// AddJsonRpcService<T>(ServiceLifetime)) are resolved from, given the RPC context of the request + /// (what returns). Without one, the host handles an + /// (its RequestServices) and a raw (the scope the connection handler opens + /// per document, published as ). Required when + /// produces anything else and a non-singleton service is registered: the host refuses to start, and + /// MapJsonRpc(pattern, options) refuses to map, otherwise. A selector that returns null falls through to + /// the built-in one; when no provider is found the call fails with -32603. The root provider is never used. + /// + public Func ServiceProviderSelector { get; set; } + + /// The built-in selection: the HTTP request's services, or the per-document scope of a raw connection. + internal static IServiceProvider DefaultServiceProviderSelector(object context) + { + if (context is HttpContext http) return http.RequestServices; + if (context is ConnectionContext connection) return connection.Features.Get()?.RequestServices; + return null; + } + /// Largest request body accepted, in bytes. Larger bodies get 413. Default 4 MB. public long MaxRequestBytes { get; set; } = 4 * 1024 * 1024; diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs index 1cd0c04..37575e4 100644 --- a/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs @@ -21,6 +21,7 @@ public static IServiceCollection AddJsonRpc(this IServiceCollection services, Ac { if (configure != null) services.Configure(configure); else services.AddOptions(); + AddCore(services); services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; @@ -29,26 +30,53 @@ public static IServiceCollection AddJsonRpc(this IServiceCollection services, Ac /// /// Registers as a singleton built by the container and binds every /// [JsonRpcMethod] on it to the session when the host starts. Any class works, controllers included: - /// dependencies come from DI, the class does not need to derive from . + /// dependencies come from DI, the class does not need to derive from . The one + /// instance serves every request on every thread; for a service resolved per request, pass a lifetime to + /// . /// public static IServiceCollection AddJsonRpcService(this IServiceCollection services, string sessionId = null) where TService : class { - services.TryAddSingleton(); - services.AddSingleton(new JsonRpcServiceRegistration(typeof(TService), sessionId)); + return services.AddJsonRpcService(ServiceLifetime.Singleton, sessionId); + } + + /// + /// Registers with and binds every [JsonRpcMethod] + /// on it to the session when the host starts. resolves the service once + /// from the root container at startup. and + /// resolve nothing at startup: right before each call the service is resolved from the request's + /// (HttpContext.RequestServices on HTTP; the scope the raw connection handler + /// opens per document), so a scoped dependency such as a DbContext goes in the constructor as usual, every + /// call of a batch shares one scope and a transient is created per call. A registration of + /// already in the container must have the same lifetime; otherwise this throws, + /// or the host fails at startup when the conflicting registration is added later. A class deriving from + /// binds itself in its constructor and is accepted as a singleton only. + /// + public static IServiceCollection AddJsonRpcService(this IServiceCollection services, ServiceLifetime lifetime, string sessionId = null) where TService : class + { + AddRegistration(services, typeof(TService), lifetime, sessionId); return services; } - /// Registers every class in that declares at least one [JsonRpcMethod]. + /// Registers every class in that declares at least one [JsonRpcMethod], as singletons. public static IServiceCollection AddJsonRpcServicesFromAssembly(this IServiceCollection services, Assembly assembly, string sessionId = null) { + return services.AddJsonRpcServicesFromAssembly(assembly, ServiceLifetime.Singleton, sessionId); + } + + /// + /// Registers every class in that declares at least one [JsonRpcMethod] with + /// ; see . + /// + public static IServiceCollection AddJsonRpcServicesFromAssembly(this IServiceCollection services, Assembly assembly, ServiceLifetime lifetime, string sessionId = null) + { + if (assembly == null) throw new ArgumentNullException(nameof(assembly)); foreach (var type in assembly.GetTypes()) { if (type.IsAbstract || type.IsInterface || type.IsGenericTypeDefinition) continue; bool hasRpc = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) .Any(m => m.IsDefined(typeof(JsonRpcMethodAttribute), false)); if (!hasRpc) continue; - services.TryAddSingleton(type); - services.AddSingleton(new JsonRpcServiceRegistration(type, sessionId)); + AddRegistration(services, type, lifetime, sessionId); } return services; } @@ -56,47 +84,168 @@ public static IServiceCollection AddJsonRpcServicesFromAssembly(this IServiceCol /// /// Maps a POST endpoint that processes JSON-RPC documents. Options default to the registered /// (see ); pass to override per endpoint. + /// An endpoint whose replaces the must also + /// carry a when a scoped or transient service is registered. /// public static IEndpointConventionBuilder MapJsonRpc(this IEndpointRouteBuilder endpoints, string pattern = "/jsonrpc", JsonRpcOptions options = null) { var resolved = options ?? endpoints.ServiceProvider.GetService>()?.Value ?? new JsonRpcOptions(); + if (resolved.ContextFactory != null && resolved.ServiceProviderSelector == null) + { + var registrations = endpoints.ServiceProvider.GetService>(); + var perRequest = registrations?.FirstOrDefault(r => r.Lifetime != ServiceLifetime.Singleton); + if (perRequest != null) throw MissingSelector(perRequest.Type); + } + if (options != null && options.ServiceProviderSelector != null) + endpoints.ServiceProvider.GetService()?.Add(options.ServiceProviderSelector); return resolved.EnableAsyncMethods ? endpoints.MapPost(pattern, http => JsonRpcEndpoint.HandleAsynchronousMethodsAsync(http, resolved)) : endpoints.MapPost(pattern, http => JsonRpcEndpoint.HandleSynchronousMethodsAsync(http, resolved)); } + private static void AddCore(IServiceCollection services) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + services.TryAddSingleton(new JsonRpcServiceDescriptors(services)); + services.TryAddSingleton(); + } + + private static void AddRegistration(IServiceCollection services, Type type, ServiceLifetime lifetime, string sessionId) + { + AddCore(services); + if (lifetime != ServiceLifetime.Singleton && typeof(JsonRpcService).IsAssignableFrom(type)) + throw new ArgumentException("JSON-RPC service '" + type + "' derives from JsonRpcService, whose constructor binds the instance itself, so it cannot be " + lifetime + ". Use a class that does not derive from JsonRpcService.", nameof(lifetime)); + bool registered = false; + foreach (var descriptor in services) + { + if (descriptor.ServiceType != type) continue; + if (descriptor.Lifetime != lifetime) throw LifetimeConflict(type, lifetime, descriptor.Lifetime); + registered = true; + } + if (!registered) services.Add(new ServiceDescriptor(type, type, lifetime)); + services.AddSingleton(new JsonRpcServiceRegistration(type, lifetime, sessionId)); + } + + private static InvalidOperationException LifetimeConflict(Type type, ServiceLifetime declared, ServiceLifetime registered) + { + return new InvalidOperationException("JSON-RPC service '" + type + "' is registered in the container as " + registered + " but AddJsonRpcService declared it " + declared + ". Pass the container's lifetime to AddJsonRpcService, or register the service once with the lifetime you want."); + } + + private static InvalidOperationException MissingSelector(Type type) + { + return new InvalidOperationException("JSON-RPC service '" + type + "' is scoped or transient and JsonRpcOptions.ContextFactory replaces the HttpContext as the RPC context, so set JsonRpcOptions.ServiceProviderSelector to return the request's IServiceProvider from that context."); + } + internal sealed class JsonRpcServiceRegistration { - public JsonRpcServiceRegistration(Type type, string sessionId) { Type = type; SessionId = sessionId; } + public JsonRpcServiceRegistration(Type type, ServiceLifetime lifetime, string sessionId) { Type = type; Lifetime = lifetime; SessionId = sessionId; } public Type Type { get; } + public ServiceLifetime Lifetime { get; } public string SessionId { get; } } + /// + /// The service collection the host was built from, kept so the binder can compare each registration's declared + /// lifetime with the container's descriptors at startup: a scoped descriptor behind a singleton registration + /// would otherwise be captured silently at startup and leak. + /// + internal sealed class JsonRpcServiceDescriptors + { + private readonly IServiceCollection _services; + + public JsonRpcServiceDescriptors(IServiceCollection services) { _services = services; } + + public void Check(Type type, ServiceLifetime lifetime) + { + foreach (var descriptor in _services) + if (descriptor.ServiceType == type && descriptor.Lifetime != lifetime) throw LifetimeConflict(type, lifetime, descriptor.Lifetime); + } + } + + /// + /// Locates the request's from the RPC context and resolves the receiver of a + /// scoped or transient service from it, once per call. Selectors are tried most recently added first + /// (per-endpoint options, then from ), + /// the built-in one last; the root provider is never a fallback. + /// + internal sealed class JsonRpcServiceResolver + { + private Func[] _selectors; + + public JsonRpcServiceResolver(IOptions options) + { + var configured = options?.Value?.ServiceProviderSelector; + _selectors = configured != null + ? new[] { configured, new Func(JsonRpcOptions.DefaultServiceProviderSelector) } + : new[] { new Func(JsonRpcOptions.DefaultServiceProviderSelector) }; + } + + public void Add(Func selector) + { + if (selector == null) return; + lock (this) + { + if (Array.IndexOf(_selectors, selector) >= 0) return; + var next = new Func[_selectors.Length + 1]; + next[0] = selector; + Array.Copy(_selectors, 0, next, 1, _selectors.Length); + Volatile.Write(ref _selectors, next); + } + } + + public object Resolve(object context, Type serviceType) + { + var selectors = Volatile.Read(ref _selectors); + for (int i = 0; i < selectors.Length; i++) + { + var provider = selectors[i](context); + if (provider != null) return provider.GetRequiredService(serviceType); + } + throw new InvalidOperationException("JSON-RPC service '" + serviceType + "' is scoped or transient, but no IServiceProvider was found for the RPC context" + + (context == null ? " (null)" : " of type '" + context.GetType() + "'") + ". Set JsonRpcOptions.ServiceProviderSelector to locate the request's provider from that context."); + } + } + /// Resolves registered services from the container and binds them before the host starts accepting requests. internal sealed class JsonRpcBinderHostedService : IHostedService { private readonly IServiceProvider _provider; private readonly IEnumerable _registrations; private readonly JsonRpcOptions _options; + private readonly JsonRpcServiceResolver _resolver; + private readonly JsonRpcServiceDescriptors _descriptors; - public JsonRpcBinderHostedService(IServiceProvider provider, IEnumerable registrations, IOptions options) + public JsonRpcBinderHostedService(IServiceProvider provider, IEnumerable registrations, IOptions options, + JsonRpcServiceResolver resolver, JsonRpcServiceDescriptors descriptors) { _provider = provider; _registrations = registrations; _options = options.Value; + _resolver = resolver; + _descriptors = descriptors; } public Task StartAsync(CancellationToken cancellationToken) { foreach (var r in _registrations) { - var instance = _provider.GetRequiredService(r.Type); + _descriptors.Check(r.Type, r.Lifetime); // The effective session: the registration's own, then JsonRpcOptions.SessionId, then the default. var session = r.SessionId ?? _options.SessionId ?? Handler.DefaultSessionId(); - // Always bind, whatever the type: attribute binding replaces entries by name, so binding a - // JsonRpcService subclass that already bound itself to the default session is harmless, and a - // subclass constructed with base(false) is bound nowhere else. - ServiceBinder.BindService(session, instance); + if (r.Lifetime == ServiceLifetime.Singleton) + { + // Always bind, whatever the type: attribute binding replaces entries by name, so binding a + // JsonRpcService subclass that already bound itself to the default session is harmless, and a + // subclass constructed with base(false) is bound nowhere else. + ServiceBinder.BindService(session, _provider.GetRequiredService(r.Type)); + continue; + } + // Scoped and transient: bind the type; the receiver is resolved per call from the request's provider, + // which the selector chain finds from the RPC context. Nothing is resolved or probed here. + if (_options.ContextFactory != null && _options.ServiceProviderSelector == null) throw MissingSelector(r.Type); + var type = r.Type; + var resolver = _resolver; + ServiceBinder.BindService(session, type, context => resolver.Resolve(context, type)); } return Task.CompletedTask; } diff --git a/AustinHarris.JsonRpc.AspNetCore/README.md b/AustinHarris.JsonRpc.AspNetCore/README.md index aed7e4a..16aba34 100644 --- a/AustinHarris.JsonRpc.AspNetCore/README.md +++ b/AustinHarris.JsonRpc.AspNetCore/README.md @@ -74,18 +74,48 @@ app.MapJsonRpc("/legacy", new JsonRpcOptions { SessionId = "legacy-clients", Ser ## Services and lifetime -`AddJsonRpcService()` registers `T` as a singleton unless `T` is already registered. When the host starts, -each registered service is resolved once from the root container and bound; that one instance then serves every -HTTP request and every raw connection, concurrently. So `T` and its dependencies must be thread-safe, and `T` -cannot take scoped dependencies such as an EF Core `DbContext`: with scope validation on, the host fails at -startup; with it off, the dependency leaks. For per-request services, resolve them inside the method from -`((HttpContext)Handler.RpcContext()).RequestServices` on HTTP; a raw connection's context is the -`ConnectionContext`, which has no request scope. Do not inject request-scoped state into a service; read -per-request data from the context instead. +`AddJsonRpcService()` registers `T` as a singleton, or reuses an existing singleton registration of `T`. When +the host starts, each singleton service is resolved once from the root container and bound; that one instance then +serves every HTTP request and every raw connection, concurrently. So `T` and its dependencies must be thread-safe, +and `T` cannot take scoped dependencies such as an EF Core `DbContext`: with scope validation on (the default in +Development), the host fails at startup; with it off, the dependency leaks. A singleton that needs per-request +services uses `IDbContextFactory`, or resolves them from `((HttpContext)Handler.RpcContext()).RequestServices` +captured before its first `await` (the ambient context is per thread, see the main README). + +`AddJsonRpcService(ServiceLifetime.Scoped)` and `ServiceLifetime.Transient` bind the type instead of an +instance. Nothing is resolved at startup; right before each call the service is resolved from the request's +`IServiceProvider`, so a `DbContext` or any other scoped dependency goes in the constructor as usual: + +- **HTTP:** the provider is `HttpContext.RequestServices`, the request scope ASP.NET Core already owns. +- **Raw connections:** the connection handler opens one scope per document from `IServiceScopeFactory`, publishes + it on the connection as `IServiceProvidersFeature`, and disposes it once the document's response is written, + before it is flushed. The scope never spans documents, so a connection cannot accumulate state. The cost is one + scope per document, paid only when a scoped or transient service is bound to the handler's session. +- Every call of a batch shares the document's scope; a transient service is still created per call. +- Static `[JsonRpcMethod]` methods never resolve anything. +- There is no parameter injection: a method takes its dependencies through the service's constructor, or reads + the context (`JsonRpcContext.Current().Value`) before its first `await`. + +The lifetime you pass must match any registration of `T` already in the container: a mismatch throws from +`AddJsonRpcService`, or at startup when the conflicting registration is added later. A class deriving from +`JsonRpcService` binds itself in its constructor and is accepted as a singleton only. The host does not probe +services at startup; the container's own `ValidateOnBuild` and `ValidateScopes` catch a scoped dependency inside a +singleton. + +When `ContextFactory` replaces the `HttpContext` as the RPC context, also set `ServiceProviderSelector` so the host +can find the request's provider from your context. With a scoped or transient service registered and no selector, +the host refuses to start (options from `AddJsonRpc`) or `MapJsonRpc(pattern, options)` refuses to map. A selector +that returns null falls through to the built-in one; when no provider is found the call fails with `-32603` and the +error handler sees an `InvalidOperationException` naming the service. The root provider is never used. + +```csharp +builder.Services.AddScoped(); // an EF Core DbContext +builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); // takes OrdersContext in its constructor +``` `AddJsonRpcServicesFromAssembly(assembly)` does the same for every non-abstract class in the assembly that -declares a `[JsonRpcMethod]`. Private methods count, so the attribute is the whole access list, and an MVC -controller that carries it becomes a singleton too. +declares a `[JsonRpcMethod]`, as singletons unless you pass a `ServiceLifetime`. Private methods count, so the +attribute is the whole access list, and an MVC controller that carries it is registered too. The host binds every registered service to its effective session: the session given to `AddJsonRpcService`, else `JsonRpcOptions.SessionId`, else the default. A class deriving from `JsonRpcService` also binds itself to the @@ -140,6 +170,7 @@ in the main README. | `SessionSelector` | null | HTTP | pick the session per request from the `HttpContext`; an id that was never registered answers `-32601` and creates nothing | | `Serializer` | session, then `Config.Serializer` | HTTP and raw | serializer for this host | | `ContextFactory` | `HttpContext` | HTTP | what `JsonRpcContext.Current()` returns | +| `ServiceProviderSelector` | `HttpContext.RequestServices`, or the raw document scope | HTTP and raw | where scoped and transient services are resolved from, given the RPC context; required with `ContextFactory` when such a service is registered | | `MaxRequestBytes` | 4 MB | HTTP body, or one raw document | larger bodies get 413; a larger raw document aborts the connection | | `ResponseContentType` | `application/json` | HTTP | | | `NoContentForNotifications` | true | HTTP | 204 for notifications, otherwise 200 with an empty body | diff --git a/AustinHarris.JsonRpcTestN/DiLifetimeTests.cs b/AustinHarris.JsonRpcTestN/DiLifetimeTests.cs new file mode 100644 index 0000000..bd729c7 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/DiLifetimeTests.cs @@ -0,0 +1,647 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// A scoped dependency: one per request scope, disposed with it. + public sealed class RequestMarker : IDisposable + { + public static int Disposed; + public readonly string Id = Guid.NewGuid().ToString("N"); + public void Dispose() => Interlocked.Increment(ref Disposed); + } + + /// A service registered with ServiceLifetime.Scoped: takes the scoped dependency in its constructor, as a DbContext would be. + public sealed class ScopedRpcService + { + private readonly RequestMarker _marker; + public ScopedRpcService(RequestMarker marker) { _marker = marker; } + + [JsonRpcMethod("scoped.id")] public string Id() => _marker.Id; + [JsonRpcMethod("scoped.idAsync")] public async Task IdAsync() { await Task.Yield(); return _marker.Id; } + [JsonRpcMethod("scoped.context")] public string ContextTypeName() => JsonRpcContext.Current().Value?.GetType().Name; + [JsonRpcMethod("scoped.static")] public static int Static() => 3; + } + + /// A service registered with ServiceLifetime.Transient: a new instance per call. + public sealed class TransientRpcService + { + private static int _instances; + private readonly int _instance = Interlocked.Increment(ref _instances); + [JsonRpcMethod("transient.instance")] public int Instance() => _instance; + } + + /// Scoped and transient services over HTTP and a raw connection, synchronous processing. + [TestFixture] + [NonParallelizable] + public class DiLifetimeTests + { + private const string Session = "di-lifetimes"; + private WebApplication _app; + private HttpClient _http; + private int _tcpPort; + + [OneTimeSetUp] + public async Task StartHost() + { + _tcpPort = FreePort(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(k => + { + k.Listen(IPAddress.Loopback, 0); + k.Listen(IPAddress.Loopback, _tcpPort, l => l.UseConnectionHandler()); + }); + builder.Services.AddScoped(); + builder.Services.AddJsonRpc(o => o.SessionId = Session); + builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); + builder.Services.AddJsonRpcService(ServiceLifetime.Transient); + _app = builder.Build(); + _app.MapJsonRpc("/rpc"); + await _app.StartAsync(); + var addresses = _app.Services.GetRequiredService().Features.Get().Addresses; + _http = new HttpClient { BaseAddress = new Uri(addresses.First(a => !a.EndsWith(":" + _tcpPort))), Timeout = TimeSpan.FromSeconds(10) }; + } + + [OneTimeTearDown] + public async Task StopHost() + { + _http?.Dispose(); + if (_app != null) { await _app.StopAsync(); await _app.DisposeAsync(); } + Handler.DestroySession(Session); + } + + internal static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private async Task PostAsync(string json) + { + using var response = await _http.PostAsync("/rpc", new StringContent(json, Encoding.UTF8, "application/json")); + return await response.Content.ReadAsStringAsync(); + } + + internal static async Task ExchangeAsync(int port, string document, int expectedResponses) + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port); + using var stream = client.GetStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes(document)); + var text = new StringBuilder(); + var bytes = new byte[4096]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (CountResponses(text.ToString()) < expectedResponses) + { + int count = await stream.ReadAsync(bytes.AsMemory(), cts.Token); + if (count == 0) break; + text.Append(Encoding.UTF8.GetString(bytes, 0, count)); + } + return text.ToString(); + } + + private static int CountResponses(string text) + { + int n = 0, at = 0; + while ((at = text.IndexOf("\"jsonrpc\":\"2.0\"", at, StringComparison.Ordinal)) >= 0) { n++; at++; } + return n; + } + + internal static void AssertHexId(string value) + { + Assert.AreEqual(32, value?.Length, "a marker id: " + value); + Assert.IsTrue(value.All(c => Uri.IsHexDigit(c)), value); + } + + [Test] + public async Task Http_ScopedService_IsResolvedFromTheRequestScope_OncePerRequest() + { + var first = JObject.Parse(await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""scoped.id"",""id"":1}")); + var second = JObject.Parse(await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""scoped.id"",""id"":2}")); + AssertHexId((string)first["result"]); + AssertHexId((string)second["result"]); + Assert.AreNotEqual((string)first["result"], (string)second["result"], "each HTTP request has its own scope"); + } + + [Test] + public async Task Http_Batch_SharesOneScope_AndCreatesATransientPerCall() + { + var batch = JArray.Parse(await PostAsync(@"[{""jsonrpc"":""2.0"",""method"":""scoped.id"",""id"":1},{""jsonrpc"":""2.0"",""method"":""scoped.id"",""id"":2},{""jsonrpc"":""2.0"",""method"":""transient.instance"",""id"":3},{""jsonrpc"":""2.0"",""method"":""transient.instance"",""id"":4}]")); + Assert.AreEqual(4, batch.Count); + Assert.AreEqual((string)batch[0]["result"], (string)batch[1]["result"], "one scope per document"); + Assert.AreNotEqual((int)batch[2]["result"], (int)batch[3]["result"], "a transient per call"); + } + + [Test] + public async Task Http_ScopedDependency_IsDisposedWithTheRequest() + { + int before = Volatile.Read(ref RequestMarker.Disposed); + AssertHexId((string)JObject.Parse(await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""scoped.id"",""id"":1}"))["result"]); + // the request scope is disposed by the pipeline after the response is sent + var deadline = DateTime.UtcNow.AddSeconds(5); + while (Volatile.Read(ref RequestMarker.Disposed) == before && DateTime.UtcNow < deadline) await Task.Delay(10); + Assert.Greater(Volatile.Read(ref RequestMarker.Disposed), before); + } + + [Test] + public async Task Http_ContextIsStillTheHttpContext_AndStaticMethodsNeedNoInstance() + { + Assert.AreEqual("DefaultHttpContext", (string)JObject.Parse(await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""scoped.context"",""id"":1}"))["result"]); + Assert.AreEqual(3, (int)JObject.Parse(await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""scoped.static"",""id"":2}"))["result"]); + } + + [Test] + public async Task Tcp_EachDocumentGetsItsOwnScope_DisposedBeforeTheResponseIsFlushed() + { + int before = Volatile.Read(ref RequestMarker.Disposed); + var text = await ExchangeAsync(_tcpPort, "{\"method\":\"scoped.id\",\"id\":1}{\"method\":\"scoped.id\",\"id\":2}", 2); + var ids = text.Split(new[] { "{\"jsonrpc\"" }, StringSplitOptions.RemoveEmptyEntries).Select(s => (string)JObject.Parse("{\"jsonrpc\"" + s)["result"]).ToArray(); + Assert.AreEqual(2, ids.Length, text); + AssertHexId(ids[0]); + AssertHexId(ids[1]); + Assert.AreNotEqual(ids[0], ids[1], "one scope per document"); + // the document scope is disposed before the connection flushes, so both markers are gone by the time the client reads + Assert.GreaterOrEqual(Volatile.Read(ref RequestMarker.Disposed), before + 2); + } + + [Test] + public async Task Tcp_Batch_SharesTheDocumentScope() + { + var text = await ExchangeAsync(_tcpPort, "[{\"method\":\"scoped.id\",\"id\":1},{\"method\":\"scoped.id\",\"id\":2},{\"method\":\"transient.instance\",\"id\":3},{\"method\":\"transient.instance\",\"id\":4}]", 4); + var batch = JArray.Parse(text); + Assert.AreEqual((string)batch[0]["result"], (string)batch[1]["result"]); + Assert.AreNotEqual((int)batch[2]["result"], (int)batch[3]["result"]); + } + + [Test] + public async Task Tcp_ContextIsTheConnection() + { + var text = await ExchangeAsync(_tcpPort, "{\"method\":\"scoped.context\",\"id\":1}", 1); + StringAssert.Contains("Connection", (string)JObject.Parse(text)["result"]); + } + } + + /// The same services with EnableAsyncMethods: the document scope lives until the awaited operation is done. + [TestFixture] + [NonParallelizable] + public class DiLifetimeAsyncTests + { + private const string Session = "di-lifetimes-async"; + private WebApplication _app; + private HttpClient _http; + private int _tcpPort; + + [OneTimeSetUp] + public async Task StartHost() + { + _tcpPort = DiLifetimeTests.FreePort(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(k => + { + k.Listen(IPAddress.Loopback, 0); + k.Listen(IPAddress.Loopback, _tcpPort, l => l.UseConnectionHandler()); + }); + builder.Services.AddScoped(); + builder.Services.AddJsonRpc(o => { o.SessionId = Session; o.EnableAsyncMethods = true; }); + builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); + builder.Services.AddJsonRpcService(ServiceLifetime.Transient); + _app = builder.Build(); + _app.MapJsonRpc("/rpc"); + await _app.StartAsync(); + var addresses = _app.Services.GetRequiredService().Features.Get().Addresses; + _http = new HttpClient { BaseAddress = new Uri(addresses.First(a => !a.EndsWith(":" + _tcpPort))), Timeout = TimeSpan.FromSeconds(10) }; + } + + [OneTimeTearDown] + public async Task StopHost() + { + _http?.Dispose(); + if (_app != null) { await _app.StopAsync(); await _app.DisposeAsync(); } + Handler.DestroySession(Session); + } + + [Test] + public async Task Http_AsyncScopedMethod_ResolvesBeforeTheFirstAwait() + { + using var a = await _http.PostAsync("/rpc", new StringContent(@"{""jsonrpc"":""2.0"",""method"":""scoped.idAsync"",""id"":1}", Encoding.UTF8, "application/json")); + using var b = await _http.PostAsync("/rpc", new StringContent(@"{""jsonrpc"":""2.0"",""method"":""scoped.idAsync"",""id"":2}", Encoding.UTF8, "application/json")); + var first = (string)JObject.Parse(await a.Content.ReadAsStringAsync())["result"]; + var second = (string)JObject.Parse(await b.Content.ReadAsStringAsync())["result"]; + DiLifetimeTests.AssertHexId(first); + DiLifetimeTests.AssertHexId(second); + Assert.AreNotEqual(first, second); + } + + [Test] + public async Task Tcp_AsyncDocuments_EachHaveAScope_SharedByTheBatch_DisposedAfterTheAwait() + { + int before = Volatile.Read(ref RequestMarker.Disposed); + var text = await DiLifetimeTests.ExchangeAsync(_tcpPort, + "{\"method\":\"scoped.idAsync\",\"id\":1}[{\"method\":\"scoped.idAsync\",\"id\":2},{\"method\":\"scoped.id\",\"id\":3},{\"method\":\"transient.instance\",\"id\":4},{\"method\":\"transient.instance\",\"id\":5}]", 5); + int split = text.IndexOf('['); + var single = (string)JObject.Parse(text.Substring(0, split))["result"]; + var batch = JArray.Parse(text.Substring(split)); + DiLifetimeTests.AssertHexId(single); + Assert.AreEqual((string)batch[0]["result"], (string)batch[1]["result"], "the batch shares one scope, async and sync calls alike"); + Assert.AreNotEqual(single, (string)batch[0]["result"], "the next document gets a new scope"); + Assert.AreNotEqual((int)batch[2]["result"], (int)batch[3]["result"]); + Assert.GreaterOrEqual(Volatile.Read(ref RequestMarker.Disposed), before + 2, "both document scopes were disposed before their responses were flushed"); + } + } + + /// Registration and startup validation, custom contexts and the selector. + [TestFixture] + [NonParallelizable] + public class DiLifetimeValidationTests + { + public sealed class SelfBindingService : JsonRpcService + { + public SelfBindingService() : base(false) { } + [JsonRpcMethod("validation.self")] public int Self() => 1; + } + + /// A custom RPC context produced by ContextFactory. + public sealed class Envelope + { + public HttpContext Http; + } + + private static WebApplicationBuilder NewBuilder() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, 0)); + builder.Services.AddScoped(); + return builder; + } + + private static async Task PostAsync(WebApplication app, string path, string json) + { + var address = app.Services.GetRequiredService().Features.Get().Addresses.First(); + using var http = new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(10) }; + using var response = await http.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json")); + return await response.Content.ReadAsStringAsync(); + } + + [Test] + public void SingletonRegistration_OverAScopedDescriptor_IsRefused() + { + var services = new ServiceCollection(); + services.AddScoped(); + var ex = Assert.Throws(() => services.AddJsonRpcService()); + StringAssert.Contains(nameof(ScopedRpcService), ex.Message); + StringAssert.Contains("Scoped", ex.Message); + } + + [Test] + public void MatchingLifetime_ReusesTheExistingDescriptor() + { + var services = new ServiceCollection(); + services.AddScoped(); + services.AddJsonRpcService(ServiceLifetime.Scoped); + Assert.AreEqual(1, services.Count(d => d.ServiceType == typeof(ScopedRpcService))); + services.AddJsonRpcService(ServiceLifetime.Transient); + Assert.AreEqual(ServiceLifetime.Transient, services.Single(d => d.ServiceType == typeof(TransientRpcService)).Lifetime); + } + + [Test] + public void NonSingletonJsonRpcServiceSubclass_IsRefused() + { + var services = new ServiceCollection(); + var ex = Assert.Throws(() => services.AddJsonRpcService(ServiceLifetime.Scoped)); + StringAssert.Contains(nameof(SelfBindingService), ex.Message); + Assert.DoesNotThrow(() => services.AddJsonRpcService(), "as a singleton it is fine"); + } + + [Test] + public async Task ConflictingRegistrationAddedLater_FailsAtStartup() + { + var builder = NewBuilder(); + builder.Services.AddJsonRpc(o => o.SessionId = "validation-conflict"); + builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); + builder.Services.AddSingleton(); + await using var app = builder.Build(); + var ex = Assert.ThrowsAsync(async () => await app.StartAsync()); + StringAssert.Contains(nameof(ScopedRpcService), ex.Message); + StringAssert.Contains("Singleton", ex.Message); + Handler.DestroySession("validation-conflict"); + } + + [Test] + public async Task ContextFactoryWithoutSelector_AndAScopedService_FailsAtStartup() + { + var builder = NewBuilder(); + builder.Services.AddJsonRpc(o => { o.SessionId = "validation-nosel"; o.ContextFactory = http => new Envelope { Http = http }; }); + builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); + await using var app = builder.Build(); + var ex = Assert.ThrowsAsync(async () => await app.StartAsync()); + StringAssert.Contains("ServiceProviderSelector", ex.Message); + StringAssert.Contains(nameof(ScopedRpcService), ex.Message); + Handler.DestroySession("validation-nosel"); + } + + [Test] + public async Task ContextFactoryWithoutSelector_AndOnlySingletons_IsFine() + { + const string session = "validation-singletons"; + var builder = NewBuilder(); + builder.Services.AddJsonRpc(o => { o.SessionId = session; o.ContextFactory = http => new Envelope { Http = http }; }); + builder.Services.AddJsonRpcService(); + await using var app = builder.Build(); + app.MapJsonRpc("/rpc"); + await app.StartAsync(); + try + { + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"Envelope\",\"id\":1}", await PostAsync(app, "/rpc", @"{""jsonrpc"":""2.0"",""method"":""di.context"",""id"":1}")); + } + finally { await app.StopAsync(); Handler.DestroySession(session); } + } + + [Test] + public async Task PerEndpointContextFactoryWithoutSelector_FailsWhenMapped() + { + var builder = NewBuilder(); + builder.Services.AddJsonRpc(o => o.SessionId = "validation-endpoint"); + builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); + await using var app = builder.Build(); + var ex = Assert.Throws(() => app.MapJsonRpc("/custom", new JsonRpcOptions { SessionId = "validation-endpoint", ContextFactory = http => new Envelope { Http = http } })); + StringAssert.Contains("ServiceProviderSelector", ex.Message); + Handler.DestroySession("validation-endpoint"); + } + + [Test] + public async Task Selector_LocatesTheProviderFromACustomContext_GloballyAndPerEndpoint() + { + const string session = "validation-selector"; + var builder = NewBuilder(); + builder.Services.AddJsonRpc(o => + { + o.SessionId = session; + o.ContextFactory = http => new Envelope { Http = http }; + o.ServiceProviderSelector = context => context is Envelope e ? e.Http.RequestServices : null; + }); + builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); + await using var app = builder.Build(); + app.MapJsonRpc("/rpc"); + // a second endpoint with its own context type and its own selector + app.MapJsonRpc("/tuple", new JsonRpcOptions + { + SessionId = session, + ContextFactory = http => Tuple.Create(http), + ServiceProviderSelector = context => context is Tuple t ? t.Item1.RequestServices : null + }); + await app.StartAsync(); + try + { + var global = JObject.Parse(await PostAsync(app, "/rpc", @"{""jsonrpc"":""2.0"",""method"":""scoped.id"",""id"":1}")); + DiLifetimeTests.AssertHexId((string)global["result"]); + Assert.AreEqual("Envelope", (string)JObject.Parse(await PostAsync(app, "/rpc", @"{""jsonrpc"":""2.0"",""method"":""scoped.context"",""id"":2}"))["result"]); + + var endpoint = JObject.Parse(await PostAsync(app, "/tuple", @"{""jsonrpc"":""2.0"",""method"":""scoped.id"",""id"":3}")); + DiLifetimeTests.AssertHexId((string)endpoint["result"]); + Assert.AreNotEqual((string)global["result"], (string)endpoint["result"]); + StringAssert.StartsWith("Tuple", (string)JObject.Parse(await PostAsync(app, "/tuple", @"{""jsonrpc"":""2.0"",""method"":""scoped.context"",""id"":4}"))["result"]); + } + finally { await app.StopAsync(); Handler.DestroySession(session); } + } + + [Test] + public async Task SelectorFindingNoProvider_IsAnInternalError_NamingTheService_NeverTheRootProvider() + { + const string session = "validation-null-selector"; + var builder = NewBuilder(); + builder.Services.AddJsonRpc(o => + { + o.SessionId = session; + o.ContextFactory = http => new object(); + o.ServiceProviderSelector = context => null; + }); + builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); + await using var app = builder.Build(); + app.MapJsonRpc("/rpc"); + Exception seen = null; + Config.SetErrorHandler(session, (request, error) => { seen = error.data as Exception; return error; }); + await app.StartAsync(); + try + { + var response = JObject.Parse(await PostAsync(app, "/rpc", @"{""jsonrpc"":""2.0"",""method"":""scoped.id"",""id"":1}")); + Assert.AreEqual(-32603, (int)response["error"]["code"], response.ToString()); + Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type, "redacted on the wire"); + Assert.IsInstanceOf(seen); + StringAssert.Contains(nameof(ScopedRpcService), seen.Message); + // the static method needs no provider at all + Assert.AreEqual(3, (int)JObject.Parse(await PostAsync(app, "/rpc", @"{""jsonrpc"":""2.0"",""method"":""scoped.static"",""id"":2}"))["result"]); + } + finally { await app.StopAsync(); Handler.DestroySession(session); } + } + + [Test] + public async Task RawConnection_WithoutNonSingletonServices_PublishesNoScope() + { + // the scope per document is opt-in: with singletons only, the handler leaves the connection's features alone + const string session = "validation-raw-singleton"; + int port = DiLifetimeTests.FreePort(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, port, l => l.UseConnectionHandler())); + builder.Services.AddJsonRpc(o => o.SessionId = session); + builder.Services.AddJsonRpcService(); + await using var app = builder.Build(); + await app.StartAsync(); + try + { + var text = await DiLifetimeTests.ExchangeAsync(port, "{\"method\":\"probe.hasScope\",\"id\":1}", 1); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":false,\"id\":1}", text); + } + finally { await app.StopAsync(); Handler.DestroySession(session); } + } + + [Test] + public async Task RawConnection_WithAScopedService_PublishesTheScope_OnlyDuringTheDocument() + { + const string session = "validation-raw-scoped"; + int port = DiLifetimeTests.FreePort(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, port, l => l.UseConnectionHandler())); + builder.Services.AddScoped(); + builder.Services.AddJsonRpc(o => o.SessionId = session); + builder.Services.AddJsonRpcService(); + builder.Services.AddJsonRpcService(ServiceLifetime.Scoped); + await using var app = builder.Build(); + await app.StartAsync(); + try + { + var text = await DiLifetimeTests.ExchangeAsync(port, "{\"method\":\"probe.hasScope\",\"id\":1}{\"method\":\"probe.sameScope\",\"id\":2}", 2); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":true,\"id\":1}{\"jsonrpc\":\"2.0\",\"result\":false,\"id\":2}", text, + "the feature is present during a document, and the next document sees a different scope"); + } + finally { await app.StopAsync(); Handler.DestroySession(session); } + } + + public sealed class FeatureProbeService + { + private IServiceProvider _last; + + [JsonRpcMethod("probe.hasScope")] + public bool HasScope() + { + var connection = (ConnectionContext)JsonRpcContext.Current().Value; + _last = connection.Features.Get()?.RequestServices; + return _last != null; + } + + [JsonRpcMethod("probe.sameScope")] + public bool SameScope() + { + var connection = (ConnectionContext)JsonRpcContext.Current().Value; + var now = connection.Features.Get()?.RequestServices; + return now == null || ReferenceEquals(now, _last); + } + } + } + + /// The core seam on its own: ServiceBinder.BindService(sessionId, type, resolve) without any container. + [TestFixture] + public class FactoryBindingTests + { + public class TaggedService + { + public static int Resolved; + public static int StaticCalls; + private readonly string _tag; + public TaggedService(string tag) { _tag = tag; } + + [JsonRpcMethod("fb.tag")] public string Tag() => _tag; + [JsonRpcMethod("fb.tagWith")] public string TagWith(string suffix, int times = 1) => _tag + string.Concat(Enumerable.Repeat(suffix, times)); + [JsonRpcMethod("fb.async", ContextFlow = RpcContextFlow.Flow)] + public async Task TagAsync() { await Task.Yield(); return _tag + ":" + (JsonRpcContext.Current().Value as string); } + [JsonRpcMethod("fb.static")] public static int Static() { StaticCalls++; return 42; } + } + + public sealed class DerivedService : TaggedService + { + public DerivedService(string tag) : base(tag) { } + [JsonRpcMethod("fb.derived")] public string Derived() => "derived:" + Tag(); + } + + private string _session; + [SetUp] public void SetUp() => _session = "factory-" + Guid.NewGuid().ToString("N"); + [TearDown] public void TearDown() { Handler.DestroySession(_session); } + + private string Run(string method, object context, string parameters = null, string serializer = "jsmn") + { + var json = "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + "\"" + (parameters == null ? "" : ",\"params\":" + parameters) + ",\"id\":1}"; + return JsonRpcProcessor.ProcessSync(_session, json, context, SerializerCatalog.Create(serializer)); + } + + [TestCase("jsmn")] [TestCase("newtonsoft")] [TestCase("stj")] + public void TheResolverGetsTheContext_AndItsInstanceIsInvoked(string serializer) + { + ServiceBinder.BindService(_session, typeof(TaggedService), context => new TaggedService((string)context)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"A\",\"id\":1}", Run("fb.tag", "A", serializer: serializer)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"B\",\"id\":1}", Run("fb.tag", "B", serializer: serializer)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"B--\",\"id\":1}", Run("fb.tagWith", "B", "{\"suffix\":\"-\",\"times\":2}", serializer)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"C-\",\"id\":1}", Run("fb.tagWith", "C", "[\"-\"]", serializer), "defaults still apply"); + } + + [Test] + public void TheResolverRunsOncePerCall_BeforeTheMethod_AndNeverForStaticMethods() + { + int resolved = 0; + ServiceBinder.BindService(_session, typeof(TaggedService), context => { resolved++; return new TaggedService("x"); }); + int staticBefore = TaggedService.StaticCalls; + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":42,\"id\":1}", Run("fb.static", null)); + Assert.AreEqual(0, resolved, "a static method has no receiver"); + Assert.AreEqual(staticBefore + 1, TaggedService.StaticCalls); + Run("fb.tag", null); + Assert.AreEqual(1, resolved); + JsonRpcProcessor.ProcessSync(_session, "[{\"jsonrpc\":\"2.0\",\"method\":\"fb.tag\",\"id\":1},{\"jsonrpc\":\"2.0\",\"method\":\"fb.tag\",\"id\":2},{\"jsonrpc\":\"2.0\",\"method\":\"fb.tag\"}]", null); + Assert.AreEqual(4, resolved, "once per call, notifications included"); + // an unknown method or a binding error never reaches the resolver + StringAssert.Contains("-32601", Run("fb.missing", null)); + StringAssert.Contains("-32602", Run("fb.tagWith", null, "[1, \"not an int\"]")); + Assert.AreEqual(4, resolved); + } + + [Test] + public void ANullOrForeignInstance_IsAnInternalError_NamingTheServiceType() + { + object answer = null; + ServiceBinder.BindService(_session, typeof(TaggedService), context => answer); + Exception seen = null; + Config.SetErrorHandler(_session, (request, error) => { seen = error.data as Exception; return error; }); + + var response = JObject.Parse(Run("fb.tag", null)); + Assert.AreEqual(-32603, (int)response["error"]["code"]); + Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type); + Assert.IsInstanceOf(seen); + StringAssert.Contains("null", seen.Message); + StringAssert.Contains(typeof(TaggedService).FullName, seen.Message); + + answer = "not a service"; + seen = null; + response = JObject.Parse(Run("fb.tag", null)); + Assert.AreEqual(-32603, (int)response["error"]["code"]); + Assert.IsInstanceOf(seen); + StringAssert.Contains("System.String", seen.Message); + StringAssert.Contains(typeof(TaggedService).FullName, seen.Message); + } + + [Test] + public void ADerivedInstance_IsAccepted_AndInheritedMethodsBindOnTheDerivedType() + { + ServiceBinder.BindService(_session, typeof(DerivedService), context => new DerivedService((string)context)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"derived:D\",\"id\":1}", Run("fb.derived", "D")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"D\",\"id\":1}", Run("fb.tag", "D"), "the base class method is bound too"); + } + + [Test] + public async Task AsyncMethods_ResolveOnTheInvokingThread_BeforeTheFirstAwait() + { + int thread = -1; + ServiceBinder.BindService(_session, typeof(TaggedService), context => { thread = Thread.CurrentThread.ManagedThreadId; return new TaggedService((string)context); }); + int caller = Thread.CurrentThread.ManagedThreadId; + var response = await JsonRpcProcessor.ProcessAsync(_session, "{\"jsonrpc\":\"2.0\",\"method\":\"fb.async\",\"id\":1}", "ctx"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"ctx:ctx\",\"id\":1}", response, "the receiver saw the context, and so did the method after its await (Flow)"); + Assert.AreEqual(caller, thread, "resolved synchronously on the invoking thread"); + } + + [Test] + public void Arguments_AreChecked() + { + Assert.Throws(() => ServiceBinder.BindService(_session, (Type)null, c => null)); + Assert.Throws(() => ServiceBinder.BindService(_session, typeof(TaggedService), null)); + Assert.Throws(() => ServiceBinder.BindService(null, typeof(TaggedService), c => null)); + Assert.Throws(() => ServiceBinder.BindService(_session, (object)null)); + Assert.Throws(() => ServiceBinder.BindService(_session, typeof(List<>), c => null)); + var method = typeof(TaggedService).GetMethod(nameof(TaggedService.Tag)); + var ex = Assert.Throws(() => AustinHarris.JsonRpc.Invocation.RpcMethod.FromMethod("fb.tag", method, typeof(string), c => null)); + StringAssert.Contains(typeof(TaggedService).FullName, ex.Message); + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index b1a90cd..d229ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ behaviour: a breaking change to either means a new major version. - `AustinHarris.JsonRpc.AspNetCore`: `MapJsonRpc` endpoint (`PipeReader` in, `BodyWriter` out), a raw Kestrel `ConnectionHandler`, DI registration (`AddJsonRpcService`, `AddJsonRpcServicesFromAssembly`), `EnableAsyncMethods` for asynchronous HTTP and ordered raw-connection processing. - The request id is available inside a method (`Handler.RpcRequestId`, `JsonRpcContext.CurrentRequestId`, kind and raw bytes), read on demand at no cost to methods that do not ask. - `Config.SetPreProcessHandler(sessionId, …)` and `Config.SetPostProcessHandler(sessionId, …)`, symmetric with the default-session setters. `Config.SetBeforeProcessHandler(sessionId, …)` remains as an obsolete alias. +- `ServiceBinder.BindService(sessionId, serviceType, resolve)` binds a type whose instance is resolved per call from the RPC context: the seam for container lifetimes, with no container dependency in the core. +- `AddJsonRpcService(ServiceLifetime, sessionId)` and the matching `AddJsonRpcServicesFromAssembly` overload: scoped and transient services are resolved per call from `HttpContext.RequestServices`, or from a scope the raw connection handler opens per document and publishes as `IServiceProvidersFeature`; a batch shares one scope. `JsonRpcOptions.ServiceProviderSelector` locates the provider from a custom context. A lifetime that conflicts with the container's registration, a non-singleton `JsonRpcService` subclass, and a `ContextFactory` without a selector are refused at registration or startup. - `protected JsonRpcService(bool autoBind)`: a subclass constructed with `base(false)` binds itself nowhere, for services that a host or an explicit `BindService` call binds. - `SECURITY.md` (private vulnerability reporting) and this changelog. diff --git a/Json-Rpc/Invocation/RpcMethod.cs b/Json-Rpc/Invocation/RpcMethod.cs index 0d79280..36ad2c4 100644 --- a/Json-Rpc/Invocation/RpcMethod.cs +++ b/Json-Rpc/Invocation/RpcMethod.cs @@ -121,6 +121,63 @@ public static RpcMethod FromMethod(string name, MethodInfo method, object target return Build(name, ps, method.ReturnType, parameterNames, args => Expression.Call(instance, method, args), contextFlow: contextFlow); } + /// + /// Builds the invokers for an instance method whose receiver is produced per invocation. Right before the + /// method body runs, is called once with the RPC context of the request being + /// served (what returns) and must answer with an instance of + /// . This is the seam for container-managed lifetimes: the resolver can look + /// the request's scope up through the context and return a scoped or transient service. Nothing is cached + /// or disposed here. A static method keeps a null receiver and never resolves. A null result or another + /// type is an naming the service type, answered as -32603. + /// + public static RpcMethod FromMethod(string name, MethodInfo method, Type serviceType, Func resolve, string[] parameterNames = null, RpcContextFlow contextFlow = RpcContextFlow.None) + { + if (method == null) throw new ArgumentNullException(nameof(method)); + if (serviceType == null) throw new ArgumentNullException(nameof(serviceType)); + if (resolve == null) throw new ArgumentNullException(nameof(resolve)); + if (serviceType.ContainsGenericParameters) + throw new ArgumentException("JSON-RPC method '" + name + "': the service type '" + serviceType + "' is not a closed type.", nameof(serviceType)); + if (!method.DeclaringType.IsAssignableFrom(serviceType)) + throw new ArgumentException("JSON-RPC method '" + name + "' is declared by '" + method.DeclaringType + "', which '" + serviceType + "' is not.", nameof(serviceType)); + if (method.IsStatic) return FromMethod(name, method, null, parameterNames, contextFlow); + RejectAsyncReturnType(name, method); + var ps = method.GetParameters(); + // (TService)ResolveReceiver(resolve, name): evaluated once per invocation. The arguments are read into + // locals first, so a request the serializer refuses (-32602) never resolves a service. + var receiver = Expression.Call(ResolveReceiverGeneric.MakeGenericMethod(serviceType), Expression.Constant(resolve), Expression.Constant(name)); + return Build(name, ps, method.ReturnType, parameterNames, args => CallAfterArguments(receiver, method, args), contextFlow: contextFlow); + } + + private static Expression CallAfterArguments(Expression receiver, MethodInfo method, Expression[] args) + { + var locals = new List(); + var body = new List(); + var passed = new Expression[args.Length]; + for (int i = 0; i < args.Length; i++) + { + // a variable (ref JsonRpcException, the cancellation token) is passed through as it is + if (args[i] is ParameterExpression) { passed[i] = args[i]; continue; } + var local = Expression.Variable(args[i].Type, "arg" + i); + locals.Add(local); + body.Add(Expression.Assign(local, args[i])); + passed[i] = local; + } + body.Add(Expression.Call(receiver, method, passed)); + return locals.Count == 0 ? body[0] : Expression.Block(locals, body); + } + + private static readonly MethodInfo ResolveReceiverGeneric = typeof(RpcMethod).GetMethod(nameof(ResolveReceiver), BindingFlags.NonPublic | BindingFlags.Static); + + /// The per-invocation receiver of a factory-bound method: the resolver gets the ambient RPC context and must answer with a . + private static T ResolveReceiver(Func resolve, string name) + { + var instance = resolve(Handler.RpcContext()); + if (instance is T typed) return typed; + throw new InvalidOperationException(instance == null + ? "JSON-RPC method '" + name + "': the service resolver returned null instead of an instance of '" + typeof(T) + "'." + : "JSON-RPC method '" + name + "': the service resolver returned a '" + instance.GetType() + "', not an instance of '" + typeof(T) + "'."); + } + /// /// Builds the invokers for an interface contract method dispatched to its implementation on . /// The contract supplies the parameter list, names, defaults and return type; the call is compiled against the diff --git a/Json-Rpc/ServiceBinder.cs b/Json-Rpc/ServiceBinder.cs index 90128bd..62b3b36 100644 --- a/Json-Rpc/ServiceBinder.cs +++ b/Json-Rpc/ServiceBinder.cs @@ -86,10 +86,40 @@ public static bool UnbindMethod(string name) BindService(sessionID, new T()); } + /// + /// Registers every [JsonRpcMethod] of 's type on session , + /// invoking them on that one instance from every thread; it must be thread-safe. + /// public static void BindService(string sessionID, Object instance) { - var item = instance.GetType(); + if (sessionID == null) throw new ArgumentNullException(nameof(sessionID)); + if (instance == null) throw new ArgumentNullException(nameof(instance)); + Bind(sessionID, instance.GetType(), instance, null); + } + /// + /// Registers every [JsonRpcMethod] of on session + /// without an instance. Right before each call, is handed the RPC context of the + /// request (what returns) and returns the instance to invoke; it runs once per + /// invocation, on the invoking thread. This is how a container's scoped and transient lifetimes reach a method: + /// the resolver looks the request's scope up through the context and asks it for the service. The binder never + /// constructs, caches or disposes anything itself, and the core takes no dependency on any container. Static + /// methods never resolve. A resolver that returns null or another type fails the call with -32603 (an + /// naming the service type, visible to the error handler). + /// + public static void BindService(string sessionID, Type serviceType, Func resolve) + { + if (sessionID == null) throw new ArgumentNullException(nameof(sessionID)); + if (serviceType == null) throw new ArgumentNullException(nameof(serviceType)); + if (resolve == null) throw new ArgumentNullException(nameof(resolve)); + if (serviceType.ContainsGenericParameters) + throw new ArgumentException("A closed type is required: '" + serviceType + "'.", nameof(serviceType)); + Bind(sessionID, serviceType, null, resolve); + } + + /// Attribute discovery shared by the instance and the resolver overloads; exactly one of and is set. + private static void Bind(string sessionID, Type item, object instance, Func resolve) + { var methods = item.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) .Where(m => m.GetCustomAttributes(typeof(JsonRpcMethodAttribute), false).Length > 0); foreach (var meth in methods) @@ -130,16 +160,22 @@ public static void BindService(string sessionID, Object instance) foreach (JsonRpcMethodAttribute handlerAttribute in atdata) { var methodName = string.IsNullOrEmpty(handlerAttribute.JsonMethodName) ? meth.Name : handlerAttribute.JsonMethodName; - var rpc = RpcMethod.FromMethod(methodName, meth, meth.IsStatic ? null : instance, jsonNames, handlerAttribute.ContextFlow); + var rpc = resolve != null && !meth.IsStatic + ? RpcMethod.FromMethod(methodName, meth, item, resolve, jsonNames, handlerAttribute.ContextFlow) + : RpcMethod.FromMethod(methodName, meth, meth.IsStatic ? null : instance, jsonNames, handlerAttribute.ContextFlow); Delegate legacy = null; - try - { - legacy = Delegate.CreateDelegate(System.Linq.Expressions.Expression.GetDelegateType(paras.Values.ToArray()), meth.IsStatic ? null : instance, meth); - } - catch (ArgumentException) + if (instance != null || meth.IsStatic) { - // e.g. ref parameters: no Func<> shape exists; the compiled invoker still works + try + { + legacy = Delegate.CreateDelegate(System.Linq.Expressions.Expression.GetDelegateType(paras.Values.ToArray()), meth.IsStatic ? null : instance, meth); + } + catch (ArgumentException) + { + // e.g. ref parameters: no Func<> shape exists; the compiled invoker still works + } } + // a resolver-bound method has no instance to close a legacy delegate over; invocation uses rpc var handlerSession = Handler.GetSessionHandler(sessionID); handlerSession.MetaData.AddService(methodName, paras, defaultValues, legacy, rpc); } diff --git a/README.md b/README.md index 1dfda2c..b920169 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ That is the whole server. The rest of this page is about exposing methods, putti Any class works, not only `JsonRpcService` subclasses: bind an instance with `ServiceBinder.BindService(sessionId, instance)`. A `JsonRpcService` subclass binds itself to the default session in its parameterless constructor. Write `: base(false)` for a subclass that something else binds (the AspNetCore host binds every registered service to its effective session) and `: base(sessionId)` to bind to another session. -One instance serves every request on every thread, so a service must be thread-safe. When the AspNetCore package builds a service through DI it is a singleton created once at startup; see [Kestrel HTTP endpoint](#kestrel-http-endpoint). +An instance bound with `BindService(sessionId, instance)` serves every request on every thread, so it must be thread-safe. `BindService(sessionId, typeof(T), resolve)` binds a type instead: right before each call the resolver is handed the RPC context and returns the instance to invoke, which is how a container's scoped and transient lifetimes reach a method (the AspNetCore package does this for `AddJsonRpcService(ServiceLifetime.Scoped)`; see [Kestrel HTTP endpoint](#kestrel-http-endpoint)). The core takes no dependency on any container: the resolver is a plain delegate, so Microsoft.Extensions.DependencyInjection, Autofac and a hand-written factory all fit. Static methods never resolve. ### Delegates @@ -207,7 +207,7 @@ app.Run(); A request or batch answers `200 application/json`; a notification answers `204`. The body goes from `PipeReader` to `BodyWriter` without becoming a string. Inside a method, `JsonRpcContext.Current().Value` is the `HttpContext`. -`AddJsonRpcService()` registers `T` as a singleton unless `T` is already registered. It is resolved once from the root container when the host starts, and that one instance serves every request on every thread, so it must be thread-safe and cannot take scoped dependencies such as an EF Core `DbContext`. For per-request services, resolve them in the method from `((HttpContext)Handler.RpcContext()).RequestServices`. To await `Task` and `ValueTask` methods, set `o.EnableAsyncMethods = true` in `AddJsonRpc`; the request is then cancelled when the client disconnects (`RequestAborted`). The other options (session per request, serializer, body size limit, content type) and the per-endpoint overload `MapJsonRpc(pattern, options)` are in the [package README](AustinHarris.JsonRpc.AspNetCore/README.md). +`AddJsonRpcService()` registers `T` as a singleton: resolved once from the root container when the host starts, one instance for every request on every thread, so it must be thread-safe and cannot take scoped dependencies such as an EF Core `DbContext` (a singleton uses `IDbContextFactory`, or captures `((HttpContext)Handler.RpcContext()).RequestServices` before its first `await`). `AddJsonRpcService(ServiceLifetime.Scoped)` (or `Transient`) resolves `T` per call from the request's provider instead: `HttpContext.RequestServices` on HTTP, a scope the raw connection handler opens and disposes per document. A `DbContext` then goes in the constructor as usual, every call of a batch shares one scope, and a transient is created per call. A lifetime that disagrees with an existing registration of `T` is refused, at registration or at startup. To await `Task` and `ValueTask` methods, set `o.EnableAsyncMethods = true` in `AddJsonRpc`; the request is then cancelled when the client disconnects (`RequestAborted`). The other options (session per request, serializer, body size limit, content type) and the per-endpoint overload `MapJsonRpc(pattern, options)` are in the [package README](AustinHarris.JsonRpc.AspNetCore/README.md). ### Kestrel raw connections (TCP, Unix socket, named pipe) diff --git a/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj b/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj index 3c84503..097a4f8 100644 --- a/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj +++ b/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj @@ -19,6 +19,8 @@ + + diff --git a/benchmarks/Micro/LifetimeBenchmarks.cs b/benchmarks/Micro/LifetimeBenchmarks.cs new file mode 100644 index 0000000..571da1c --- /dev/null +++ b/benchmarks/Micro/LifetimeBenchmarks.cs @@ -0,0 +1,110 @@ +using System; +using System.Text; +using AustinHarris.JsonRpc.Serialization; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace AustinHarris.JsonRpc.Micro +{ + /// + /// What a container-managed receiver costs. The same addInt request through an instance-bound + /// registration (the singleton default), through a resolver that hands back a cached instance (the + /// per-invocation resolver call alone), and through a resolver that asks the request's + /// Microsoft.Extensions.DependencyInjection scope for a scoped or a transient service, with the scope created + /// and disposed around the document as the raw connection handler does. Scope_Only is that scope + /// without any request. The instance row must stay where DispatchBenchmarks.AddInt is and allocate + /// nothing; the other rows are the opt-in price of ServiceLifetime.Scoped and Transient. + /// + [MemoryDiagnoser(displayGenColumns: false)] + public class LifetimeBenchmarks + { + private const string Instance = "lifetime-instance"; + private const string Factory = "lifetime-factory"; + private const string Scoped = "lifetime-scoped"; + private const string Transient = "lifetime-transient"; + + private PooledByteBufferWriter _out; + private ReadOnlyMemory _addInt; + private Service _cached; + private ServiceProvider _provider; + private IServiceScopeFactory _scopes; + + public sealed class Service + { + [JsonRpcMethod] private int addInt(int l, int r) => l + r; + } + + [GlobalSetup] + public void Setup() + { + ServiceBinder.BindService(Instance, new Service()); + _cached = new Service(); + ServiceBinder.BindService(Factory, typeof(Service), context => _cached); + + var services = new ServiceCollection(); + services.AddScoped(); + services.AddTransient(); + _provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + _scopes = _provider.GetRequiredService(); + // the RPC context stands in for the request's provider, as HttpContext.RequestServices or the raw + // connection's IServiceProvidersFeature does in the AspNetCore package + ServiceBinder.BindService(Scoped, typeof(Service), context => ((IServiceProvider)context).GetRequiredService()); + ServiceBinder.BindService(Transient, typeof(Service), context => ((IServiceProvider)context).GetRequiredService()); + + _out = new PooledByteBufferWriter(256); + _addInt = Encoding.UTF8.GetBytes("{\"method\":\"addInt\",\"params\":[1,7],\"id\":2}"); + Expect(AddInt_Instance); + Expect(AddInt_Factory); + Expect(AddInt_Scoped); + Expect(AddInt_Transient); + } + + [GlobalCleanup] + public void Cleanup() + { + _out.Dispose(); + _provider.Dispose(); + foreach (var session in new[] { Instance, Factory, Scoped, Transient }) Handler.DestroySession(session); + } + + [Benchmark(Baseline = true)] + public int AddInt_Instance() => Run(Instance, null); + + [Benchmark] + public int AddInt_Factory() => Run(Factory, _cached); + + [Benchmark] + public int AddInt_Scoped() + { + using (var scope = _scopes.CreateScope()) return Run(Scoped, scope.ServiceProvider); + } + + [Benchmark] + public int AddInt_Transient() + { + using (var scope = _scopes.CreateScope()) return Run(Transient, scope.ServiceProvider); + } + + [Benchmark] + public int Scope_Only() + { + using (var scope = _scopes.CreateScope()) return scope.ServiceProvider.GetHashCode(); + } + + private int Run(string session, object context) + { + var w = _out; + w.Clear(); + JsonRpcProcessor.Process(session, _addInt, w, context); + return w.WrittenCount; + } + + private void Expect(Func row) + { + row(); + var actual = _out.ToString(); + const string expected = "{\"jsonrpc\":\"2.0\",\"result\":8,\"id\":2}"; + if (actual != expected) throw new InvalidOperationException("unexpected response: " + actual); + } + } +} diff --git a/benchmarks/Micro/README.md b/benchmarks/Micro/README.md index 5123b60..160dd7b 100644 --- a/benchmarks/Micro/README.md +++ b/benchmarks/Micro/README.md @@ -14,6 +14,7 @@ Benchmark classes: - `InterfaceBindingBenchmarks`: the same five shapes through a contract registered with `ServiceBinder.BindInterface`, plus one and two levels of interface-typed properties (`Calc.addInt`, `Admin.Calc.addInt`). Interface rows should match the class rows of `DispatchBenchmarks`; the tree rows pay only for the longer method name. - `BindingComparisonBenchmarks`: `addInt`, decimal and string through the same class registered with `[JsonRpcMethod]` and through `BindInterface`, in one process, so a drift of the machine between runs cannot masquerade as a binding cost. - `SessionRegistryBenchmarks`: the session registry on the request path (last hit, snapshot, unknown id, register/lookup/destroy, one request end to end), each quiet and with a background thread registering and destroying sessions (`Churn`), which forces the snapshot refresh on every lookup. Use it to judge a change to the registry or its dictionary type: the `Churn` rows show what registry changes cost requests. +- `LifetimeBenchmarks`: `addInt` through an instance-bound registration, through a resolver that returns a cached instance (`ServiceBinder.BindService(session, type, resolve)`, the seam behind `AddJsonRpcService(ServiceLifetime)`), and through a resolver that asks a Microsoft.Extensions.DependencyInjection scope for a scoped or a transient service with the scope created and disposed around the document, as the raw connection handler does; plus that scope alone. The instance row must match `DispatchBenchmarks.AddInt` and allocate nothing; the other rows are the price of opting into scoped and transient lifetimes. - `AsyncDispatchBenchmarks`: a synchronous method through `Process` and `ProcessAsync`, `Task` and `ValueTask` methods that complete inline, and a method that yields once, each with the default `RpcContextFlow.None` and with `RpcContextFlow.Flow`. The inline default rows should allocate nothing; the `Flow` rows pay for the execution-context bridge; the yielding rows show the cost of a real suspension. Read the `Allocated` column first: a non-zero value on a numeric shape means the request touched the GC, which the fast path must not do. Then compare `Mean`, but only between runs on an idle machine or within one run: background load biases ratios as well as absolute numbers, which is why `BindingComparisonBenchmarks` puts both registrations in one process.