Skip to content

2.0: byte-first core, pluggable serializers, Kestrel host (finishes the .NET Standard upgrade from #90) - #148

Merged
Astn merged 1 commit into
masterfrom
finish-netstandard-upgrade
Sep 24, 2026
Merged

Astn merged 1 commit into
masterfrom
finish-netstandard-upgrade

Conversation

@Astn

@Astn Astn commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

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.

What changed

Targets and packaging

  • Core, Newtonsoft and SystemTextJson packages target netstandard2.0, netstandard2.1, net8.0, net10.0; the AspNetCore package targets net8.0 and net10.0. netcoreapp3.1 is dropped; SDK pinned by global.json (10.0.x).
  • CI builds the solution, runs the tests on both frameworks and packs all four packages (2.0.0).

Core (2.0)

  • Byte-first pipeline: JsonRpcProcessor.Process(sessionId, ReadOnlySequence/Memory/Span<byte>, IBufferWriter<byte>); the string overloads transcode into it. A small request costs about 230 ns and 0 bytes on one core.
  • Pluggable JsonRpcSerializer: built-in dependency-free serializer (span port of jsmn plus a cached reflection mapper), AustinHarris.JsonRpc.Newtonsoft, AustinHarris.JsonRpc.SystemTextJson. Resolution per call, per session, then global. The wire format (member order, .0 on whole floats, Json.NET-style dates, quoted non-finite floats) is fixed by the core so switching is invisible to clients.
  • Parser hardening: depth limit (MaxDepth, default 64, virtual per serializer so STJ and Json.NET enforce their own option), strict grammar and UTF-8 validation, escaped member names, stable id echo.
  • Dispatch hardening from an independent review: notifications never get a wire response, batches always answer with an array, pre-hook mutations are re-dispatched, per-invocation context state, hook error boundary, SMDServiceCollection, named-parameter validation, async void rejected at registration, exception details redacted by default (Config.IncludeExceptionDetails).
  • Config.VersionPolicy for the jsonrpc member: Lenient (default: missing accepted, present must be "2.0"), Ignore, Strict; per-session override.

Open issues folded in

  • Allow JsonRpc.id to be available #56: the request id is readable inside a method: Handler.RpcRequestId() / JsonRpcContext.CurrentRequestId() (an owned JsonRpcRequestId snapshot: kind, Int64, decoded string, or the digits of a wider integer), Handler.RpcRequestIdKind() and Handler.RpcRequestIdRaw() (the id's JSON as sent). The invocation frame keeps a reference to the request reader, so nothing is decoded or allocated unless a method asks; the numeric request stays at 0 bytes whether or not it reads the id. A parameter named id is unaffected. A pre-process hook that replaces the id changes what the method sees; a replacement that is not a valid id is -32600.
  • Params conversion error raises internal error #123: a parameter value the serializer cannot convert is -32602 with data = {"reason":"conversion","parameter":…,"index":…,"expectedType":…} (plus message with IncludeExceptionDetails), was -32603 with the exception. The invokers are unchanged (no try block, no bookkeeping on the happy path): when an invocation throws, the dispatcher re-reads the arguments and names the first one the serializer refuses; the same exception thrown inside the method stays -32603. Recognised: JsonRpcBindException, FormatException, OverflowException, InvalidCastException and the JsonException families of System.Text.Json and Json.NET; a type the serializer cannot handle stays -32603.
  • Improve error message when method not found #145: -32601's data is {"method":"<name as requested>"} and the error handler sees a MethodNotFoundInfo on both dispatch paths (notifications included; the wire still gets nothing).
  • Usage of await operator in the JsonRpcMethod. #147: real asynchronous invocation. 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] CancellationToken receives the processor token). Completed operations run inline; the byte overloads return Task.CompletedTask when the whole document completes inline. The synchronous Process/ProcessSync path is untouched: it refuses an asynchronous registration with -32603 without invoking it, and async void stays rejected at registration. RpcContextFlow.None (default) keeps the ambient context for the synchronous part of the method and costs nothing extra on the completed path; RpcContextFlow.Flow opts a method in to Handler.RpcContext(), the request id and RpcSetException across awaits, at a per-invocation allocation. Kestrel gets JsonRpcOptions.EnableAsyncMethods (default off) for HTTP and ordered raw-connection processing. README section "Asynchronous methods".
  • Recursive binding of interfaces, custom method naming rules #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 (GetInterfaceMap, 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.
  • Should be able to register methods using expressions #6: ServiceBinder.BindMethod(sessionId, name, delegate, parameterNames?, defaults?) registers any delegate without attributes or a service class (lambdas keep their parameter names; closed delegates get arg1, arg2 unless named); duplicate names are refused, UnbindMethod removes one. The "serialize an expression and register it remotely" half of the issue stays out.
  • Verified already covered by the 2.0 core: Add support for System.Text.Json #117, Add the ability to configure serializer/deserialzer settings #68, Single response when sending batch request. #140, faulty answer string with batches that contain requests with and without id's #131, Suggestions: make it more agnostic, get rid of inheritance #125, Add Tests for Context #10, Add tests for Exceptions #9.

Hosting

  • AustinHarris.JsonRpc.AspNetCore: MapJsonRpc("/rpc") on PipeReader/BodyWriter, JsonRpcConnectionHandler for raw TCP/Unix-socket/named-pipe connections, DI registration of services, per-request session selection.
  • samples/WasmHost: the server running inside the browser as Blazor WebAssembly, with a page that benchmarks JSON-RPC against plain Blazor interop ([JSInvokable], [JSExport], UTF-8 buffers).

Benchmarks (TestServer_Console: sync sweep, Task batches, Kestrel HTTP/TCP, compare, connection sweep; 8-core 7800X3D, .NET 10)

  • Library alone: 4.5 to 4.6 M RPC/s on 1 thread, 30.6 to 35.8 M on 16.

  • Kestrel: 15.2 to 15.5 M RPC/s over pipelined TCP, 12.7 to 13.7 M for HTTP batches of 100.

  • Versus StreamJsonRpc and gRPC for .NET on the same Kestrel (--compare), and every library and transport by connection count (--sweep).

  • Charts in light and dark, the README tables and an interactive explorer page (GitHub Pages) all come from benchmarks/charts/benchmarks.json; render.py --check keeps them in agreement in CI.

  • simdjson evaluated and rejected (benchmarks/SimdJsonEval/RESULTS.md).

  • benchmarks/Micro: BenchmarkDotNet per-request timings with allocation columns for class-bound, interface-bound and asynchronous dispatch. Full job, MemoryDiagnoser, one core, .NET 10, run in an exclusive window on a loaded workstation (absolute numbers are inflated by other load; ratios within a run are the signal).

    Synchronous dispatch, before (the previous commit of this PR) and after this merge, same load:

    Shape Before After Allocated
    add (double) 255.6 ns ± 18.8 241.7 ns ± 15.6 0 B
    addInt 174.3 ns ± 8.8 185.4 ns ± 9.9 0 B
    nullable float 239.6 ns ± 11.6 250.3 ns ± 16.1 0 B
    decimal 193.4 ns ± 8.9 207.7 ns ± 11.9 0 B
    string 186.3 ns ± 18.6 176.7 ns ± 10.9 32 B
    batch of 5 1,060 ns ± 80 1,039 ns ± 63 32 B
    notification 164.8 ns ± 12.4 148.1 ns ± 8.5 0 B

    Interface binding versus class binding, same method, one process (BindingComparisonBenchmarks):

    Shape [JsonRpcMethod] class BindInterface Allocated
    addInt 186.6 ns ± 16.9 173.7 ns ± 9.2 0 B
    decimal 201.7 ns ± 15.9 223.0 ns ± 28.6 0 B
    string 187.4 ns ± 20.4 178.8 ns ± 14.3 32 B

    Tree names cost only their length: Calc.addInt 183.5 ns, Admin.Calc.addInt 201.4 ns against 177.5 ns for addInt in the same run, all 0 B.

    Asynchronous dispatch (AsyncDispatchBenchmarks, addInt shape):

    Row Mean Allocated
    sync method, Process 227.3 ns ± 13.3 0 B
    sync method, ProcessAsync 284.6 ns ± 20.3 0 B
    Task<int> completed, default (None) 228.4 ns ± 17.0 0 B
    Task<int> completed, Flow 303.5 ns ± 43.6 184 B
    ValueTask<int> completed, default (None) 269.2 ns ± 27.1 0 B
    ValueTask<int> completed, Flow 341.2 ns ± 57.0 184 B
    one Task.Yield, default (None) 1,377 ns ± 42 710 B
    one Task.Yield, Flow 1,465 ns ± 80 887 B

    A completed Task under the default costs the same as the synchronous path. Opting into RpcContextFlow.Flow pays 184 B and about 75 ns per invocation for the AsyncLocal bridge that keeps Handler.RpcContext() and the request id readable after an await.

Docs: README rewritten (setup, hosting modes, configuration, benchmarks, upgrading from 1.x), docs/serializers.md, the review and its fix pass in docs/reviews/.

Tests

1,140 tests pass on net8.0 and net10.0: the protocol suite once per serializer, plus parser, dispatch, request-id, error-data, delegate-binding, interface-binding, asynchronous-invocation, version-policy, serializer and Kestrel integration tests. The request-id suite includes an allocation test (undivided total over 2000 requests) for the integer, null, absent and untouched-string cases.

Breaking changes from 1.x

Listed under "Upgrading from 1.x" in the README. The main ones: the core no longer references Json.NET (JsonSerializerSettings overloads moved to the Newtonsoft package), notifications never answered, batches always arrays, exception details redacted by default, SMD.Services is a collection type, async void rejected and Task-returning methods require ProcessAsync, conversion failures are -32602 with structured data, -32601 data is an object.

Closes #89. Supersedes #90.
Closes #56, #123, #145, #147, #130, #6.
Closes #117, #68, #140, #131, #125, #10, #9.

@Astn Astn mentioned this pull request Sep 23, 2026
7 tasks
@Astn
Astn force-pushed the finish-netstandard-upgrade branch 4 times, most recently from 14dd097 to 6321b54 Compare September 24, 2026 05:29
…he .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.None (default) keeps the ambient context for the
synchronous part of the method and costs nothing extra on the completed path; RpcContextFlow.Flow opts a
method in to the context, the request id and RpcSetException across awaits, at a per-invocation allocation. 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for System.Text.Json dotnet standard library Allow JsonRpc.id to be available

1 participant