Skip to content

Commit 0686cc7

Browse files
committed
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<T>(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.
1 parent 160897a commit 0686cc7

13 files changed

Lines changed: 1169 additions & 49 deletions

File tree

‎AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs‎

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
using System.Threading.Tasks;
33
using AustinHarris.JsonRpc.Serialization;
44
using Microsoft.AspNetCore.Connections;
5+
using Microsoft.AspNetCore.Http.Features;
6+
using Microsoft.Extensions.DependencyInjection;
57

68
namespace AustinHarris.JsonRpc.AspNetCore
79
{
@@ -35,26 +37,42 @@ private async Task RunAsynchronousMethodsAsync(ConnectionContext connection)
3537
return;
3638
}
3739
reply.Clear();
38-
var pending = JsonRpcProcessor.ProcessAsync(session, document, reply, connection, _options.Serializer, token);
39-
if (!pending.IsCompleted && wrote)
40+
// The document's service scope, when a scoped or transient service is bound: published for
41+
// the duration of the document and disposed asynchronously after its last response is
42+
// written, which is after the running operation has been awaited on every path below.
43+
var scope = _scopes == null ? default : _scopes.CreateAsyncScope();
44+
if (_scopes != null) connection.Features.Set<IServiceProvidersFeature>(new ServiceProvidersFeature { RequestServices = scope.ServiceProvider });
45+
try
4046
{
41-
bool closed = false;
42-
try
47+
var pending = JsonRpcProcessor.ProcessAsync(session, document, reply, connection, _options.Serializer, token);
48+
if (!pending.IsCompleted && wrote)
4349
{
44-
var flush = await output.FlushAsync(token).ConfigureAwait(false);
45-
closed = flush.IsCompleted || flush.IsCanceled;
46-
wrote = false;
50+
bool closed = false;
51+
try
52+
{
53+
var flush = await output.FlushAsync(token).ConfigureAwait(false);
54+
closed = flush.IsCompleted || flush.IsCanceled;
55+
wrote = false;
56+
}
57+
finally
58+
{
59+
// Even a failed flush cannot release the input or reply while invocation runs.
60+
await pending.ConfigureAwait(false);
61+
}
62+
if (closed) return;
4763
}
48-
finally
64+
else await pending.ConfigureAwait(false);
65+
token.ThrowIfCancellationRequested();
66+
if (reply.WrittenCount != 0) { reply.CopyTo(output); wrote = true; }
67+
}
68+
finally
69+
{
70+
if (_scopes != null)
4971
{
50-
// Even a failed flush cannot release the input or reply while invocation runs.
51-
await pending.ConfigureAwait(false);
72+
connection.Features.Set<IServiceProvidersFeature>(null);
73+
await scope.DisposeAsync().ConfigureAwait(false);
5274
}
53-
if (closed) return;
5475
}
55-
else await pending.ConfigureAwait(false);
56-
token.ThrowIfCancellationRequested();
57-
if (reply.WrittenCount != 0) { reply.CopyTo(output); wrote = true; }
5876
}
5977
if (wrote)
6078
{

‎AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
using System;
22
using System.Buffers;
3+
using System.Collections.Generic;
34
using System.Threading.Tasks;
45
using AustinHarris.JsonRpc.Serialization;
56
using Microsoft.AspNetCore.Connections;
7+
using Microsoft.AspNetCore.Http.Features;
8+
using Microsoft.Extensions.DependencyInjection;
69
using Microsoft.Extensions.Options;
710

811
namespace AustinHarris.JsonRpc.AspNetCore
@@ -12,14 +15,55 @@ namespace AustinHarris.JsonRpc.AspNetCore
1215
/// back (optionally whitespace / newline separated) and receive responses in order. Wire it up with
1316
/// <c>kestrel.ListenLocalhost(port, l => l.UseConnectionHandler&lt;JsonRpcConnectionHandler&gt;())</c>.
1417
/// The connection's <see cref="ConnectionContext"/> is the RPC context for every call.
18+
/// When a scoped or transient service is bound to the handler's session, each document runs inside one
19+
/// host-owned service scope, published on the connection as <see cref="IServiceProvidersFeature"/> and disposed
20+
/// once the document's response is written; the scope never spans documents.
1521
/// </summary>
1622
public partial class JsonRpcConnectionHandler : ConnectionHandler
1723
{
1824
private readonly JsonRpcOptions _options;
25+
/// <summary>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.</summary>
26+
private readonly IServiceScopeFactory _scopes;
1927

20-
public JsonRpcConnectionHandler(IOptions<JsonRpcOptions> options)
28+
public JsonRpcConnectionHandler(IOptions<JsonRpcOptions> options, IServiceProvider services = null)
2129
{
2230
_options = options?.Value ?? new JsonRpcOptions();
31+
_scopes = NeedsDocumentScope(services) ? services.GetService<IServiceScopeFactory>() : null;
32+
}
33+
34+
private bool NeedsDocumentScope(IServiceProvider services)
35+
{
36+
var registrations = services?.GetService<IEnumerable<JsonRpcServiceCollectionExtensions.JsonRpcServiceRegistration>>();
37+
if (registrations == null) return false;
38+
string session = _options.SessionId ?? Handler.DefaultSessionId();
39+
foreach (var r in registrations)
40+
{
41+
if (r.Lifetime == ServiceLifetime.Singleton) continue;
42+
if ((r.SessionId ?? _options.SessionId ?? Handler.DefaultSessionId()) == session) return true;
43+
}
44+
return false;
45+
}
46+
47+
/// <summary>
48+
/// One scope per document: created from <see cref="IServiceScopeFactory"/>, handed to the methods through the
49+
/// connection's <see cref="IServiceProvidersFeature"/> (what the default <see cref="JsonRpcOptions.ServiceProviderSelector"/>
50+
/// reads), removed and disposed once the whole document, batch included, has been answered. Every call of a
51+
/// batch shares it; a transient service is still created per call.
52+
/// </summary>
53+
private void ProcessInScope(string session, in ReadOnlySequence<byte> document, IBufferWriter<byte> output, ConnectionContext connection)
54+
{
55+
using (var scope = _scopes.CreateScope())
56+
{
57+
connection.Features.Set<IServiceProvidersFeature>(new ServiceProvidersFeature { RequestServices = scope.ServiceProvider });
58+
try
59+
{
60+
JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer);
61+
}
62+
finally
63+
{
64+
connection.Features.Set<IServiceProvidersFeature>(null);
65+
}
66+
}
2367
}
2468

2569
/// <summary>Processes a connection using the hosting mode selected in options.</summary>
@@ -50,7 +94,8 @@ private async Task RunSynchronousMethodsAsync(ConnectionContext connection)
5094
connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
5195
return;
5296
}
53-
JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer);
97+
if (_scopes == null) JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer);
98+
else ProcessInScope(session, in document, output, connection);
5499
wrote = true;
55100
}
56101
}

