Skip to content

Commit 6321b54

Browse files
committed
2.0: byte-first core, pluggable serializers, Kestrel host (finishes the .NET Standard upgrade from #90)
Finishes the .NET Standard upgrade from #90 (issue #89) on top of master and carries it through to a 2.0 release: a serializer-neutral, byte-first core with three serializer packages and a Kestrel host. Targets and packaging: core, Newtonsoft and SystemTextJson packages target netstandard2.0, netstandard2.1, net8.0 and net10.0; AspNetCore targets net8.0 and net10.0; netcoreapp3.1 dropped; SDK pinned by global.json. CI builds the solution, runs the tests on both frameworks and packs all four packages (2.0.0). Core: byte-first pipeline (JsonRpcProcessor.Process over ReadOnlySequence/Memory/Span<byte> into an IBufferWriter<byte>; the string overloads transcode into it). Pluggable JsonRpcSerializer: the built-in dependency-free serializer, AustinHarris.JsonRpc.Newtonsoft and AustinHarris.JsonRpc.SystemTextJson, resolved per call, per session, then global, with one wire format fixed by the core. Parser hardening (depth limit, strict grammar, UTF-8 validation, escaped member names, stable id echo) and dispatch hardening from an independent review (notifications never answered, batches always arrays, pre-hook re-dispatch, per-invocation context, hook error boundary, named-parameter validation, async void rejected, exception details redacted by default). Config.VersionPolicy for the jsonrpc member. Open issues folded in: the request id is readable inside a method (Handler.RpcRequestId / JsonRpcContext.CurrentRequestId, RpcRequestIdKind, RpcRequestIdRaw) from the invocation frame on demand, at no cost to methods that do not ask (#56); a parameter value the serializer cannot convert is -32602 with structured data naming the parameter instead of -32603 (#123); -32601 names the requested method in its data and reaches the error handler on both paths (#145); ServiceBinder.BindMethod registers any delegate without attributes (#6). Asynchronous invocation (#147): JsonRpcProcessor.ProcessAsync (byte and string overloads) awaits Task, Task<T>, ValueTask and ValueTask<T> methods with typed result writing, sequential ordered batches and cooperative cancellation ([JsonRpcCancellation] receives the processor token). Completed operations run inline and the byte overloads return Task.CompletedTask when the document completes inline. The synchronous Process path is unchanged; it refuses an asynchronous registration with -32603 without invoking it, and async void stays rejected at registration. RpcContextFlow.Flow (default) carries the ambient context, the request id and RpcSetException across awaits; RpcContextFlow.None opts a method out for zero allocation on the completed path. Kestrel gets JsonRpcOptions.EnableAsyncMethods for HTTP and ordered raw connections. Interface binding (#130): ServiceBinder.BindInterface<TInterface>(sessionId, implementation, options) registers an interface contract, including a tree of interface-typed properties, atomically and returns an RpcBinding that unbinds it on Dispose. Names come from the contract ([JsonRpcMethod] aliases, [JsonRpcParam], optional defaults) with Prefix, Separator, camel casing, an Include filter and a NameRule override; implementation-only methods stay private. The tree is flattened and compiled at registration through GetInterfaceMap with the receiver typed as the implementation, so interface-bound methods dispatch at the same cost as class-bound ones. Task and ValueTask members are asynchronous registrations. Hosting: AustinHarris.JsonRpc.AspNetCore with MapJsonRpc on PipeReader/BodyWriter and JsonRpcConnectionHandler for raw TCP, Unix-socket and named-pipe connections; samples/WasmHost runs the server inside the browser and benchmarks JSON-RPC against plain Blazor interop, interpreted and AOT. Benchmarks: TestServer_Console gains sync, Task, Kestrel, compare (StreamJsonRpc and gRPC for .NET on the same Kestrel) and sweep (every library and transport by connection count) modes. Charts are rendered in light and dark from benchmarks/charts/benchmarks.json, the one data file behind the README tables, the SVGs and the interactive explorer page (deployed to GitHub Pages); render.py --check keeps them in agreement in CI. simdjson evaluated and rejected. benchmarks/Micro holds BenchmarkDotNet per-request timings with allocation columns for class-bound, interface-bound and asynchronous dispatch. Docs: README rewritten (setup, hosting modes, configuration, benchmarks, upgrading from 1.x), docs/serializers.md, the reviews and their fix passes in docs/reviews/. Closes #89. Supersedes #90. Closes #56, #123, #145, #147, #130, #6. Closes #117, #68, #140, #131, #125, #10, #9 (already covered by the 2.0 core; verified against this branch).
1 parent 82d23f1 commit 6321b54

127 files changed

Lines changed: 23840 additions & 915 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/build_publish_master.yml‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,29 @@ on:
88
jobs:
99
build:
1010

11-
strategy:
12-
matrix:
13-
os: ['ubuntu-latest']
14-
dotnet-version: ['3.1.201']
15-
project : ['Json-Rpc']
16-
17-
runs-on: ${{ matrix.os }}
11+
runs-on: ubuntu-latest
1812

1913
steps:
20-
- uses: actions/checkout@v2
21-
- name: Setup .NET Core
22-
uses: actions/setup-dotnet@v1
14+
- uses: actions/checkout@v7
15+
- name: Setup .NET
16+
uses: actions/setup-dotnet@v6
2317
with:
24-
dotnet-version: ${{matrix.dotnet-version}}
18+
# global.json pins the SDK; the 8.0 runtime is needed for the net8.0 test target.
19+
global-json-file: global.json
20+
dotnet-version: |
21+
8.0.x
22+
10.0.x
2523
- name: Install dependencies
26-
run: dotnet restore
24+
run: dotnet restore AustinHarris.JsonRpc.sln
25+
# Building the solution packs every package project (GeneratePackageOnBuild); `dotnet pack` on the
26+
# solution would trip NU5026 with GeneratePackageOnBuild, so the packages come from the build.
2727
- name: Build
28-
run: dotnet build ${{matrix.project}} --configuration Release
28+
run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore
2929
- name: Test
30-
run: dotnet test AustinHarris.JsonRpcTestN
31-
# Publish
30+
run: dotnet test AustinHarris.JsonRpcTestN --configuration Release --no-build
31+
# Publish all four packages: the core and the three companions (Json.NET, System.Text.Json, ASP.NET Core).
3232
- name: publish nuget version change
33-
run: dotnet nuget push Json-Rpc/bin/Release/*.nupkg --skip-duplicate --source "https://www.nuget.org" --api-key ${{secrets.NugetKey}} # API key for the NuGet feed
33+
run: |
34+
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
36+
done
Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Pull Reqest
1+
name: Pull Request
22
on:
33
pull_request:
44
paths-ignore:
@@ -11,25 +11,34 @@ jobs:
1111
strategy:
1212
matrix:
1313
os: ['windows-latest','ubuntu-latest']
14-
dotnet-version: ['3.1.201']
15-
project : ['Json-Rpc']
1614

1715
runs-on: ${{ matrix.os }}
1816

1917
steps:
20-
- uses: actions/checkout@v2
21-
- name: Setup .NET Core
22-
uses: actions/setup-dotnet@v1
18+
- uses: actions/checkout@v7
19+
- name: Setup .NET
20+
uses: actions/setup-dotnet@v6
2321
with:
24-
dotnet-version: ${{matrix.dotnet-version}}
22+
# global.json pins the SDK; the 8.0 runtime is needed for the net8.0 test target.
23+
global-json-file: global.json
24+
dotnet-version: |
25+
8.0.x
26+
10.0.x
2527
- name: Install dependencies
26-
run: dotnet restore
28+
run: dotnet restore AustinHarris.JsonRpc.sln
29+
# Building the solution packs every package project (GeneratePackageOnBuild); `dotnet pack` on the
30+
# solution would trip NU5026 with GeneratePackageOnBuild, so the packages come from the build.
2731
- name: Build
28-
run: dotnet build ${{matrix.project}} --configuration Release --version-suffix ci-${{ github.run_id }}-${{ github.run_number }}
32+
run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore --version-suffix ci-${{ github.run_id }}-${{ github.run_number }}
2933
- name: Test
30-
run: dotnet test AustinHarris.JsonRpcTestN
31-
# Publish
34+
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.
3238
- name: publish nuget version change
33-
if: ${{matrix.os == 'ubuntu-latest'}}
34-
run: dotnet nuget push Json-Rpc/bin/Release/*.nupkg --skip-duplicate --source "https://www.nuget.org" --api-key ${{secrets.NugetKey}} # API key for the NuGet feed
35-
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

‎.github/workflows/charts.yml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Benchmark charts
2+
# The committed charts, the explorer page and the README figures must agree with benchmarks/charts/benchmarks.json.
3+
on:
4+
pull_request:
5+
paths:
6+
- "benchmarks/charts/**"
7+
- "README.md"
8+
- "samples/WasmHost/README.md"
9+
push:
10+
branches: [ master ]
11+
paths:
12+
- "benchmarks/charts/**"
13+
- "README.md"
14+
- "samples/WasmHost/README.md"
15+
16+
jobs:
17+
check:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v7
21+
- name: Renderer tests
22+
run: python3 -m unittest benchmarks/charts/test_render.py
23+
- name: Committed outputs and README figures match the data
24+
run: python3 benchmarks/charts/render.py --check

‎.github/workflows/pages.yml‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Benchmark explorer (GitHub Pages)
2+
on:
3+
push:
4+
branches: [ master ]
5+
paths:
6+
- "benchmarks/charts/**"
7+
- ".github/workflows/pages.yml"
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
pages: write
13+
id-token: write
14+
15+
concurrency:
16+
group: pages
17+
cancel-in-progress: true
18+
19+
jobs:
20+
deploy:
21+
runs-on: ubuntu-latest
22+
environment:
23+
name: github-pages
24+
url: ${{ steps.deployment.outputs.page_url }}
25+
steps:
26+
- uses: actions/checkout@v7
27+
# The committed outputs must match the data: a stale chart or explorer fails the deploy.
28+
- name: Check the committed charts against the data
29+
run: python3 benchmarks/charts/render.py --check
30+
- name: Stage the site
31+
run: |
32+
mkdir -p site
33+
cp benchmarks/charts/explorer.html site/index.html
34+
cp benchmarks/charts/*.svg benchmarks/charts/*.json site/
35+
- uses: actions/configure-pages@v6
36+
- uses: actions/upload-pages-artifact@v5
37+
with:
38+
path: site
39+
- id: deployment
40+
uses: actions/deploy-pages@v5

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,9 @@ Backup*/
108108
UpgradeLog*.XML
109109
.nuget/NuGet.exe
110110
.vs/
111+
112+
# JetBrains Rider / IntelliJ
113+
.idea/
114+
115+
# Python renderer caches
116+
__pycache__/
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<Company>Austin Harris</Company>
5+
<Authors>Austin Harris</Authors>
6+
<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>
8+
<VersionPrefix>2.0.0</VersionPrefix>
9+
<VersionSuffix>$(VersionSuffix)</VersionSuffix>
10+
<Copyright>Austin Harris</Copyright>
11+
<PackageProjectUrl>https://github.com/Astn/JSON-RPC.NET</PackageProjectUrl>
12+
<RepositoryUrl>https://github.com/Astn/JSON-RPC.NET</RepositoryUrl>
13+
<RepositoryType>git</RepositoryType>
14+
<PackageLicenseExpression>MIT</PackageLicenseExpression>
15+
<PackageReadmeFile>README.md</PackageReadmeFile>
16+
<PackageTags>json-rpc;jsonrpc;json;rpc;server;aspnetcore;kestrel;pipelines</PackageTags>
17+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
18+
<LangVersion>latest</LangVersion>
19+
<Nullable>disable</Nullable>
20+
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
21+
<RootNamespace>AustinHarris.JsonRpc.AspNetCore</RootNamespace>
22+
</PropertyGroup>
23+
24+
<ItemGroup>
25+
<None Include="README.md" Pack="true" PackagePath="\" />
26+
</ItemGroup>
27+
28+
<ItemGroup>
29+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
30+
</ItemGroup>
31+
32+
<ItemGroup>
33+
<ProjectReference Include="..\Json-Rpc\AustinHarris.JsonRpc.csproj" />
34+
</ItemGroup>
35+
36+
</Project>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using AustinHarris.JsonRpc.Serialization;
4+
using Microsoft.AspNetCore.Connections;
5+
6+
namespace AustinHarris.JsonRpc.AspNetCore
7+
{
8+
public partial class JsonRpcConnectionHandler
9+
{
10+
private async Task RunAsynchronousMethodsAsync(ConnectionContext connection)
11+
{
12+
var input = connection.Transport.Input;
13+
var output = connection.Transport.Output;
14+
var token = connection.ConnectionClosed;
15+
string session = _options.SessionId ?? Handler.DefaultSessionId();
16+
// Keep result commits separate from transport flushes: a method can finish on another
17+
// thread while an earlier reply is waiting for transport backpressure.
18+
using var reply = new PooledByteBufferWriter();
19+
try
20+
{
21+
while (true)
22+
{
23+
var result = await input.ReadAsync(token).ConfigureAwait(false);
24+
var buffer = result.Buffer;
25+
bool wrote = false;
26+
try
27+
{
28+
while (JsonFramer.TryReadDocument(ref buffer, out var document))
29+
{
30+
token.ThrowIfCancellationRequested();
31+
if (document.Length <= 1 && document.First.Span[0] != (byte)'{' && document.First.Span[0] != (byte)'[') continue;
32+
if (document.Length > _options.MaxRequestBytes)
33+
{
34+
connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
35+
return;
36+
}
37+
reply.Clear();
38+
var pending = JsonRpcProcessor.ProcessAsync(session, document, reply, connection, _options.Serializer, token);
39+
if (!pending.IsCompleted && wrote)
40+
{
41+
bool closed = false;
42+
try
43+
{
44+
var flush = await output.FlushAsync(token).ConfigureAwait(false);
45+
closed = flush.IsCompleted || flush.IsCanceled;
46+
wrote = false;
47+
}
48+
finally
49+
{
50+
// Even a failed flush cannot release the input or reply while invocation runs.
51+
await pending.ConfigureAwait(false);
52+
}
53+
if (closed) return;
54+
}
55+
else await pending.ConfigureAwait(false);
56+
token.ThrowIfCancellationRequested();
57+
if (reply.WrittenCount != 0) { reply.CopyTo(output); wrote = true; }
58+
}
59+
if (wrote)
60+
{
61+
var flush = await output.FlushAsync(token).ConfigureAwait(false);
62+
if (flush.IsCompleted || flush.IsCanceled) return;
63+
}
64+
if (result.IsCompleted || result.IsCanceled) return;
65+
if (buffer.Length > _options.MaxRequestBytes)
66+
{
67+
connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
68+
return;
69+
}
70+
}
71+
finally { input.AdvanceTo(buffer.Start, buffer.End); }
72+
}
73+
}
74+
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
75+
catch (ConnectionAbortedException) { }
76+
}
77+
}
78+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System;
2+
using System.Buffers;
3+
using System.Threading.Tasks;
4+
using AustinHarris.JsonRpc.Serialization;
5+
using Microsoft.AspNetCore.Connections;
6+
using Microsoft.Extensions.Options;
7+
8+
namespace AustinHarris.JsonRpc.AspNetCore
9+
{
10+
/// <summary>
11+
/// JSON-RPC over a raw Kestrel connection (TCP, Unix socket, named pipe): clients send JSON documents back to
12+
/// back (optionally whitespace / newline separated) and receive responses in order. Wire it up with
13+
/// <c>kestrel.ListenLocalhost(port, l => l.UseConnectionHandler&lt;JsonRpcConnectionHandler&gt;())</c>.
14+
/// The connection's <see cref="ConnectionContext"/> is the RPC context for every call.
15+
/// </summary>
16+
public partial class JsonRpcConnectionHandler : ConnectionHandler
17+
{
18+
private readonly JsonRpcOptions _options;
19+
20+
public JsonRpcConnectionHandler(IOptions<JsonRpcOptions> options)
21+
{
22+
_options = options?.Value ?? new JsonRpcOptions();
23+
}
24+
25+
/// <summary>Processes a connection using the hosting mode selected in options.</summary>
26+
public override Task OnConnectedAsync(ConnectionContext connection)
27+
{
28+
return _options.EnableAsyncMethods ? RunAsynchronousMethodsAsync(connection) : RunSynchronousMethodsAsync(connection);
29+
}
30+
31+
private async Task RunSynchronousMethodsAsync(ConnectionContext connection)
32+
{
33+
var input = connection.Transport.Input;
34+
var output = connection.Transport.Output;
35+
string session = _options.SessionId ?? Handler.DefaultSessionId();
36+
37+
while (true)
38+
{
39+
var result = await input.ReadAsync(connection.ConnectionClosed).ConfigureAwait(false);
40+
var buffer = result.Buffer;
41+
bool wrote = false;
42+
43+
while (JsonFramer.TryReadDocument(ref buffer, out var document))
44+
{
45+
// the framer hands back a one-byte slice for anything that is not '{' or '[': drop it
46+
if (document.Length > 1 || document.First.Span[0] == (byte)'{' || document.First.Span[0] == (byte)'[')
47+
{
48+
if (document.Length > _options.MaxRequestBytes)
49+
{
50+
connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
51+
return;
52+
}
53+
JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer);
54+
wrote = true;
55+
}
56+
}
57+
58+
if (wrote)
59+
{
60+
var flush = await output.FlushAsync(connection.ConnectionClosed).ConfigureAwait(false);
61+
if (flush.IsCompleted || flush.IsCanceled) break;
62+
}
63+
64+
if (result.IsCompleted || result.IsCanceled)
65+
{
66+
input.AdvanceTo(buffer.Start, buffer.End);
67+
break;
68+
}
69+
if (buffer.Length > _options.MaxRequestBytes)
70+
{
71+
connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes."));
72+
return;
73+
}
74+
// consumed up to the last complete document, examined everything
75+
input.AdvanceTo(buffer.Start, buffer.End);
76+
}
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)