Skip to content

Commit 3fa5487

Browse files
committed
Merge master (pull 155) into async-scratch-cache
Conflicts: the DI paragraphs in README.md and the AspNetCore README keep the lifetimes text from master; the pull-request workflow keeps master's note that pull requests no longer publish and this branch's allowlist and scaling jobs.
2 parents 1b7ffc2 + 805c64f commit 3fa5487

40 files changed

Lines changed: 1474 additions & 927 deletions

‎.github/workflows/build_publish_master.yml‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ on:
55
- "**/*.md"
66
branches: [ master ]
77

8+
# Publishing uses nuget.org Trusted Publishing: the job requests an OIDC token from GitHub, NuGet/login exchanges
9+
# it for an API key that lives one hour, and the push uses that key. No long-lived NuGet secret is stored here.
10+
# The nuget.org policy is bound to this repository and to this file's name (build_publish_master.yml); renaming
11+
# the file or moving the push to another workflow needs a new policy on nuget.org.
12+
permissions:
13+
id-token: write
14+
contents: read
15+
816
jobs:
917
build:
1018

@@ -24,13 +32,22 @@ jobs:
2432
run: dotnet restore AustinHarris.JsonRpc.sln
2533
# Building the solution packs every package project (GeneratePackageOnBuild); `dotnet pack` on the
2634
# solution would trip NU5026 with GeneratePackageOnBuild, so the packages come from the build.
35+
# ContinuousIntegrationBuild makes the build deterministic and lets Source Link map the symbols package
36+
# (.snupkg, pushed alongside each .nupkg) back to this commit on GitHub.
2737
- name: Build
28-
run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore
38+
run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore -p:ContinuousIntegrationBuild=true
2939
- name: Test
3040
run: dotnet test AustinHarris.JsonRpcTestN --configuration Release --no-build
41+
# The key is requested after the tests so it is fresh for the push (it expires after one hour). `user` is the
42+
# nuget.org profile that owns the packages and the trusted publishing policy.
43+
- name: NuGet login (OIDC to a temporary API key)
44+
uses: NuGet/login@v1
45+
id: nuget-login
46+
with:
47+
user: AustinHarris
3148
# Publish all four packages: the core and the three companions (Json.NET, System.Text.Json, ASP.NET Core).
3249
- name: publish nuget version change
3350
run: |
3451
for project in Json-Rpc AustinHarris.JsonRpc.Newtonsoft AustinHarris.JsonRpc.SystemTextJson AustinHarris.JsonRpc.AspNetCore; do
35-
dotnet nuget push "$project/bin/Release/"*.nupkg --skip-duplicate --source "https://api.nuget.org/v3/index.json" --api-key ${{ secrets.NugetKey }} # API key for the NuGet feed
52+
dotnet nuget push "$project/bin/Release/"*.nupkg --skip-duplicate --source "https://api.nuget.org/v3/index.json" --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}"
3653
done

‎.github/workflows/build_pull_request.yml‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,11 @@ jobs:
2929
# Building the solution packs every package project (GeneratePackageOnBuild); `dotnet pack` on the
3030
# solution would trip NU5026 with GeneratePackageOnBuild, so the packages come from the build.
3131
- name: Build
32-
run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore --version-suffix ci-${{ github.run_id }}-${{ github.run_number }}
32+
run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore
3333
- name: Test
3434
run: dotnet test AustinHarris.JsonRpcTestN --configuration Release --no-build
35-
# Publish all four pre-release packages: the core and the three companions (Json.NET, System.Text.Json, ASP.NET Core).
36-
# Non-fatal: the build and tests are the pull-request verdict; a rejected key (403) or a fork's missing secret
37-
# shows as a warning here and fails loudly in the master workflow instead.
38-
- name: publish nuget version change
39-
if: ${{ matrix.os == 'ubuntu-latest' }}
40-
continue-on-error: true
41-
run: |
42-
for project in Json-Rpc AustinHarris.JsonRpc.Newtonsoft AustinHarris.JsonRpc.SystemTextJson AustinHarris.JsonRpc.AspNetCore; do
43-
dotnet nuget push "$project/bin/Release/"*.nupkg --skip-duplicate --source "https://api.nuget.org/v3/index.json" --api-key ${{ secrets.NugetKey }} # API key for the NuGet feed
44-
done
35+
# Pull requests do not publish. Packages go out from the master workflow through Trusted Publishing, whose
36+
# nuget.org policy is bound to build_publish_master.yml; nothing else can push.
4537

4638
# Every lock and Interlocked on the request-path files must be listed with a reason (a process-wide lock on the
4739
# per-document path once capped ProcessAsync at 4 M RPC/s on every core count). A review aid, not the measurement.

‎AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,34 @@
44
<Company>Austin Harris</Company>
55
<Authors>Austin Harris</Authors>
66
<Product>Json-Rpc.Net ASP.NET Core host</Product>
7-
<Description>ASP.NET Core / Kestrel hosting for JSON-RPC.Net: MapJsonRpc endpoint (PipeReader in, BodyWriter out, no strings), a raw Kestrel ConnectionHandler for JSON-RPC over TCP, and DI registration of services.</Description>
7+
<Description>ASP.NET Core hosting for AustinHarris.JsonRpc. Adds HTTP endpoints and raw Kestrel connections over TCP, Unix sockets and named pipes, with dependency injection.</Description>
88
<VersionPrefix>2.0.0</VersionPrefix>
9-
<PackageReleaseNotes>https://github.com/Astn/JSON-RPC.NET/blob/master/CHANGELOG.md</PackageReleaseNotes>
10-
<VersionSuffix>$(VersionSuffix)</VersionSuffix>
9+
<VersionSuffix>preview.1</VersionSuffix>
10+
<PackageReleaseNotes>2.0.0-preview.1, released with the core package. What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html</PackageReleaseNotes>
1111
<Copyright>Austin Harris</Copyright>
12-
<PackageProjectUrl>https://github.com/Astn/JSON-RPC.NET</PackageProjectUrl>
12+
<PackageProjectUrl>https://astn.github.io/JSON-RPC.NET/aspnetcore.html</PackageProjectUrl>
1313
<RepositoryUrl>https://github.com/Astn/JSON-RPC.NET</RepositoryUrl>
1414
<RepositoryType>git</RepositoryType>
1515
<PackageLicenseExpression>MIT</PackageLicenseExpression>
1616
<PackageReadmeFile>README.md</PackageReadmeFile>
17-
<PackageTags>json-rpc;jsonrpc;json;rpc;server;aspnetcore;kestrel;pipelines</PackageTags>
17+
<PackageTags>json-rpc;jsonrpc;json;rpc;server;aspnetcore;asp.net-core;kestrel;pipelines;tcp;net8.0;net10.0</PackageTags>
1818
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
1919
<LangVersion>latest</LangVersion>
2020
<Nullable>disable</Nullable>
21+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
22+
<NoWarn>$(NoWarn);CS1591</NoWarn>
23+
<IncludeSymbols>true</IncludeSymbols>
24+
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
25+
<PublishRepositoryUrl>true</PublishRepositoryUrl>
26+
<EmbedUntrackedSources>true</EmbedUntrackedSources>
27+
<PackageIcon>icon.png</PackageIcon>
2128
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
2229
<RootNamespace>AustinHarris.JsonRpc.AspNetCore</RootNamespace>
2330
</PropertyGroup>
2431

2532
<ItemGroup>
2633
<None Include="README.md" Pack="true" PackagePath="\" />
34+
<None Include="..\icon.png" Pack="true" PackagePath="\" />
2735
</ItemGroup>
2836

2937
<ItemGroup>

‎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)