‎AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System;
22
using AustinHarris.JsonRpc.Serialization;
3+
using Microsoft.AspNetCore.Connections;
34
using Microsoft.AspNetCore.Http;
5+
using Microsoft.AspNetCore.Http.Features;
46

57
namespace AustinHarris.JsonRpc.AspNetCore
68
{
@@ -28,6 +30,26 @@ public class JsonRpcOptions
2830
/// </summary>
2931
public Func<HttpContext, object> ContextFactory { get; set; }
3032

33+
/// <summary>
34+
/// Finds the <see cref="IServiceProvider"/> that scoped and transient services (see
35+
/// <c>AddJsonRpcService&lt;T&gt;(ServiceLifetime)</c>) are resolved from, given the RPC context of the request
36+
/// (what <see cref="JsonRpcContext.Current"/> returns). Without one, the host handles an <see cref="HttpContext"/>
37+
/// (its <c>RequestServices</c>) and a raw <see cref="ConnectionContext"/> (the scope the connection handler opens
38+
/// per document, published as <see cref="IServiceProvidersFeature"/>). Required when <see cref="ContextFactory"/>
39+
/// produces anything else and a non-singleton service is registered: the host refuses to start, and
40+
/// <c>MapJsonRpc(pattern, options)</c> refuses to map, otherwise. A selector that returns null falls through to
41+
/// the built-in one; when no provider is found the call fails with <c>-32603</c>. The root provider is never used.
42+
/// </summary>
43+
public Func<object, IServiceProvider> ServiceProviderSelector { get; set; }
44+
45+
/// <summary>The built-in selection: the HTTP request's services, or the per-document scope of a raw connection.</summary>
46+
internal static IServiceProvider DefaultServiceProviderSelector(object context)
47+
{
48+
if (context is HttpContext http) return http.RequestServices;
49+
if (context is ConnectionContext connection) return connection.Features.Get<IServiceProvidersFeature>()?.RequestServices;
50+
return null;
51+
}
52+
3153
/// <summary>Largest request body accepted, in bytes. Larger bodies get 413. Default 4 MB.</summary>
3254
public long MaxRequestBytes { get; set; } = 4 * 1024 * 1024;
3355

0 commit comments

Comments
 (0)