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.