diff --git a/.github/workflows/build_publish_master.yml b/.github/workflows/build_publish_master.yml index 1a7fe10..5c6cebf 100644 --- a/.github/workflows/build_publish_master.yml +++ b/.github/workflows/build_publish_master.yml @@ -8,26 +8,29 @@ on: jobs: build: - strategy: - matrix: - os: ['ubuntu-latest'] - dotnet-version: ['3.1.201'] - project : ['Json-Rpc'] - - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 with: - dotnet-version: ${{matrix.dotnet-version}} + # global.json pins the SDK; the 8.0 runtime is needed for the net8.0 test target. + global-json-file: global.json + dotnet-version: | + 8.0.x + 10.0.x - name: Install dependencies - run: dotnet restore + run: dotnet restore AustinHarris.JsonRpc.sln + # Building the solution packs every package project (GeneratePackageOnBuild); `dotnet pack` on the + # solution would trip NU5026 with GeneratePackageOnBuild, so the packages come from the build. - name: Build - run: dotnet build ${{matrix.project}} --configuration Release + run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore - name: Test - run: dotnet test AustinHarris.JsonRpcTestN - # Publish + run: dotnet test AustinHarris.JsonRpcTestN --configuration Release --no-build + # Publish all four packages: the core and the three companions (Json.NET, System.Text.Json, ASP.NET Core). - name: publish nuget version change - 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 + run: | + for project in Json-Rpc AustinHarris.JsonRpc.Newtonsoft AustinHarris.JsonRpc.SystemTextJson AustinHarris.JsonRpc.AspNetCore; do + 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 + done diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index f3e06e9..e22a198 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -1,4 +1,4 @@ -name: Pull Reqest +name: Pull Request on: pull_request: paths-ignore: @@ -11,25 +11,34 @@ jobs: strategy: matrix: os: ['windows-latest','ubuntu-latest'] - dotnet-version: ['3.1.201'] - project : ['Json-Rpc'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 with: - dotnet-version: ${{matrix.dotnet-version}} + # global.json pins the SDK; the 8.0 runtime is needed for the net8.0 test target. + global-json-file: global.json + dotnet-version: | + 8.0.x + 10.0.x - name: Install dependencies - run: dotnet restore + run: dotnet restore AustinHarris.JsonRpc.sln + # Building the solution packs every package project (GeneratePackageOnBuild); `dotnet pack` on the + # solution would trip NU5026 with GeneratePackageOnBuild, so the packages come from the build. - name: Build - run: dotnet build ${{matrix.project}} --configuration Release --version-suffix ci-${{ github.run_id }}-${{ github.run_number }} + run: dotnet build AustinHarris.JsonRpc.sln --configuration Release --no-restore --version-suffix ci-${{ github.run_id }}-${{ github.run_number }} - name: Test - run: dotnet test AustinHarris.JsonRpcTestN - # Publish + run: dotnet test AustinHarris.JsonRpcTestN --configuration Release --no-build + # Publish all four pre-release packages: the core and the three companions (Json.NET, System.Text.Json, ASP.NET Core). + # Non-fatal: the build and tests are the pull-request verdict; a rejected key (403) or a fork's missing secret + # shows as a warning here and fails loudly in the master workflow instead. - name: publish nuget version change - if: ${{matrix.os == 'ubuntu-latest'}} - 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 - \ No newline at end of file + if: ${{ matrix.os == 'ubuntu-latest' }} + continue-on-error: true + run: | + for project in Json-Rpc AustinHarris.JsonRpc.Newtonsoft AustinHarris.JsonRpc.SystemTextJson AustinHarris.JsonRpc.AspNetCore; do + 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 + done diff --git a/.github/workflows/charts.yml b/.github/workflows/charts.yml new file mode 100644 index 0000000..34db1b1 --- /dev/null +++ b/.github/workflows/charts.yml @@ -0,0 +1,24 @@ +name: Benchmark charts +# The committed charts, the explorer page and the README figures must agree with benchmarks/charts/benchmarks.json. +on: + pull_request: + paths: + - "benchmarks/charts/**" + - "README.md" + - "samples/WasmHost/README.md" + push: + branches: [ master ] + paths: + - "benchmarks/charts/**" + - "README.md" + - "samples/WasmHost/README.md" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Renderer tests + run: python3 -m unittest benchmarks/charts/test_render.py + - name: Committed outputs and README figures match the data + run: python3 benchmarks/charts/render.py --check diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..6562457 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,40 @@ +name: Benchmark explorer (GitHub Pages) +on: + push: + branches: [ master ] + paths: + - "benchmarks/charts/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + # The committed outputs must match the data: a stale chart or explorer fails the deploy. + - name: Check the committed charts against the data + run: python3 benchmarks/charts/render.py --check + - name: Stage the site + run: | + mkdir -p site + cp benchmarks/charts/explorer.html site/index.html + cp benchmarks/charts/*.svg benchmarks/charts/*.json site/ + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: site + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 6ea46be..e8a3a67 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,9 @@ Backup*/ UpgradeLog*.XML .nuget/NuGet.exe .vs/ + +# JetBrains Rider / IntelliJ +.idea/ + +# Python renderer caches +__pycache__/ diff --git a/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj b/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj new file mode 100644 index 0000000..ccbffb3 --- /dev/null +++ b/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj @@ -0,0 +1,36 @@ + + + + Austin Harris + Austin Harris + Json-Rpc.Net ASP.NET Core host + 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. + 2.0.0 + $(VersionSuffix) + Austin Harris + https://github.com/Astn/JSON-RPC.NET + https://github.com/Astn/JSON-RPC.NET + git + MIT + README.md + json-rpc;jsonrpc;json;rpc;server;aspnetcore;kestrel;pipelines + net8.0;net10.0 + latest + disable + true + AustinHarris.JsonRpc.AspNetCore + + + + + + + + + + + + + + + diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs new file mode 100644 index 0000000..21c923f --- /dev/null +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.Async.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using AustinHarris.JsonRpc.Serialization; +using Microsoft.AspNetCore.Connections; + +namespace AustinHarris.JsonRpc.AspNetCore +{ + public partial class JsonRpcConnectionHandler + { + private async Task RunAsynchronousMethodsAsync(ConnectionContext connection) + { + var input = connection.Transport.Input; + var output = connection.Transport.Output; + var token = connection.ConnectionClosed; + string session = _options.SessionId ?? Handler.DefaultSessionId(); + // Keep result commits separate from transport flushes: a method can finish on another + // thread while an earlier reply is waiting for transport backpressure. + using var reply = new PooledByteBufferWriter(); + try + { + while (true) + { + var result = await input.ReadAsync(token).ConfigureAwait(false); + var buffer = result.Buffer; + bool wrote = false; + try + { + while (JsonFramer.TryReadDocument(ref buffer, out var document)) + { + token.ThrowIfCancellationRequested(); + if (document.Length <= 1 && document.First.Span[0] != (byte)'{' && document.First.Span[0] != (byte)'[') continue; + if (document.Length > _options.MaxRequestBytes) + { + connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes.")); + return; + } + reply.Clear(); + var pending = JsonRpcProcessor.ProcessAsync(session, document, reply, connection, _options.Serializer, token); + if (!pending.IsCompleted && wrote) + { + 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; + } + else await pending.ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + if (reply.WrittenCount != 0) { reply.CopyTo(output); wrote = true; } + } + if (wrote) + { + var flush = await output.FlushAsync(token).ConfigureAwait(false); + if (flush.IsCompleted || flush.IsCanceled) return; + } + if (result.IsCompleted || result.IsCanceled) return; + if (buffer.Length > _options.MaxRequestBytes) + { + connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes.")); + return; + } + } + finally { input.AdvanceTo(buffer.Start, buffer.End); } + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (ConnectionAbortedException) { } + } + } +} diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs new file mode 100644 index 0000000..d664604 --- /dev/null +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs @@ -0,0 +1,79 @@ +using System; +using System.Buffers; +using System.Threading.Tasks; +using AustinHarris.JsonRpc.Serialization; +using Microsoft.AspNetCore.Connections; +using Microsoft.Extensions.Options; + +namespace AustinHarris.JsonRpc.AspNetCore +{ + /// + /// JSON-RPC over a raw Kestrel connection (TCP, Unix socket, named pipe): clients send JSON documents back to + /// 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. + /// + public partial class JsonRpcConnectionHandler : ConnectionHandler + { + private readonly JsonRpcOptions _options; + + public JsonRpcConnectionHandler(IOptions options) + { + _options = options?.Value ?? new JsonRpcOptions(); + } + + /// Processes a connection using the hosting mode selected in options. + public override Task OnConnectedAsync(ConnectionContext connection) + { + return _options.EnableAsyncMethods ? RunAsynchronousMethodsAsync(connection) : RunSynchronousMethodsAsync(connection); + } + + private async Task RunSynchronousMethodsAsync(ConnectionContext connection) + { + var input = connection.Transport.Input; + var output = connection.Transport.Output; + string session = _options.SessionId ?? Handler.DefaultSessionId(); + + while (true) + { + var result = await input.ReadAsync(connection.ConnectionClosed).ConfigureAwait(false); + var buffer = result.Buffer; + bool wrote = false; + + while (JsonFramer.TryReadDocument(ref buffer, out var document)) + { + // the framer hands back a one-byte slice for anything that is not '{' or '[': drop it + if (document.Length > 1 || document.First.Span[0] == (byte)'{' || document.First.Span[0] == (byte)'[') + { + if (document.Length > _options.MaxRequestBytes) + { + connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes.")); + return; + } + JsonRpcProcessor.Process(session, in document, output, connection, _options.Serializer); + wrote = true; + } + } + + if (wrote) + { + var flush = await output.FlushAsync(connection.ConnectionClosed).ConfigureAwait(false); + if (flush.IsCompleted || flush.IsCanceled) break; + } + + if (result.IsCompleted || result.IsCanceled) + { + input.AdvanceTo(buffer.Start, buffer.End); + break; + } + if (buffer.Length > _options.MaxRequestBytes) + { + connection.Abort(new ConnectionAbortedException("JSON-RPC document exceeds MaxRequestBytes.")); + return; + } + // consumed up to the last complete document, examined everything + input.AdvanceTo(buffer.Start, buffer.End); + } + } + } +} diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcEndpoint.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcEndpoint.cs new file mode 100644 index 0000000..6e0b07b --- /dev/null +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcEndpoint.cs @@ -0,0 +1,165 @@ +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc.Serialization; +using Microsoft.AspNetCore.Http; + +namespace AustinHarris.JsonRpc.AspNetCore +{ + /// + /// The HTTP request handler: reads the body through , runs the processor on the + /// it yields, and writes the response straight into + /// . No strings, no intermediate byte arrays. + /// + public static class JsonRpcEndpoint + { + /// Processes an HTTP request using the synchronous or asynchronous mode selected in options. + public static Task HandleAsync(HttpContext http, JsonRpcOptions options) + { + return options?.EnableAsyncMethods == true ? HandleAsynchronousMethodsAsync(http, options) : HandleSynchronousMethodsAsync(http, options); + } + + internal static async Task HandleSynchronousMethodsAsync(HttpContext http, JsonRpcOptions options) + { + options ??= new JsonRpcOptions(); + if (!HttpMethods.IsPost(http.Request.Method)) + { + http.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + http.Response.Headers.Allow = "POST"; + return; + } + + var reader = http.Request.BodyReader; + var ct = http.RequestAborted; + ReadResult result; + while (true) + { + result = await reader.ReadAsync(ct).ConfigureAwait(false); + if (result.IsCompleted || result.IsCanceled) break; + if (result.Buffer.Length > options.MaxRequestBytes) + { + reader.AdvanceTo(result.Buffer.Start, result.Buffer.End); + http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + return; + } + // nothing consumed, everything examined: ReadAsync waits for more bytes + reader.AdvanceTo(result.Buffer.Start, result.Buffer.End); + } + + var buffer = result.Buffer; + try + { + if (buffer.Length > options.MaxRequestBytes) + { + http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + return; + } + + string session = options.SessionSelector?.Invoke(http) ?? options.SessionId ?? Handler.DefaultSessionId(); + object context = options.ContextFactory != null ? options.ContextFactory(http) : http; + + http.Response.ContentType = options.ResponseContentType; + var counting = new CountingBufferWriter(http.Response.BodyWriter); + JsonRpcProcessor.Process(session, in buffer, counting, context, options.Serializer); + + if (counting.Written == 0) + { + if (options.NoContentForNotifications) + { + http.Response.ContentType = null; + http.Response.StatusCode = StatusCodes.Status204NoContent; + } + else + { + http.Response.ContentLength = 0; + } + return; + } + await http.Response.BodyWriter.FlushAsync(ct).ConfigureAwait(false); + } + finally + { + reader.AdvanceTo(buffer.End); + } + } + + internal static async Task HandleAsynchronousMethodsAsync(HttpContext http, JsonRpcOptions options) + { + options ??= new JsonRpcOptions(); + if (!HttpMethods.IsPost(http.Request.Method)) + { + http.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + http.Response.Headers.Allow = "POST"; + return; + } + + var reader = http.Request.BodyReader; + var ct = http.RequestAborted; + ReadResult result; + while (true) + { + result = await reader.ReadAsync(ct).ConfigureAwait(false); + if (result.IsCompleted || result.IsCanceled) break; + if (result.Buffer.Length > options.MaxRequestBytes) + { + reader.AdvanceTo(result.Buffer.Start, result.Buffer.End); + http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + return; + } + // nothing consumed, everything examined: ReadAsync waits for more bytes + reader.AdvanceTo(result.Buffer.Start, result.Buffer.End); + } + + var buffer = result.Buffer; + try + { + if (buffer.Length > options.MaxRequestBytes) + { + http.Response.StatusCode = StatusCodes.Status413PayloadTooLarge; + return; + } + + string session = options.SessionSelector?.Invoke(http) ?? options.SessionId ?? Handler.DefaultSessionId(); + object context = options.ContextFactory != null ? options.ContextFactory(http) : http; + + http.Response.ContentType = options.ResponseContentType; + var counting = new CountingBufferWriter(http.Response.BodyWriter); + await JsonRpcProcessor.ProcessAsync(session, buffer, counting, context, options.Serializer, ct).ConfigureAwait(false); + + if (counting.Written == 0) + { + if (options.NoContentForNotifications) + { + http.Response.ContentType = null; + http.Response.StatusCode = StatusCodes.Status204NoContent; + } + else + { + http.Response.ContentLength = 0; + } + return; + } + await http.Response.BodyWriter.FlushAsync(ct).ConfigureAwait(false); + } + finally + { + reader.AdvanceTo(buffer.End); + } + } + + /// Tracks how many bytes a processor call advanced, so an empty (notification) response can be detected before headers are sent. + internal sealed class CountingBufferWriter : IBufferWriter + { + private readonly IBufferWriter _inner; + public long Written; + + public CountingBufferWriter(IBufferWriter inner) { _inner = inner; } + + public void Advance(int count) { Written += count; _inner.Advance(count); } + public Memory GetMemory(int sizeHint = 0) => _inner.GetMemory(sizeHint); + public Span GetSpan(int sizeHint = 0) => _inner.GetSpan(sizeHint); + } + } +} diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs new file mode 100644 index 0000000..eb9f86d --- /dev/null +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcOptions.cs @@ -0,0 +1,40 @@ +using System; +using AustinHarris.JsonRpc.Serialization; +using Microsoft.AspNetCore.Http; + +namespace AustinHarris.JsonRpc.AspNetCore +{ + /// Settings shared by the HTTP endpoint and the raw connection handler. + public class JsonRpcOptions + { + /// Awaits Task and ValueTask methods through ProcessAsync. False preserves synchronous processing. + public bool EnableAsyncMethods { get; set; } + + /// The session whose registered methods answer requests. Null means the default session. + public string SessionId { get; set; } + + /// + /// Picks the session per HTTP request (for example from a route value or a header). When set it takes + /// precedence over . Not used by the raw connection handler. + /// + public Func SessionSelector { get; set; } + + /// The serializer to use. Null means the session's serializer, falling back to . + public JsonRpcSerializer Serializer { get; set; } + + /// + /// Produces the object handed to methods through and to pre/post handlers. + /// Defaults to the itself for HTTP and the connection context for raw connections. + /// + public Func ContextFactory { get; set; } + + /// Largest request body accepted, in bytes. Larger bodies get 413. Default 4 MB. + public long MaxRequestBytes { get; set; } = 4 * 1024 * 1024; + + /// Content type of responses. Default "application/json". + public string ResponseContentType { get; set; } = "application/json"; + + /// Whether a notification (no response) answers 204 No Content (default) or 200 with an empty body. + public bool NoContentForNotifications { get; set; } = true; + } +} diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs new file mode 100644 index 0000000..8b6d528 --- /dev/null +++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace AustinHarris.JsonRpc.AspNetCore +{ + public static class JsonRpcServiceCollectionExtensions + { + /// Registers JSON-RPC options and the binder that registers DI-constructed services at startup. + public static IServiceCollection AddJsonRpc(this IServiceCollection services, Action configure = null) + { + if (configure != null) services.Configure(configure); + else services.AddOptions(); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; + } + + /// + /// 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 . + /// + public static IServiceCollection AddJsonRpcService(this IServiceCollection services, string sessionId = null) where TService : class + { + services.TryAddSingleton(); + services.AddSingleton(new JsonRpcServiceRegistration(typeof(TService), sessionId)); + return services; + } + + /// Registers every class in that declares at least one [JsonRpcMethod]. + public static IServiceCollection AddJsonRpcServicesFromAssembly(this IServiceCollection services, Assembly assembly, string sessionId = null) + { + 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)); + } + return services; + } + + /// + /// Maps a POST endpoint that processes JSON-RPC documents. Options default to the registered + /// (see ); pass to override per endpoint. + /// + public static IEndpointConventionBuilder MapJsonRpc(this IEndpointRouteBuilder endpoints, string pattern = "/jsonrpc", JsonRpcOptions options = null) + { + var resolved = options ?? endpoints.ServiceProvider.GetService>()?.Value ?? new JsonRpcOptions(); + return resolved.EnableAsyncMethods + ? endpoints.MapPost(pattern, http => JsonRpcEndpoint.HandleAsynchronousMethodsAsync(http, resolved)) + : endpoints.MapPost(pattern, http => JsonRpcEndpoint.HandleSynchronousMethodsAsync(http, resolved)); + } + + internal sealed class JsonRpcServiceRegistration + { + public JsonRpcServiceRegistration(Type type, string sessionId) { Type = type; SessionId = sessionId; } + public Type Type { get; } + public string SessionId { get; } + } + + /// 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; + + public JsonRpcBinderHostedService(IServiceProvider provider, IEnumerable registrations, IOptions options) + { + _provider = provider; + _registrations = registrations; + _options = options.Value; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + string defaultSession = Handler.DefaultSessionId(); + foreach (var r in _registrations) + { + var instance = _provider.GetRequiredService(r.Type); + // The effective session: the registration's own, then JsonRpcOptions.SessionId, then the default. + var session = r.SessionId ?? _options.SessionId ?? defaultSession; + // A JsonRpcService subclass already bound itself in its constructor, to the default session + // (parameterless base constructor). Bind it here whenever the effective session is a different + // one, otherwise the configured session would answer -32601 for it; rebinding the same + // instance to the same session is harmless (the method table entry is replaced). + if (instance is JsonRpcService && session == defaultSession) continue; + ServiceBinder.BindService(session, instance); + } + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + } +} diff --git a/AustinHarris.JsonRpc.AspNetCore/README.md b/AustinHarris.JsonRpc.AspNetCore/README.md new file mode 100644 index 0000000..2ccb3b3 --- /dev/null +++ b/AustinHarris.JsonRpc.AspNetCore/README.md @@ -0,0 +1,82 @@ +# AustinHarris.JsonRpc.AspNetCore + +Hosts [JSON-RPC.Net](https://github.com/Astn/JSON-RPC.NET) in ASP.NET Core. The core processes requests as +UTF-8 bytes, so the HTTP endpoint reads the body with `PipeReader` and writes straight into +`Response.BodyWriter`; nothing is turned into a string on the way through. A `ConnectionHandler` does the +same for JSON-RPC over a raw Kestrel connection (TCP, Unix socket, named pipe). + +## HTTP endpoint + +```csharp +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddJsonRpc(o => +{ + // o.EnableAsyncMethods = true; // await Task and ValueTask service methods + // o.Serializer = new SystemTextJsonRpcSerializer(); // optional, default is the built-in serializer + // o.SessionSelector = http => http.Request.RouteValues["session"] as string; +}); +builder.Services.AddJsonRpcService(); // any class with [JsonRpcMethod] methods, built by DI + +var app = builder.Build(); +app.MapJsonRpc("/rpc"); +app.Run(); + +public class CalculatorService +{ + private readonly ILogger _log; + public CalculatorService(ILogger log) => _log = log; + + [JsonRpcMethod] + public double add(double l, double r) => l + r; +} +``` + +`POST /rpc` with a request or a batch answers `200 application/json`; a notification answers `204`. +Inside a method `JsonRpcContext.Current().Value` is the `HttpContext` (override with `ContextFactory`). +Classes deriving from `JsonRpcService` still bind themselves; `AddJsonRpcService()` is for classes that +take constructor dependencies, and `AddJsonRpcServicesFromAssembly(typeof(Program).Assembly)` registers every +class that declares a `[JsonRpcMethod]`, MVC controllers included. + +Because it is an ordinary endpoint, `RequireAuthorization()`, rate limiting, output caching and the rest of +the middleware pipeline compose with it: + +```csharp +app.MapJsonRpc("/rpc").RequireAuthorization("api"); +``` + +## Raw connection (TCP) + +```csharp +builder.WebHost.ConfigureKestrel(k => +{ + k.ListenAnyIP(9000, l => l.UseConnectionHandler()); +}); +``` + +Clients write JSON documents back to back (a newline between them is fine); each document is answered in +order on the same connection, notifications produce nothing. The `ConnectionContext` is the RPC context. + +With `EnableAsyncMethods = true`, HTTP awaits `ProcessAsync` with `HttpContext.RequestAborted`; +the body reader remains leased until invocation finishes. Raw connections await each framed document +before starting the next and flush earlier completed replies before waiting for a slow document. +Notifications are awaited and keep the same HTTP status rules. Connection cancellation is cooperative: +the processor waits for the running method to terminate before releasing input and discards its staged response. +Mark a `CancellationToken` parameter with `[JsonRpcCancellation]` to receive that token. +The default mode preserves synchronous processing and rejects async methods at call time. + +## Options + +| Option | Default | Meaning | +|---|---|---| +| `EnableAsyncMethods` | false | use ProcessAsync for Task/ValueTask methods, with host cancellation | +| `SessionId` | default session | which session's methods answer | +| `SessionSelector` | null | pick the session per HTTP request | +| `Serializer` | session, then `Config.Serializer` | serializer for this host | +| `ContextFactory` | `HttpContext` | what `JsonRpcContext.Current()` returns | +| `MaxRequestBytes` | 4 MB | larger bodies get 413 (HTTP) or abort the connection | +| `ResponseContentType` | `application/json` | | +| `NoContentForNotifications` | true | 204 for notifications, otherwise 200 with an empty body | diff --git a/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj b/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj new file mode 100644 index 0000000..89ce515 --- /dev/null +++ b/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj @@ -0,0 +1,40 @@ + + + + Austin Harris + Austin Harris + Json-Rpc.Net Json.NET serializer + Json.NET (Newtonsoft.Json) serializer for JSON-RPC.Net. Lenient parsing and full Json.NET conversion semantics; pass JsonSerializerSettings to control it. + 2.0.0 + $(VersionSuffix) + Austin Harris + https://github.com/Astn/JSON-RPC.NET + https://github.com/Astn/JSON-RPC.NET + git + MIT + README.md + json-rpc;jsonrpc;json;rpc;server;json.net;newtonsoft + netstandard2.0;netstandard2.1;net8.0;net10.0 + latest + true + true + AustinHarris.JsonRpc.Newtonsoft + + + + + + + + + + + + + + + + + + + diff --git a/AustinHarris.JsonRpc.Newtonsoft/BufferWriterTextWriter.cs b/AustinHarris.JsonRpc.Newtonsoft/BufferWriterTextWriter.cs new file mode 100644 index 0000000..2f55fec --- /dev/null +++ b/AustinHarris.JsonRpc.Newtonsoft/BufferWriterTextWriter.cs @@ -0,0 +1,162 @@ +using System; +using System.Buffers; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace AustinHarris.JsonRpc.Newtonsoft +{ + /// + /// A reusable that UTF-8 encodes straight into an . + /// Characters are encoded in place (GetSpan / Advance) with a stateful , so surrogate + /// pairs split across writes stay intact and nothing is buffered on this side; drains + /// the encoder. No BOM is ever written. + /// + internal sealed class BufferWriterTextWriter : TextWriter + { + private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false); + + private readonly Encoder _encoder = Utf8NoBom.GetEncoder(); + private IBufferWriter _output; +#if NETSTANDARD2_0 + private readonly char[] _one = new char[1]; + private char[] _charScratch; + private byte[] _byteScratch; +#endif + + public BufferWriterTextWriter() : base(CultureInfo.InvariantCulture) { } + + public override Encoding Encoding => Utf8NoBom; + + /// Points the writer at a new destination and clears any encoder state. + public void Reset(IBufferWriter output) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _encoder.Reset(); + } + + /// Drops the reference to the destination once the value has been flushed. + public void Detach() => _output = null; + + public override void Flush() + { + if (_output == null) return; +#if NETSTANDARD2_0 + Encode(Array.Empty(), 0, 0, flush: true); +#else + Encode(ReadOnlySpan.Empty, flush: true); +#endif + } + + // ------------------------------------------------------------------ overrides Json.NET uses + + public override void Write(char value) + { +#if NETSTANDARD2_0 + _one[0] = value; + Encode(_one, 0, 1, flush: false); +#else + Span one = stackalloc char[1]; + one[0] = value; + Encode(one, flush: false); +#endif + } + + public override void Write(char[] buffer) => Write(buffer, 0, buffer.Length); + + public override void Write(char[] buffer, int index, int count) + { + if (count == 0) return; +#if NETSTANDARD2_0 + Encode(buffer, index, count, flush: false); +#else + Encode(new ReadOnlySpan(buffer, index, count), flush: false); +#endif + } + + public override void Write(string value) + { + if (string.IsNullOrEmpty(value)) return; +#if NETSTANDARD2_0 + if (_charScratch == null || _charScratch.Length < value.Length) + { + if (_charScratch != null) ArrayPool.Shared.Return(_charScratch); + _charScratch = ArrayPool.Shared.Rent(value.Length); + } + value.CopyTo(0, _charScratch, 0, value.Length); + Encode(_charScratch, 0, value.Length, flush: false); +#else + Encode(value.AsSpan(), flush: false); +#endif + } + +#if !NETSTANDARD2_0 + public override void Write(ReadOnlySpan buffer) + { + if (!buffer.IsEmpty) Encode(buffer, flush: false); + } +#endif + + // ------------------------------------------------------------------ encoding + +#if NETSTANDARD2_0 + private void Encode(char[] chars, int index, int count, bool flush) + { + var output = _output ?? throw new InvalidOperationException("The writer is not attached to an output."); + do + { + var memory = output.GetMemory(SizeHint(count)); + if (MemoryMarshal.TryGetArray(memory, out var segment)) + { + _encoder.Convert(chars, index, count, segment.Array, segment.Offset, segment.Count, flush, out int charsUsed, out int bytesUsed, out _); + output.Advance(bytesUsed); + index += charsUsed; + count -= charsUsed; + } + else + { + // not array-backed: encode through a pooled scratch buffer and copy + if (_byteScratch == null) _byteScratch = ArrayPool.Shared.Rent(4096); + _encoder.Convert(chars, index, count, _byteScratch, 0, _byteScratch.Length, flush, out int charsUsed, out int bytesUsed, out _); + var dest = output.GetSpan(bytesUsed); + new ReadOnlySpan(_byteScratch, 0, bytesUsed).CopyTo(dest); + output.Advance(bytesUsed); + index += charsUsed; + count -= charsUsed; + } + } while (count > 0); + } +#else + private void Encode(ReadOnlySpan chars, bool flush) + { + var output = _output ?? throw new InvalidOperationException("The writer is not attached to an output."); + do + { + var dest = output.GetSpan(SizeHint(chars.Length)); + _encoder.Convert(chars, dest, flush, out int charsUsed, out int bytesUsed, out _); + output.Advance(bytesUsed); + chars = chars.Slice(charsUsed); + } while (!chars.IsEmpty); + } +#endif + + /// Asks for the whole encoded size for short runs and a bounded chunk for long ones (the loop above handles the remainder). + private static int SizeHint(int charCount) + { + const int Chunk = 4096; + if (charCount >= Chunk) return Chunk; + return Math.Max(16, (charCount + 1) * 3); + } + + protected override void Dispose(bool disposing) + { + _output = null; +#if NETSTANDARD2_0 + if (_charScratch != null) { ArrayPool.Shared.Return(_charScratch); _charScratch = null; } + if (_byteScratch != null) { ArrayPool.Shared.Return(_byteScratch); _byteScratch = null; } +#endif + base.Dispose(disposing); + } + } +} diff --git a/AustinHarris.JsonRpc.Newtonsoft/JsonArrayPool.cs b/AustinHarris.JsonRpc.Newtonsoft/JsonArrayPool.cs new file mode 100644 index 0000000..bc25e5b --- /dev/null +++ b/AustinHarris.JsonRpc.Newtonsoft/JsonArrayPool.cs @@ -0,0 +1,20 @@ +using System.Buffers; +using Newtonsoft.Json; + +namespace AustinHarris.JsonRpc.Newtonsoft +{ + /// Lets / rent their char buffers from . + internal sealed class JsonArrayPool : IArrayPool + { + public static readonly JsonArrayPool Instance = new JsonArrayPool(); + + private JsonArrayPool() { } + + public char[] Rent(int minimumLength) => ArrayPool.Shared.Rent(minimumLength); + + public void Return(char[] array) + { + if (array != null) ArrayPool.Shared.Return(array); + } + } +} diff --git a/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpc.cs b/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpc.cs new file mode 100644 index 0000000..246a8cf --- /dev/null +++ b/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpc.cs @@ -0,0 +1,59 @@ +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace AustinHarris.JsonRpc.Newtonsoft +{ + /// + /// Settings-based entry points for callers that used to pass a to + /// in 1.x. Each distinct settings instance is turned into one + /// the first time it is seen and reused afterwards (the settings are + /// snapshotted at that point, as does); null means + /// Json.NET defaults. + /// + public static class NewtonsoftJsonRpc + { + private static readonly NewtonsoftJsonRpcSerializer Default = new NewtonsoftJsonRpcSerializer(); + private static readonly ConditionalWeakTable Cache = + new ConditionalWeakTable(); + + /// The serializer for (cached per settings instance; null = defaults). + public static NewtonsoftJsonRpcSerializer SerializerFor(JsonSerializerSettings settings) + { + if (settings == null) return Default; + return Cache.GetValue(settings, s => new NewtonsoftJsonRpcSerializer(s)); + } + + /// Processes a request on the default session with Json.NET configured by . + public static string ProcessSync(string jsonRpc, object context, JsonSerializerSettings settings) + { + return JsonRpcProcessor.ProcessSync(Handler.DefaultSessionId(), jsonRpc, context, SerializerFor(settings)); + } + + /// Processes a request on with Json.NET configured by . + public static string ProcessSync(string sessionId, string jsonRpc, object context, JsonSerializerSettings settings) + { + return JsonRpcProcessor.ProcessSync(sessionId, jsonRpc, context, SerializerFor(settings)); + } + + public static Task Process(string jsonRpc, object context, JsonSerializerSettings settings) + { + return JsonRpcProcessor.Process(Handler.DefaultSessionId(), jsonRpc, context, SerializerFor(settings)); + } + + public static Task Process(string sessionId, string jsonRpc, object context, JsonSerializerSettings settings) + { + return JsonRpcProcessor.Process(sessionId, jsonRpc, context, SerializerFor(settings)); + } + + public static void Process(JsonRpcStateAsync async, object context, JsonSerializerSettings settings) + { + JsonRpcProcessor.Process(Handler.DefaultSessionId(), async, context, SerializerFor(settings)); + } + + public static void Process(string sessionId, JsonRpcStateAsync async, object context, JsonSerializerSettings settings) + { + JsonRpcProcessor.Process(sessionId, async, context, SerializerFor(settings)); + } + } +} diff --git a/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpcSerializer.cs b/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpcSerializer.cs new file mode 100644 index 0000000..645ad54 --- /dev/null +++ b/AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpcSerializer.cs @@ -0,0 +1,189 @@ +using System; +using System.Buffers; +using AustinHarris.JsonRpc.Serialization; +using Newtonsoft.Json; + +namespace AustinHarris.JsonRpc.Newtonsoft +{ + /// + /// Json.NET-backed serializer. The core parses the JSON-RPC envelope itself and hands this class the raw + /// UTF-8 bytes of individual values (params, results, error data); every conversion goes through one + /// built from , so converters, contract resolvers, + /// date/float handling and formatting behave exactly as they do with . + /// + /// Reading decodes the value bytes into a pooled char buffer and feeds it to a + /// whose scratch buffer is rented from ; nothing is materialised as a string. + /// Writing runs a per-thread over a that + /// UTF-8 encodes straight into the caller's . Output is compact, exactly one + /// JSON value, no trailing whitespace. + /// + /// + public sealed class NewtonsoftJsonRpcSerializer : JsonRpcSerializer + { + private readonly JsonSerializer _serializer; + + public NewtonsoftJsonRpcSerializer() : this(null) { } + + /// + /// Settings applied to every conversion (null = Json.NET defaults). The serializer is built once from + /// the settings; like , is honoured + /// as the baseline. + /// + public NewtonsoftJsonRpcSerializer(JsonSerializerSettings settings) + { + Settings = settings; + _serializer = JsonSerializer.CreateDefault(settings); + } + + /// The settings used for every conversion (null = Json.NET defaults). + public JsonSerializerSettings Settings { get; } + + /// The built from . Shared by every conversion; do not mutate it while requests are in flight. + public JsonSerializer Serializer => _serializer; + + public override string Name => "newtonsoft"; + + /// Json.NET accepts single quotes, unquoted names and comments; let the envelope reader do the same. + public override bool Lenient => true; + + /// The envelope reader enforces the same limit as (64 when unset). + public override int MaxDepth => _serializer.MaxDepth ?? 64; + + // ------------------------------------------------------------------ read + + public override T Read(ReadOnlySpan utf8Json) + { + var scratch = Scratch.Current; + var text = scratch.RentReader(); + try + { + text.Reset(utf8Json); + using (var json = CreateReader(text)) + { + return _serializer.Deserialize(json); + } + } + finally + { + scratch.ReturnReader(text); + } + } + + public override object Read(ReadOnlySpan utf8Json, Type type) + { + var scratch = Scratch.Current; + var text = scratch.RentReader(); + try + { + text.Reset(utf8Json); + using (var json = CreateReader(text)) + { + // typeof(object) yields JObject / JArray / unwrapped primitives, exactly like JsonConvert.DeserializeObject(text) + return _serializer.Deserialize(json, type); + } + } + finally + { + scratch.ReturnReader(text); + } + } + + private static JsonTextReader CreateReader(Utf8CharReader text) + { + return new JsonTextReader(text) + { + ArrayPool = JsonArrayPool.Instance, + CloseInput = false + }; + } + + // ------------------------------------------------------------------ write + + public override void Write(IBufferWriter output, T value) => WriteCore(output, value, typeof(T)); + + public override void Write(IBufferWriter output, object value, Type type) => WriteCore(output, value, type ?? value?.GetType() ?? typeof(object)); + + private void WriteCore(IBufferWriter output, object value, Type type) + { + var scratch = Scratch.Current; + if (scratch.WriterInUse) + { + // re-entrant call on this thread (a converter serializing through the RPC serializer): use throwaway instances + var fresh = new BufferWriterTextWriter(); + fresh.Reset(output); + Serialize(CreateWriter(fresh), fresh, value, type); + return; + } + + scratch.WriterInUse = true; + var text = scratch.Text; + var json = scratch.Writer ?? (scratch.Writer = CreateWriter(text)); + text.Reset(output); + bool completed = false; + try + { + Serialize(json, text, value, type); + // a JsonTextWriter can write any number of root values; it is only reusable while it is back at the root + completed = json.WriteState == WriteState.Start; + } + finally + { + if (!completed) scratch.Writer = null; + text.Detach(); + scratch.WriterInUse = false; + } + } + + private void Serialize(JsonTextWriter json, BufferWriterTextWriter text, object value, Type type) + { + _serializer.Serialize(json, value, type); + json.Flush(); // JsonTextWriter buffers nothing itself; this flushes the TextWriter (and its UTF-8 encoder) + text.Flush(); + } + + private static JsonTextWriter CreateWriter(BufferWriterTextWriter text) + { + return new JsonTextWriter(text) + { + ArrayPool = JsonArrayPool.Instance, + CloseOutput = false, + AutoCompleteOnClose = false, + Formatting = Formatting.None + }; + } + + // ------------------------------------------------------------------ per-thread scratch + + /// + /// One decoder/encoder pair per thread. The is kept between calls (Json.NET + /// lets a writer emit successive root values); a cannot be rewound, so one is + /// created per read and only its char buffer is pooled. + /// + private sealed class Scratch + { + [ThreadStatic] private static Scratch _current; + + public static Scratch Current => _current ?? (_current = new Scratch()); + + private readonly Utf8CharReader _reader = new Utf8CharReader(); + private bool _readerInUse; + + public readonly BufferWriterTextWriter Text = new BufferWriterTextWriter(); + public JsonTextWriter Writer; + public bool WriterInUse; + + public Utf8CharReader RentReader() + { + if (_readerInUse) return new Utf8CharReader(); + _readerInUse = true; + return _reader; + } + + public void ReturnReader(Utf8CharReader reader) + { + if (ReferenceEquals(reader, _reader)) _readerInUse = false; + else reader.Dispose(); + } + } + } +} diff --git a/AustinHarris.JsonRpc.Newtonsoft/README.md b/AustinHarris.JsonRpc.Newtonsoft/README.md new file mode 100644 index 0000000..595640a --- /dev/null +++ b/AustinHarris.JsonRpc.Newtonsoft/README.md @@ -0,0 +1,78 @@ +# AustinHarris.JsonRpc.Newtonsoft + +Json.NET (Newtonsoft.Json) serializer for [JSON-RPC.Net](https://github.com/Astn/JSON-RPC.NET) 2.0. + +The core package (`AustinHarris.JsonRpc`) parses the JSON-RPC envelope itself and ships a small dependency-free +serializer for parameters and results. Install this package when you want Json.NET to do the value conversions: +its converters, contract resolvers, `[JsonProperty]` attributes, date/float handling, and its tolerance for +non-strict JSON. + +``` +dotnet add package AustinHarris.JsonRpc.Newtonsoft +``` + +## Choosing the serializer + +Process-wide (every session that does not override it): + +```csharp +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Newtonsoft; +using Newtonsoft.Json; + +var settings = new JsonSerializerSettings { DateFormatString = "yyyy-MM-dd" }; // optional +Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings)); +``` + +Per session: + +```csharp +Handler.GetSessionHandler("session-42").Serializer = new NewtonsoftJsonRpcSerializer(settings); +// or: Config.SetSerializer("session-42", new NewtonsoftJsonRpcSerializer(settings)); +``` + +Per call (overrides both): + +```csharp +var serializer = new NewtonsoftJsonRpcSerializer(settings); +string response = JsonRpcProcessor.ProcessSync(sessionId, json, context, serializer); +``` + +Create the serializer once and reuse it: it holds one `JsonSerializer` built from the settings, and the processor +caches an envelope reader per serializer instance. + +## Settings-based helpers (1.x compatibility) + +The `JsonSerializerSettings` overloads that `JsonRpcProcessor` had in 1.x live here now: + +```csharp +string response = NewtonsoftJsonRpc.ProcessSync(sessionId, json, context, settings); +Task task = NewtonsoftJsonRpc.Process(sessionId, json, context, settings); +NewtonsoftJsonRpc.Process(sessionId, stateAsync, context, settings); +``` + +Each distinct settings instance is turned into a serializer the first time it is seen and reused afterwards +(`NewtonsoftJsonRpc.SerializerFor(settings)` gives you that instance). `null` settings means Json.NET defaults. + +## What you get + +* Every conversion honours the settings: params, results, `error.data`, and the `JsonRequest.Params` handed to + pre/post-process handlers (a `JObject` / `JArray` / primitive, as `JsonConvert.DeserializeObject` returns). +* Json.NET's defaults already match the library's wire conventions: compact output, `3.0` for whole floating + values, ISO-8601 dates (fraction only when non-zero, trailing zeros trimmed) with the offset, `char` as a one-character string, nulls + written, members in declaration order, case-insensitive member names on input, numbers coerced to + `bool`/`char`/floating types. +* Leniency. Json.NET accepts more than RFC 8259, and with this serializer selected so does the envelope reader: + single-quoted strings, unquoted member names and trailing commas are accepted in the request, e.g. + `{method:'add',params:[1,2],id:1}`. With the built-in serializer the same request is a `-32700` parse error. +* `JsonConvert.DefaultSettings`, if your process sets it, is the baseline exactly as it is for `JsonConvert`. + +Conversion failures throw and are reported to the client as `-32603 Internal Error`. + +## Performance notes + +Reading decodes the value's UTF-8 bytes once into a pooled `char[]` and hands that to a `JsonTextReader` whose +own buffer is rented from `ArrayPool`; no `string` or `MemoryStream` is created. Writing keeps one +`JsonTextWriter` per thread over a `TextWriter` that UTF-8 encodes straight into the caller's +`IBufferWriter` (a `PipeWriter`, the HTTP body writer, or the processor's pooled buffer), so the JSON is +never assembled as a string first. diff --git a/AustinHarris.JsonRpc.Newtonsoft/Utf8CharReader.cs b/AustinHarris.JsonRpc.Newtonsoft/Utf8CharReader.cs new file mode 100644 index 0000000..bbeedd9 --- /dev/null +++ b/AustinHarris.JsonRpc.Newtonsoft/Utf8CharReader.cs @@ -0,0 +1,96 @@ +using System; +using System.Buffers; +using System.IO; +using System.Text; + +namespace AustinHarris.JsonRpc.Newtonsoft +{ + /// + /// A reusable over the UTF-8 bytes of one JSON value. The bytes are decoded once + /// into a pooled char buffer (no string, no MemoryStream) and served to . + /// + internal sealed class Utf8CharReader : TextReader + { + private char[] _chars = ArrayPool.Shared.Rent(512); + private int _length; + private int _pos; + + /// Decodes into the buffer and rewinds. + public void Reset(ReadOnlySpan utf8) + { + int max = Encoding.UTF8.GetMaxCharCount(utf8.Length); + if (_chars.Length < max) + { + ArrayPool.Shared.Return(_chars); + _chars = ArrayPool.Shared.Rent(max); + } +#if NETSTANDARD2_0 + if (utf8.Length == 0) + { + _length = 0; + } + else + { + unsafe + { + fixed (byte* src = utf8) + fixed (char* dst = _chars) + { + _length = Encoding.UTF8.GetChars(src, utf8.Length, dst, _chars.Length); + } + } + } +#else + _length = Encoding.UTF8.GetChars(utf8, _chars); +#endif + _pos = 0; + } + + public override int Peek() => _pos < _length ? _chars[_pos] : -1; + + public override int Read() => _pos < _length ? _chars[_pos++] : -1; + + public override int Read(char[] buffer, int index, int count) + { + int n = Math.Min(count, _length - _pos); + if (n <= 0) return 0; + Array.Copy(_chars, _pos, buffer, index, n); + _pos += n; + return n; + } + + public override int ReadBlock(char[] buffer, int index, int count) => Read(buffer, index, count); + +#if !NETSTANDARD2_0 + public override int Read(Span buffer) + { + int n = Math.Min(buffer.Length, _length - _pos); + if (n <= 0) return 0; + new ReadOnlySpan(_chars, _pos, n).CopyTo(buffer); + _pos += n; + return n; + } + + public override int ReadBlock(Span buffer) => Read(buffer); +#endif + + public override string ReadToEnd() + { + var s = _pos < _length ? new string(_chars, _pos, _length - _pos) : string.Empty; + _pos = _length; + return s; + } + + protected override void Dispose(bool disposing) + { + var chars = _chars; + if (chars != null) + { + _chars = null; + _length = _pos = 0; + ArrayPool.Shared.Return(chars); + } + base.Dispose(disposing); + } + } +} diff --git a/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj b/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj new file mode 100644 index 0000000..04c3f9c --- /dev/null +++ b/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj @@ -0,0 +1,35 @@ + + + + Austin Harris + Austin Harris + Json-Rpc.Net System.Text.Json serializer + System.Text.Json serializer for JSON-RPC.Net. Utf8JsonReader/Utf8JsonWriter end to end; pass JsonSerializerOptions to control it. + 2.0.0 + $(VersionSuffix) + Austin Harris + https://github.com/Astn/JSON-RPC.NET + https://github.com/Astn/JSON-RPC.NET + git + MIT + README.md + json-rpc;jsonrpc;json;rpc;server;system.text.json + netstandard2.0;netstandard2.1;net8.0;net10.0 + latest + true + AustinHarris.JsonRpc.SystemTextJson + + + + + + + + + + + + + + + diff --git a/AustinHarris.JsonRpc.SystemTextJson/JsonRpcConverters.cs b/AustinHarris.JsonRpc.SystemTextJson/JsonRpcConverters.cs new file mode 100644 index 0000000..007b198 --- /dev/null +++ b/AustinHarris.JsonRpc.SystemTextJson/JsonRpcConverters.cs @@ -0,0 +1,486 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc.SystemTextJson +{ + /// + /// The converters that make System.Text.Json produce the wire conventions shared by every JSON-RPC.Net + /// serializer: whole float/double/decimal values carry ".0", DateTime is ISO-8601 as Json.NET writes it (fraction only when non-zero, trailing zeros trimmed) + /// and the offset, char is a one-character string, and reads accept the coercions the library has always + /// accepted (numeric strings, true/false as 1/0, numbers as bool, numbers as char, integers to floats). + /// + public static class JsonRpcConverters + { + /// A fresh list of the converters, in registration order. + public static IEnumerable Create() + { + yield return new JsonRpcNumberConverterFactory(); + yield return new JsonRpcBooleanConverter(); + yield return new JsonRpcCharConverter(); + yield return new JsonRpcDateTimeConverter(); + yield return new JsonRpcDateTimeOffsetConverter(); + } + + /// + /// Appends every converter whose type is not already present in . They are added + /// after the existing ones, so converters the caller registered keep precedence (System.Text.Json uses the + /// first converter that can handle a type). + /// + public static void AddMissing(JsonSerializerOptions options) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + foreach (var converter in Create()) + { + if (!Contains(options, converter.GetType())) options.Converters.Add(converter); + } + } + + /// True when every converter type from is registered on . + public static bool ContainsAll(JsonSerializerOptions options) + { + if (options == null) return false; + foreach (var converter in Create()) + { + if (!Contains(options, converter.GetType())) return false; + } + return true; + } + + private static bool Contains(JsonSerializerOptions options, Type converterType) + { + var converters = options.Converters; + for (int i = 0; i < converters.Count; i++) + { + if (converters[i].GetType() == converterType) return true; + } + return false; + } + } + + /// Shared read coercions and number formatting (mirrors the built-in serializer's JsmnMapper / Utf8Json). + internal static class Wire + { + // ------------------------------------------------------------------ reads + + public static long ReadInt64(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.Number: + if (reader.TryGetInt64(out long l)) return l; + return RoundToInt64(reader.GetDouble()); + case JsonTokenType.True: + return 1; + case JsonTokenType.False: + return 0; + case JsonTokenType.String: + { + var s = reader.GetString(); + if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out l)) return l; + if (s == "true") return 1; + if (s == "false") return 0; + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return RoundToInt64(d); + break; + } + } + throw Bind(ref reader, "integer"); + } + + public static ulong ReadUInt64(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.Number && reader.TryGetUInt64(out ulong v)) return v; + if (reader.TokenType == JsonTokenType.String && ulong.TryParse(reader.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out v)) return v; + return checked((ulong)ReadInt64(ref reader)); + } + + public static double ReadDouble(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.Number: + return reader.GetDouble(); + case JsonTokenType.True: + return 1; + case JsonTokenType.False: + return 0; + case JsonTokenType.String: + { + var s = reader.GetString(); + // "NaN", "Infinity", "-Infinity": what Json.NET (and every serializer here) writes for non-finite values + if (Utf8Json.TryParseNonFinite(s, out double d)) return d; + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out d)) return d; + if (s == "true") return 1; + if (s == "false") return 0; + break; + } + } + throw Bind(ref reader, "number"); + } + + public static float ReadSingle(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.Number) return reader.GetSingle(); + return (float)ReadDouble(ref reader); + } + + public static decimal ReadDecimal(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.Number: + if (reader.TryGetDecimal(out decimal m)) return m; + return (decimal)reader.GetDouble(); + case JsonTokenType.True: + return 1; + case JsonTokenType.False: + return 0; + case JsonTokenType.String: + { + var s = reader.GetString(); + if (decimal.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out m)) return m; + if (s == "true") return 1; + if (s == "false") return 0; + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return (decimal)d; + break; + } + } + throw Bind(ref reader, "decimal"); + } + + public static bool ReadBoolean(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.Number: + return reader.GetDouble() != 0; + case JsonTokenType.String: + { + var s = reader.GetString(); + if (bool.TryParse(s, out bool b)) return b; + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) return d != 0; + break; + } + } + throw Bind(ref reader, "boolean"); + } + + public static char ReadChar(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (s.Length == 1) return s[0]; + throw Bind(ref reader, "single character"); + } + return checked((char)ReadInt64(ref reader)); + } + + public static DateTime ReadDateTime(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)) return dt; + } + throw Bind(ref reader, "DateTime"); + } + + public static DateTimeOffset ReadDateTimeOffset(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dto)) return dto; + } + throw Bind(ref reader, "DateTimeOffset"); + } + + private static long RoundToInt64(double d) => checked((long)Math.Round(d, MidpointRounding.ToEven)); + + private static JsonException Bind(ref Utf8JsonReader reader, string expected) + { + string text; + switch (reader.TokenType) + { + case JsonTokenType.String: + text = "\"" + reader.GetString() + "\""; + break; + case JsonTokenType.Number: + case JsonTokenType.True: + case JsonTokenType.False: + case JsonTokenType.Null: + { + var span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray(); + text = System.Text.Encoding.UTF8.GetString(span, 0, span.Length); + break; + } + default: + text = reader.TokenType.ToString(); + break; + } + if (text.Length > 64) text = text.Substring(0, 64) + "..."; + return new JsonException("Could not convert " + text + " to " + expected + "."); + } + + // ------------------------------------------------------------------ writes + + public static void WriteDouble(Utf8JsonWriter writer, double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) { WriteNonFinite(writer, value); return; } + Span buffer = stackalloc byte[40]; +#if NETSTANDARD2_0 + int written = WriteAscii(value.ToString("R", CultureInfo.InvariantCulture), buffer); +#else + Utf8Formatter.TryFormat(value, buffer, out int written); +#endif + written = EnsureDecimalPlace(buffer, written); + writer.WriteRawValue(buffer.Slice(0, written), skipInputValidation: true); + } + + public static void WriteSingle(Utf8JsonWriter writer, float value) + { + if (float.IsNaN(value) || float.IsInfinity(value)) { WriteNonFinite(writer, value); return; } + Span buffer = stackalloc byte[40]; +#if NETSTANDARD2_0 + int written = WriteAscii(value.ToString("R", CultureInfo.InvariantCulture), buffer); +#else + Utf8Formatter.TryFormat(value, buffer, out int written); +#endif + written = EnsureDecimalPlace(buffer, written); + writer.WriteRawValue(buffer.Slice(0, written), skipInputValidation: true); + } + + public static void WriteDecimal(Utf8JsonWriter writer, decimal value) + { + Span buffer = stackalloc byte[48]; + Utf8Formatter.TryFormat(value, buffer, out int written); + written = EnsureDecimalPlace(buffer, written); + writer.WriteRawValue(buffer.Slice(0, written), skipInputValidation: true); + } + + public static void WriteChar(Utf8JsonWriter writer, char value) + { + Span one = stackalloc char[1]; + one[0] = value; + writer.WriteStringValue(one); + } + + /// The core's (Json.NET-compatible) DateTime text, see : fraction only when non-zero, trailing zeros trimmed; Z for Utc, offset for Local, nothing for Unspecified. + public static void WriteDateTime(Utf8JsonWriter writer, DateTime value) + { + // The raw write bypasses the encoder so a '+' in the offset is never escaped, whatever encoder the options carry. + Span buffer = stackalloc byte[Utf8Json.MaxDateTimeLength + 2]; + buffer[0] = (byte)'"'; + int written = Utf8Json.FormatDateTime(buffer.Slice(1), value); + buffer[written + 1] = (byte)'"'; + writer.WriteRawValue(buffer.Slice(0, written + 2), skipInputValidation: true); + } + + /// Same text as , with the offset always written. + public static void WriteDateTimeOffset(Utf8JsonWriter writer, DateTimeOffset value) + { + Span buffer = stackalloc byte[Utf8Json.MaxDateTimeLength + 2]; + buffer[0] = (byte)'"'; + int written = Utf8Json.FormatDateTimeOffset(buffer.Slice(1), value); + buffer[written + 1] = (byte)'"'; + writer.WriteRawValue(buffer.Slice(0, written + 2), skipInputValidation: true); + } + + private static void WriteNonFinite(Utf8JsonWriter writer, double value) + { + // Bare NaN / Infinity are not JSON. Json.NET's default (FloatFormatHandling.String) and the built-in + // serializer write the quoted strings "NaN", "Infinity", "-Infinity"; keep the wire identical. + writer.WriteStringValue(Utf8Json.NonFiniteText(value)); + } + + private static int WriteAscii(string text, Span buffer) + { + for (int i = 0; i < text.Length; i++) buffer[i] = (byte)text[i]; + return text.Length; + } + + /// Appends ".0" when the formatted (finite) number has neither a fraction nor an exponent. + private static int EnsureDecimalPlace(Span buffer, int written) + { + for (int i = 0; i < written; i++) + { + byte b = buffer[i]; + if (b == (byte)'.' || b == (byte)'E' || b == (byte)'e') return written; + } + buffer[written] = (byte)'.'; + buffer[written + 1] = (byte)'0'; + return written + 2; + } + } + + /// + /// Replaces the built-in converters for the 11 primitive numeric types. Writes float/double/decimal with a + /// ".0" on whole values; reads numbers, numeric strings and true/false (as 1/0) for every numeric type, and + /// rounds fractional input to the nearest even integer for integer types. Nullable variants are handled by + /// System.Text.Json's own nullable wrapping. + /// + public sealed class JsonRpcNumberConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + switch (Type.GetTypeCode(typeToConvert)) + { + case TypeCode.Double: + case TypeCode.Single: + case TypeCode.Decimal: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.Int16: + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + return !typeToConvert.IsEnum; + default: + return false; + } + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + switch (Type.GetTypeCode(typeToConvert)) + { + case TypeCode.Double: return DoubleConverter.Instance; + case TypeCode.Single: return SingleConverter.Instance; + case TypeCode.Decimal: return DecimalConverter.Instance; + case TypeCode.Int32: return Int32Converter.Instance; + case TypeCode.Int64: return Int64Converter.Instance; + case TypeCode.Int16: return Int16Converter.Instance; + case TypeCode.Byte: return ByteConverter.Instance; + case TypeCode.SByte: return SByteConverter.Instance; + case TypeCode.UInt16: return UInt16Converter.Instance; + case TypeCode.UInt32: return UInt32Converter.Instance; + case TypeCode.UInt64: return UInt64Converter.Instance; + default: throw new NotSupportedException(typeToConvert.FullName); + } + } + + private sealed class DoubleConverter : JsonConverter + { + public static readonly DoubleConverter Instance = new DoubleConverter(); + public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadDouble(ref reader); + public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) => Wire.WriteDouble(writer, value); + } + + private sealed class SingleConverter : JsonConverter + { + public static readonly SingleConverter Instance = new SingleConverter(); + public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadSingle(ref reader); + public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options) => Wire.WriteSingle(writer, value); + } + + private sealed class DecimalConverter : JsonConverter + { + public static readonly DecimalConverter Instance = new DecimalConverter(); + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadDecimal(ref reader); + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) => Wire.WriteDecimal(writer, value); + } + + private sealed class Int32Converter : JsonConverter + { + public static readonly Int32Converter Instance = new Int32Converter(); + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out int v)) return v; + return checked((int)Wire.ReadInt64(ref reader)); + } + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + + private sealed class Int64Converter : JsonConverter + { + public static readonly Int64Converter Instance = new Int64Converter(); + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadInt64(ref reader); + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + + private sealed class Int16Converter : JsonConverter + { + public static readonly Int16Converter Instance = new Int16Converter(); + public override short Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((short)Wire.ReadInt64(ref reader)); + public override void Write(Utf8JsonWriter writer, short value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + + private sealed class ByteConverter : JsonConverter + { + public static readonly ByteConverter Instance = new ByteConverter(); + public override byte Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((byte)Wire.ReadInt64(ref reader)); + public override void Write(Utf8JsonWriter writer, byte value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + + private sealed class SByteConverter : JsonConverter + { + public static readonly SByteConverter Instance = new SByteConverter(); + public override sbyte Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((sbyte)Wire.ReadInt64(ref reader)); + public override void Write(Utf8JsonWriter writer, sbyte value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + + private sealed class UInt16Converter : JsonConverter + { + public static readonly UInt16Converter Instance = new UInt16Converter(); + public override ushort Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((ushort)Wire.ReadInt64(ref reader)); + public override void Write(Utf8JsonWriter writer, ushort value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + + private sealed class UInt32Converter : JsonConverter + { + public static readonly UInt32Converter Instance = new UInt32Converter(); + public override uint Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => checked((uint)Wire.ReadInt64(ref reader)); + public override void Write(Utf8JsonWriter writer, uint value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + + private sealed class UInt64Converter : JsonConverter + { + public static readonly UInt64Converter Instance = new UInt64Converter(); + public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadUInt64(ref reader); + public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) => writer.WriteNumberValue(value); + } + } + + /// Reads true/false, numbers (non-zero is true) and "true"/"false"/numeric strings. + public sealed class JsonRpcBooleanConverter : JsonConverter + { + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadBoolean(ref reader); + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) => writer.WriteBooleanValue(value); + } + + /// Writes a one-character string; reads a one-character string or a number (the UTF-16 code unit). + public sealed class JsonRpcCharConverter : JsonConverter + { + public override char Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadChar(ref reader); + public override void Write(Utf8JsonWriter writer, char value, JsonSerializerOptions options) => Wire.WriteChar(writer, value); + } + + /// + /// Writes the core DateTime text (see Utf8Json.FormatDateTime); reads any ISO-8601 text with + /// DateTimeStyles.RoundtripKind, so an input with an offset yields a Local DateTime. + /// + public sealed class JsonRpcDateTimeConverter : JsonConverter + { + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadDateTime(ref reader); + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) => Wire.WriteDateTime(writer, value); + } + + /// Writes the core DateTimeOffset text (see Utf8Json.FormatDateTimeOffset); the offset is always written. + public sealed class JsonRpcDateTimeOffsetConverter : JsonConverter + { + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => Wire.ReadDateTimeOffset(ref reader); + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => Wire.WriteDateTimeOffset(writer, value); + } +} diff --git a/AustinHarris.JsonRpc.SystemTextJson/README.md b/AustinHarris.JsonRpc.SystemTextJson/README.md new file mode 100644 index 0000000..027cbfa --- /dev/null +++ b/AustinHarris.JsonRpc.SystemTextJson/README.md @@ -0,0 +1,111 @@ +# AustinHarris.JsonRpc.SystemTextJson + +System.Text.Json serializer for [JSON-RPC.Net](https://github.com/Astn/JSON-RPC.NET) 2.0. The core parses the +JSON-RPC envelope (method / params / id) itself and asks the serializer only to convert values: request +parameters arrive as the raw UTF-8 bytes of one JSON value and go straight into `JsonSerializer.Deserialize` +(no transcoding, no copies); results are written with a per-thread cached `Utf8JsonWriter` directly into the +response buffer. + +## Install + +``` +dotnet add package AustinHarris.JsonRpc.SystemTextJson +``` + +Targets netstandard2.0, netstandard2.1, net8.0 and net10.0; depends on System.Text.Json 10.0.x and the +`AustinHarris.JsonRpc` core. + +## Use + +Process-wide default: + +```csharp +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.SystemTextJson; + +Config.SetSerializer(new SystemTextJsonRpcSerializer()); +// or with your own options +Config.SetSerializer(new SystemTextJsonRpcSerializer(options)); +``` + +Per session: + +```csharp +Handler.GetSessionHandler(sessionId).Serializer = new SystemTextJsonRpcSerializer(options); +// equivalently: Config.SetSerializer(sessionId, serializer) +``` + +Per call (overrides both the session and the global default): + +```csharp +var serializer = new SystemTextJsonRpcSerializer(options); +string response = JsonRpcProcessor.ProcessSync(sessionId, json, context, serializer); +// the byte-based overloads take the same trailing argument: +JsonRpcProcessor.Process(sessionId, requestBytes, outputWriter, context, serializer); +``` + +Create one instance and reuse it: the instance owns nothing mutable, and its options are read-only after +construction. + +## Default options + +`new SystemTextJsonRpcSerializer()` uses `SystemTextJsonRpcSerializer.DefaultOptions`, a single immutable +`JsonSerializerOptions` that reproduces the wire conventions of the built-in (jsmn) and Json.NET serializers, so +the same request yields byte-identical responses whichever serializer is installed: + +| Setting | Value | +| --- | --- | +| `WriteIndented` | `false` (compact output) | +| `DefaultIgnoreCondition` | `Never` (nulls are written) | +| `PropertyNamingPolicy` | `null` (member names as declared, in declaration order) | +| `PropertyNameCaseInsensitive` | `true` | +| `IncludeFields` | `true` (public fields bind like properties) | +| `Encoder` | `JavaScriptEncoder.UnsafeRelaxedJsonEscaping` (no `+` for `+`, etc.) | +| `NumberHandling` | `AllowReadingFromString` | +| `Converters` | the `JsonRpcConverters` below | + +### Converters + +Registered by `JsonRpcConverters.AddMissing(options)`; each is public so it can be used on its own. + +| Converter | Writes | Reads | +| --- | --- | --- | +| `JsonRpcNumberConverterFactory` (double, float, decimal, and the 8 integer types) | whole float/double/decimal values with `.0` (`3.0`, `71.0`, `0.0`), otherwise shortest round-trip (`1.2345`, `3.14159`); NaN/Infinity as bare symbols like Json.NET | numbers, numeric strings, `true`/`false` as 1/0; fractional input for integer types is rounded to even | +| `JsonRpcBooleanConverter` | `true`/`false` | booleans, numbers (non-zero is true), `"true"`/`"false"`/numeric strings | +| `JsonRpcCharConverter` | a one-character string | a one-character string or a number (`98` reads as `'b'`) | +| `JsonRpcDateTimeConverter` | `yyyy-MM-ddTHH:mm:ss[.fffffff]K`, the fraction only when non-zero and with trailing zeros trimmed, exactly as Json.NET and the built-in serializer write it | any ISO-8601 text via `DateTime.Parse(..., InvariantCulture, RoundtripKind)`: an offset in the input yields a Local `DateTime` | +| `JsonRpcDateTimeOffsetConverter` | `yyyy-MM-ddTHH:mm:ss[.fffffff]zzz` | ISO-8601 text | + +Nullable variants (`double?`, `DateTime?`, ...) are covered automatically by System.Text.Json's nullable +wrapping. Because these converters replace the built-in numeric ones, `JsonNumberHandling.WriteAsString` and +`AllowNamedFloatingPointLiterals` are not applied to the primitive numeric types. + +## Supplying your own options + +`new SystemTextJsonRpcSerializer(options)` honours the options as given (naming policy, encoder, extra +converters, `TypeInfoResolver`, ...). The only adjustment is the converter set: + +- If `options.Converters` already contains every `JsonRpcConverters` type, the instance is used as-is (it is + made read-only, as System.Text.Json would do on first use anyway). +- Otherwise the options are copied with `new JsonSerializerOptions(options)` and the missing converters are + appended to the copy. The object you passed is never mutated, so it is safe to share with other code. The + converters are appended *after* yours, so a converter you registered for the same type keeps precedence. + +`Options` returns what you passed (null for the defaults); `EffectiveOptions` returns the instance actually used. +To start from the library defaults and tweak them: + +```csharp +var options = SystemTextJsonRpcSerializer.CreateDefaultOptions(); // a mutable copy of DefaultOptions +options.Converters.Add(new JsonStringEnumConverter()); +options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; +Config.SetSerializer(new SystemTextJsonRpcSerializer(options)); +``` + +If you build options from scratch, remember that the wire conventions above (`IncludeFields`, +`UnsafeRelaxedJsonEscaping`, `PropertyNameCaseInsensitive`) are then up to you; only the converters are added. + +## Object model + +Pre/post-process handlers receive `JsonRequest.Params` as a `JsonElement` (the result of deserializing the +params to `object`), and `Handler.Handle(JsonRequest)` accepts a `JsonElement` back. Conversion failures throw +`JsonException`; the core reports them as JSON-RPC error -32603. diff --git a/AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs b/AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs new file mode 100644 index 0000000..6189226 --- /dev/null +++ b/AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs @@ -0,0 +1,281 @@ +using System; +using System.Buffers; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc.SystemTextJson +{ + /// + /// System.Text.Json-backed serializer. The core parses the JSON-RPC envelope itself and hands this class the + /// raw UTF-8 bytes of single values, which go straight into (no transcoding, no + /// copies); results are written through a per-thread cached directly into the + /// caller's . + /// + /// With no options the serializer uses , which reproduce the wire conventions of the + /// built-in serializer (see ). User options are honoured as given; the only + /// adjustment is that the library converters are appended when they are missing, on a copy of the options so the + /// instance the caller holds is never mutated (see ). + /// + public sealed class SystemTextJsonRpcSerializer : JsonRpcSerializer + { + private static readonly JsonSerializerOptions _defaultOptions = BuildDefaultOptions(); + + private readonly JsonSerializerOptions _options; + + public SystemTextJsonRpcSerializer() : this(null) { } + + /// + /// Options to use for every conversion, or null for . When the options do not + /// already carry the they are copied and the converters appended to the copy. + /// + public SystemTextJsonRpcSerializer(JsonSerializerOptions options) + { + Options = options; + _options = Prepare(options); + } + + /// The options passed to the constructor (null when the library defaults are in use). + public JsonSerializerOptions Options { get; } + + /// + /// The options actually used for conversions: , the constructor argument when it + /// already contained the library converters, or a copy of it with the converters appended. + /// + public JsonSerializerOptions EffectiveOptions => _options; + + /// + /// The immutable options used when none are supplied: compact output, nulls written, no naming policy, + /// case-insensitive property matching, public fields included, , + /// numbers readable from strings, plus the . + /// + public static JsonSerializerOptions DefaultOptions => _defaultOptions; + + /// A fresh, mutable copy of to customise and pass back to the constructor. + public static JsonSerializerOptions CreateDefaultOptions() => new JsonSerializerOptions(_defaultOptions); + + public override string Name => "stj"; + + /// The envelope reader enforces the same limit as (64 when unset). + public override int MaxDepth => _options.MaxDepth > 0 ? _options.MaxDepth : 64; + + // ------------------------------------------------------------------ read + + public override T Read(ReadOnlySpan utf8Json) + { + return JsonSerializer.Deserialize(utf8Json, TypeInfo.For(_options)); + } + + public override object Read(ReadOnlySpan utf8Json, Type type) + { + // typeof(object) yields a JsonElement: the object model pre/post-process handlers see in JsonRequest.Params. + return JsonSerializer.Deserialize(utf8Json, type, _options); + } + + // ------------------------------------------------------------------ write + + public override void Write(IBufferWriter output, T value) + { + var writer = RentWriter(output); + bool completed = false; + try + { + JsonSerializer.Serialize(writer, value, TypeInfo.For(_options)); + writer.Flush(); + completed = true; + } + finally + { + ReturnWriter(writer, completed); + } + } + + public override void Write(IBufferWriter output, object value, Type type) + { + if (value == null) + { + Utf8Json.WriteNull(output); + return; + } + var writer = RentWriter(output); + bool completed = false; + try + { + JsonSerializer.Serialize(writer, value, type, _options); + writer.Flush(); + completed = true; + } + finally + { + ReturnWriter(writer, completed); + } + } + + // ------------------------------------------------------------------ writer cache + + // One writer per thread, re-targeted with Reset(output) for every call. A nested Write on the same thread + // (a custom converter calling back into the serializer) gets a throwaway writer instead. + // + // A write that throws (a property getter, a converter, the output itself) leaves the partial value pending + // inside the writer. That state is discarded before the writer is released and the writer is pointed at a + // sink that swallows everything: the caller may dispose or rewind its output the moment the exception + // reaches it, so nothing may ever be flushed into that output afterwards, not even by Dispose when the + // cached writer is evicted because the next call uses different options. + [ThreadStatic] private static Utf8JsonWriter _cachedWriter; + [ThreadStatic] private static JsonSerializerOptions _cachedWriterOptions; + [ThreadStatic] private static bool _cachedWriterInUse; + + private Utf8JsonWriter RentWriter(IBufferWriter output) + { + if (_cachedWriterInUse) return new Utf8JsonWriter(output, WriterOptions(_options)); + var writer = _cachedWriter; + if (writer == null || !ReferenceEquals(_cachedWriterOptions, _options)) + { + if (writer != null) Discard(writer); + writer = new Utf8JsonWriter(output, WriterOptions(_options)); + _cachedWriter = writer; + _cachedWriterOptions = _options; + } + else + { + writer.Reset(output); + } + _cachedWriterInUse = true; + return writer; + } + + private static void ReturnWriter(Utf8JsonWriter writer, bool completed) + { + if (ReferenceEquals(writer, _cachedWriter)) + { + try + { + if (!completed) Detach(writer); + } + finally + { + _cachedWriterInUse = false; + } + } + else if (completed) + { + writer.Dispose(); + } + else + { + Discard(writer); + } + } + + /// Drops whatever the writer has pending and points it at the discarding sink, away from the caller's output. + private static void Detach(Utf8JsonWriter writer) + { + writer.Reset(DiscardingBufferWriter.Instance); + } + + /// Disposes a writer without flushing anything into its previous output. + private static void Discard(Utf8JsonWriter writer) + { + Detach(writer); + writer.Dispose(); + } + + /// An that accepts and forgets everything; only ever the target of a detached writer. + private sealed class DiscardingBufferWriter : IBufferWriter + { + public static readonly DiscardingBufferWriter Instance = new DiscardingBufferWriter(); + + [ThreadStatic] private static byte[] _scratch; + + private DiscardingBufferWriter() { } + + public void Advance(int count) { } + + public Memory GetMemory(int sizeHint = 0) => Scratch(sizeHint); + + public Span GetSpan(int sizeHint = 0) => Scratch(sizeHint); + + private static byte[] Scratch(int sizeHint) + { + var scratch = _scratch; + if (scratch == null || scratch.Length < sizeHint) + { + _scratch = scratch = new byte[Math.Max(sizeHint, 4096)]; + } + return scratch; + } + } + + private static JsonWriterOptions WriterOptions(JsonSerializerOptions options) + { + return new JsonWriterOptions + { + Encoder = options.Encoder, + Indented = options.WriteIndented, + IndentCharacter = options.IndentCharacter, + IndentSize = options.IndentSize, + NewLine = options.NewLine, + MaxDepth = options.MaxDepth, + SkipValidation = true + }; + } + + // ------------------------------------------------------------------ options + + private static JsonSerializerOptions Prepare(JsonSerializerOptions options) + { + if (options == null) return _defaultOptions; + if (JsonRpcConverters.ContainsAll(options)) + { + options.MakeReadOnly(populateMissingResolver: true); + return options; + } + var copy = new JsonSerializerOptions(options); + JsonRpcConverters.AddMissing(copy); + copy.MakeReadOnly(populateMissingResolver: true); + return copy; + } + + private static JsonSerializerOptions BuildDefaultOptions() + { + var options = new JsonSerializerOptions + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + PropertyNamingPolicy = null, + DictionaryKeyPolicy = null, + PropertyNameCaseInsensitive = true, + IncludeFields = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + JsonRpcConverters.AddMissing(options); + options.MakeReadOnly(populateMissingResolver: true); + return options; + } + + /// Caches the root JsonTypeInfo for the most recently used options per T (skips the options' own lookup). + private static class TypeInfo + { + private static Entry _last; + + public static JsonTypeInfo For(JsonSerializerOptions options) + { + var entry = _last; + if (entry != null && ReferenceEquals(entry.Options, options)) return entry.Info; + var info = (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + _last = new Entry(options, info); + return info; + } + + private sealed class Entry + { + public readonly JsonSerializerOptions Options; + public readonly JsonTypeInfo Info; + public Entry(JsonSerializerOptions options, JsonTypeInfo info) { Options = options; Info = info; } + } + } + } +} diff --git a/AustinHarris.JsonRpc.sln b/AustinHarris.JsonRpc.sln index 6cf1c28..3433a7c 100644 --- a/AustinHarris.JsonRpc.sln +++ b/AustinHarris.JsonRpc.sln @@ -11,16 +11,30 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AustinHarris.JsonRpcTestN", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestServer_Console", "TestServer_Console\TestServer_Console.csproj", "{31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AustinHarris.JsonRpc.Newtonsoft", "AustinHarris.JsonRpc.Newtonsoft\AustinHarris.JsonRpc.Newtonsoft.csproj", "{3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Json-Rpc", "Json-Rpc", "{BE151132-B0EC-9FBA-04DB-B01036AA7C5A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AustinHarris.JsonRpc.SystemTextJson", "AustinHarris.JsonRpc.SystemTextJson\AustinHarris.JsonRpc.SystemTextJson.csproj", "{041EBECC-308F-4AA0-A91E-5FC9FD897BFB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AustinHarris.JsonRpc.AspNetCore", "AustinHarris.JsonRpc.AspNetCore\AustinHarris.JsonRpc.AspNetCore.csproj", "{58FAA243-3EF2-4125-A847-663002802812}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WasmHost", "samples\WasmHost\WasmHost.csproj", "{293EB57E-ED38-4127-879C-34FD73820127}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|ARM = Debug|ARM Debug|Mixed Platforms = Debug|Mixed Platforms Debug|x86 = Debug|x86 + Debug|x64 = Debug|x64 Release|Any CPU = Release|Any CPU Release|ARM = Release|ARM Release|Mixed Platforms = Release|Mixed Platforms Release|x86 = Release|x86 + Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -30,13 +44,17 @@ Global {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|x86.ActiveCfg = Debug|x86 {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|x86.Build.0 = Debug|x86 + {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|x64.ActiveCfg = Debug|Any CPU + {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Debug|x64.Build.0 = Debug|Any CPU {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Any CPU.ActiveCfg = Release|Any CPU {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Any CPU.Build.0 = Release|Any CPU {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|ARM.ActiveCfg = Release|x86 - {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Mixed Platforms.ActiveCfg = Release|x86 - {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Mixed Platforms.Build.0 = Release|x86 + {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|Mixed Platforms.Build.0 = Release|Any CPU {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|x86.ActiveCfg = Release|x86 {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|x86.Build.0 = Release|x86 + {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|x64.ActiveCfg = Release|Any CPU + {24FC1A2A-0BC3-43A7-9BFE-B628C2C4A307}.Release|x64.Build.0 = Release|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|Any CPU.Build.0 = Debug|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|ARM.ActiveCfg = Debug|Any CPU @@ -45,6 +63,8 @@ Global {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|x86.ActiveCfg = Debug|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|x86.Build.0 = Debug|Any CPU + {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Debug|x64.Build.0 = Debug|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|Any CPU.ActiveCfg = Release|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|Any CPU.Build.0 = Release|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|ARM.ActiveCfg = Release|Any CPU @@ -53,6 +73,8 @@ Global {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|Mixed Platforms.Build.0 = Release|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|x86.ActiveCfg = Release|Any CPU {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|x86.Build.0 = Release|Any CPU + {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|x64.ActiveCfg = Release|Any CPU + {8569B076-5A8B-4D6A-B75D-EF75A390AA5F}.Release|x64.Build.0 = Release|Any CPU {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|Any CPU.Build.0 = Debug|Any CPU {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|ARM.ActiveCfg = Debug|Any CPU @@ -61,6 +83,8 @@ Global {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|x86.ActiveCfg = Debug|x86 {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|x86.Build.0 = Debug|x86 + {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|x64.ActiveCfg = Debug|Any CPU + {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Debug|x64.Build.0 = Debug|Any CPU {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|Any CPU.ActiveCfg = Release|Any CPU {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|Any CPU.Build.0 = Release|Any CPU {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|ARM.ActiveCfg = Release|Any CPU @@ -69,10 +93,95 @@ Global {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|Mixed Platforms.Build.0 = Release|Any CPU {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|x86.ActiveCfg = Release|x86 {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|x86.Build.0 = Release|x86 + {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|x64.ActiveCfg = Release|Any CPU + {31AE59FC-B6F6-4AC7-A7B9-1E07630AE42B}.Release|x64.Build.0 = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|ARM.ActiveCfg = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|ARM.Build.0 = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|x86.ActiveCfg = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|x86.Build.0 = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|x64.ActiveCfg = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Debug|x64.Build.0 = Debug|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|Any CPU.Build.0 = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|ARM.ActiveCfg = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|ARM.Build.0 = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|x86.ActiveCfg = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|x86.Build.0 = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|x64.ActiveCfg = Release|Any CPU + {3A54E977-AD2D-4A23-90B7-B7DDC2CA7980}.Release|x64.Build.0 = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|ARM.ActiveCfg = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|ARM.Build.0 = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|x86.Build.0 = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|x64.ActiveCfg = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Debug|x64.Build.0 = Debug|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|Any CPU.Build.0 = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|ARM.ActiveCfg = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|ARM.Build.0 = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|x86.ActiveCfg = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|x86.Build.0 = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|x64.ActiveCfg = Release|Any CPU + {041EBECC-308F-4AA0-A91E-5FC9FD897BFB}.Release|x64.Build.0 = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|ARM.ActiveCfg = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|ARM.Build.0 = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|x86.ActiveCfg = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|x86.Build.0 = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|x64.ActiveCfg = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Debug|x64.Build.0 = Debug|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|Any CPU.Build.0 = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|ARM.ActiveCfg = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|ARM.Build.0 = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|x86.ActiveCfg = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|x86.Build.0 = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|x64.ActiveCfg = Release|Any CPU + {58FAA243-3EF2-4125-A847-663002802812}.Release|x64.Build.0 = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|Any CPU.Build.0 = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|ARM.ActiveCfg = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|ARM.Build.0 = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|x86.ActiveCfg = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|x86.Build.0 = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|x64.ActiveCfg = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Debug|x64.Build.0 = Debug|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|Any CPU.ActiveCfg = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|Any CPU.Build.0 = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|ARM.ActiveCfg = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|ARM.Build.0 = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|x86.ActiveCfg = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|x86.Build.0 = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|x64.ActiveCfg = Release|Any CPU + {293EB57E-ED38-4127-879C-34FD73820127}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {293EB57E-ED38-4127-879C-34FD73820127} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + EndGlobalSection GlobalSection(TestCaseManagementSettings) = postSolution CategoryFile = AustinHarris.JsonRpc.vsmdi EndGlobalSection diff --git a/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs new file mode 100644 index 0000000..c9cad93 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs @@ -0,0 +1,440 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using System.Threading; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json.Linq; +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.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// A service built by the container (constructor dependency) rather than deriving from JsonRpcService. + public class DiEchoService + { + private readonly ILogger _log; + + public DiEchoService(ILogger log) + { + _log = log; + } + + [JsonRpcMethod("di.echo")] + public string Echo(string s) + { + _log.LogDebug("echo {S}", s); + return s; + } + + [JsonRpcMethod("di.context")] + public string ContextTypeName() + { + return JsonRpcContext.Current().Value?.GetType().Name; + } + } + + /// Kestrel end to end: HTTP endpoint (PipeReader in, BodyWriter out) and JSON-RPC over a raw TCP connection. + [TestFixture] + [NonParallelizable] + public class AspNetCoreTests + { + private WebApplication _app; + private HttpClient _http; + private int _tcpPort; + + [OneTimeSetUp] + public async Task StartHost() + { + _ = new CalculatorService(); // binds the shared test service to the default session (idempotent) + + _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.AddJsonRpc(); + builder.Services.AddJsonRpcService(); + + _app = builder.Build(); + _app.MapJsonRpc("/rpc"); + await _app.StartAsync(); + + var addresses = _app.Services.GetRequiredService().Features.Get().Addresses; + var httpAddress = addresses.First(a => !a.EndsWith(":" + _tcpPort)); + _http = new HttpClient { BaseAddress = new Uri(httpAddress) }; + } + + [OneTimeTearDown] + public async Task StopHost() + { + _http?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private 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) + { + return await _http.PostAsync("/rpc", new StringContent(json, Encoding.UTF8, "application/json")); + } + + [Test] + public async Task Http_SingleRequest_IsAnsweredAsJson() + { + var response = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""IntToInt"",""params"":[5],""id"":1}"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("application/json", response.Content.Headers.ContentType.MediaType); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":5,\"id\":1}", await response.Content.ReadAsStringAsync()); + } + + [Test] + public async Task Http_Notification_Is204() + { + var response = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""Notify"",""params"":[""hi""]}"); + Assert.AreEqual(HttpStatusCode.NoContent, response.StatusCode); + Assert.AreEqual(string.Empty, await response.Content.ReadAsStringAsync()); + } + + [Test] + public async Task Http_Batch_IsAnsweredAsArray() + { + var response = await PostAsync(@"[{""jsonrpc"":""2.0"",""method"":""IntToInt"",""params"":[1],""id"":1},{""jsonrpc"":""2.0"",""method"":""IntToInt"",""params"":[2],""id"":2}]"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1},{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":2}]", await response.Content.ReadAsStringAsync()); + } + + [Test] + public async Task Http_ParseError_IsReportedInBody() + { + var response = await PostAsync("{not json"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + StringAssert.Contains("-32700", await response.Content.ReadAsStringAsync()); + } + + [Test] + public async Task Http_Get_IsMethodNotAllowed() + { + var response = await _http.GetAsync("/rpc"); + Assert.AreEqual(HttpStatusCode.MethodNotAllowed, response.StatusCode); + } + + [Test] + public async Task Http_DiService_IsBoundAndSeesHttpContext() + { + var echo = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""di.echo"",""params"":[""abc""],""id"":7}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"abc\",\"id\":7}", await echo.Content.ReadAsStringAsync()); + + var ctx = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""di.context"",""id"":8}"); + StringAssert.Contains("HttpContext", await ctx.Content.ReadAsStringAsync()); + } + + [Test] + public async Task Http_LargeBody_Is413() + { + var big = "{\"jsonrpc\":\"2.0\",\"method\":\"internal.echo\",\"params\":[\"" + new string('x', 5 * 1024 * 1024) + "\"],\"id\":1}"; + var response = await PostAsync(big); + Assert.AreEqual(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + + [Test] + public async Task Tcp_TwoDocumentsInOneWrite_AreAnsweredInOrder() + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, _tcpPort); + using var stream = client.GetStream(); + var payload = Encoding.UTF8.GetBytes("{\"method\":\"IntToInt\",\"params\":[1],\"id\":1}\n{\"method\":\"IntToInt\",\"params\":[2],\"id\":2}\n"); + await stream.WriteAsync(payload, 0, payload.Length); + + var text = await ReadUntilAsync(stream, "\"id\":2}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":2}", text); + } + + [Test] + public async Task Tcp_DocumentSplitAcrossWrites_IsReassembled() + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, _tcpPort); + using var stream = client.GetStream(); + var first = Encoding.UTF8.GetBytes("{\"method\":\"internal.echo\",\"params\":[\"sp"); + var second = Encoding.UTF8.GetBytes("lit\"],\"id\":3}"); + await stream.WriteAsync(first, 0, first.Length); + await stream.FlushAsync(); + await Task.Delay(100); + await stream.WriteAsync(second, 0, second.Length); + + var text = await ReadUntilAsync(stream, "\"id\":3}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"split\",\"id\":3}", text); + } + + // ------------------------------------------------------------------ DI binding honours JsonRpcOptions.SessionId + + /// A JsonRpcService subclass built by DI: its base constructor binds it to the default session. + public class TenantAutoService : JsonRpcService + { + [JsonRpcMethod("tenant.ping")] + public int Ping() => 7; + } + + /// A plain DI service in the same host, bound to the configured session by the binder. + public class TenantPlainService + { + [JsonRpcMethod("tenant.echo")] + public string Echo(string s) => s; + } + + [Test] + public async Task Http_JsonRpcServiceSubclass_IsBoundToTheConfiguredSession() + { + const string session = "aspnetcore-tenant"; + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(k => k.Listen(IPAddress.Loopback, 0)); + builder.Services.AddJsonRpc(o => o.SessionId = session); + builder.Services.AddJsonRpcService(); + builder.Services.AddJsonRpcService(); + + var app = builder.Build(); + app.MapJsonRpc("/rpc"); + await app.StartAsync(); + try + { + var address = app.Services.GetRequiredService().Features.Get().Addresses.First(); + using var http = new HttpClient { BaseAddress = new Uri(address) }; + + // the subclass answers in the configured session (it used to be skipped by the binder: -32601) + var ping = await http.PostAsync("/rpc", new StringContent(@"{""jsonrpc"":""2.0"",""method"":""tenant.ping"",""id"":1}", Encoding.UTF8, "application/json")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}", await ping.Content.ReadAsStringAsync()); + + var echo = await http.PostAsync("/rpc", new StringContent(@"{""jsonrpc"":""2.0"",""method"":""tenant.echo"",""params"":[""t""],""id"":2}", Encoding.UTF8, "application/json")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"t\",\"id\":2}", await echo.Content.ReadAsStringAsync()); + + // both live in the configured session's method table + var handler = Handler.GetSessionHandler(session); + Assert.IsTrue(handler.MetaData.Services.ContainsKey("tenant.ping")); + Assert.IsTrue(handler.MetaData.Services.ContainsKey("tenant.echo")); + + // the plain service is not exposed in the default session + var defaultResponse = await PostAsync(@"{""jsonrpc"":""2.0"",""method"":""tenant.echo"",""params"":[""t""],""id"":3}"); + StringAssert.Contains("-32601", await defaultResponse.Content.ReadAsStringAsync()); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + Handler.DestroySession(session); + } + } + + private static async Task ReadUntilAsync(NetworkStream stream, string terminator) + { + var sb = new StringBuilder(); + var buffer = new byte[4096]; + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + var readTask = stream.ReadAsync(buffer, 0, buffer.Length); + if (await Task.WhenAny(readTask, Task.Delay(5000)) != readTask) break; + int n = await readTask; + if (n == 0) break; + sb.Append(Encoding.UTF8.GetString(buffer, 0, n)); + if (sb.ToString().EndsWith(terminator)) break; + } + return sb.ToString(); + } + } + + [TestFixture] + [NonParallelizable] + public sealed class AsyncAspNetCoreTests + { + private WebApplication _app; + private HttpClient _http; + private int _port; + private string _session; + private AsyncHostService _service; + + public sealed class AsyncHostService + { + internal TaskCompletionSource Gate; + internal TaskCompletionSource Started; + internal TaskCompletionSource Canceled; + [JsonRpcMethod("fast")] public Task Fast(int value) => Task.FromResult(value); + [JsonRpcMethod("slow")] + public async Task Slow([JsonRpcCancellation] CancellationToken token) + { + Started.TrySetResult(1); + return await Gate.Task.WaitAsync(token); + } + [JsonRpcMethod("disconnect")] + public async Task Disconnect([JsonRpcCancellation] CancellationToken token) + { + Started.TrySetResult(1); + try { await Task.Delay(Timeout.Infinite, token); } + catch (OperationCanceledException) { Canceled.TrySetResult(1); throw; } + return 1; + } + } + + private static TaskCompletionSource NewGate() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + [SetUp] + public async Task StartAsyncHost() + { + _session = "async-host-" + Guid.NewGuid().ToString("N"); + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); _port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(k => + { + k.Listen(IPAddress.Loopback, 0); + k.Listen(IPAddress.Loopback, _port, l => l.UseConnectionHandler()); + }); + _service = new AsyncHostService { Gate = NewGate(), Started = NewGate(), Canceled = NewGate() }; + builder.Services.AddSingleton(_service); + builder.Services.AddJsonRpc(o => { o.SessionId = _session; o.EnableAsyncMethods = true; }); + builder.Services.AddJsonRpcService(); + _app = builder.Build(); + _app.MapJsonRpc("/rpc"); + _app.MapJsonRpc("/empty200", new JsonRpcOptions { SessionId = _session, EnableAsyncMethods = true, NoContentForNotifications = false }); + await _app.StartAsync(); + var address = _app.Services.GetRequiredService().Features.Get().Addresses.First(a => !a.EndsWith(":" + _port)); + _http = new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(5) }; + } + + [TearDown] + public async Task StopAsyncHost() + { + _service.Gate.TrySetResult(7); + _http?.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + Handler.DestroySession(_session); + } + + private Task Post(string json, string path = "/rpc", CancellationToken token = default) => _http.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"), token); + + [Test] + public async Task Http_AwaitsSuspendedDiMethod() + { + var pending = Post("{\"method\":\"slow\",\"id\":1}"); + await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsFalse(pending.IsCompleted); + _service.Gate.SetResult(7); + using var response = await pending; + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual(7, (int)JObject.Parse(await response.Content.ReadAsStringAsync())["result"]); + } + + [TestCase("/rpc", HttpStatusCode.NoContent)] + [TestCase("/empty200", HttpStatusCode.OK)] + public async Task Http_NotificationsKeepStatusAndAreAwaited(string path, HttpStatusCode status) + { + var pending = Post("{\"method\":\"slow\"}", path); + await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsFalse(pending.IsCompleted); + _service.Gate.SetResult(7); + using var response = await pending; + Assert.AreEqual(status, response.StatusCode); + Assert.AreEqual("", await response.Content.ReadAsStringAsync()); + } + + [Test] + public async Task Tcp_FlushesCompletedRepliesBeforeSlowDocument_AndKeepsOrder() + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, _port); + using var stream = client.GetStream(); + await stream.WriteAsync(Encoding.UTF8.GetBytes("{\"method\":\"fast\",\"params\":[1],\"id\":1}{\"method\":\"fast\",\"params\":[2],\"id\":2}{\"method\":\"slow\",\"id\":3}{\"method\":\"fast\",\"params\":[4],\"id\":4}")); + await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var early = await ReadThrough(stream, "\"id\":2}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":2}", early); + Assert.IsFalse(_service.Gate.Task.IsCompleted); + _service.Gate.SetResult(7); + var later = await ReadThrough(stream, "\"id\":4}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":3}{\"jsonrpc\":\"2.0\",\"result\":4,\"id\":4}", later); + } + + [TestCase(false)] [TestCase(true)] + public async Task Disconnect_CancelsInvocation_AndHostRemainsAvailable(bool tcp) + { + if (tcp) + { + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, _port); + await client.GetStream().WriteAsync(Encoding.UTF8.GetBytes("{\"method\":\"disconnect\",\"id\":1}")); + await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + client.Client.LingerState = new LingerOption(true, 0); + client.Close(); + } + else + { + using var cts = new CancellationTokenSource(); + var pending = Post("{\"method\":\"disconnect\",\"id\":1}", token: cts.Token); + await _service.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cts.Cancel(); + try { await pending; Assert.Fail("expected client cancellation"); } catch (OperationCanceledException) { } + } + await _service.Canceled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + using var response = await Post("{\"method\":\"fast\",\"params\":[7],\"id\":2}"); + Assert.AreEqual(7, (int)JObject.Parse(await response.Content.ReadAsStringAsync())["result"]); + } + + public sealed class InvalidAsyncDiService + { + [JsonRpcMethod] public async void Invalid() => await Task.Yield(); + } + + [Test] + public async Task DiRegistration_RejectsAsyncVoid() + { + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.Services.AddJsonRpc(o => o.SessionId = _session); + builder.Services.AddJsonRpcService(); + await using var app = builder.Build(); + Assert.ThrowsAsync(async () => await app.StartAsync()); + } + + private static async Task ReadThrough(NetworkStream stream, string terminator) + { + var text = new StringBuilder(); + var bytes = new byte[4096]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!text.ToString().EndsWith(terminator, StringComparison.Ordinal)) + { + int count = await stream.ReadAsync(bytes.AsMemory(), cts.Token); + if (count == 0) break; + text.Append(Encoding.UTF8.GetString(bytes, 0, count)); + } + return text.ToString(); + } + } + +} diff --git a/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs new file mode 100644 index 0000000..82f9173 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs @@ -0,0 +1,736 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Sources; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Invocation; +using AustinHarris.JsonRpc.Serialization; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + [TestFixture] + public sealed class AsyncInvocationTests + { + private string _session; + private static readonly string[] Serializers = { "jsmn", "newtonsoft", "stj" }; + [SetUp] public void SetUp() => _session = "async-" + Guid.NewGuid().ToString("N"); + [TearDown] public void TearDown() => Handler.DestroySession(_session); + private void Bind(string name, Delegate method, RpcContextFlow flow = RpcContextFlow.Flow) => ServiceBinder.BindMethod(_session, name, method, contextFlow: flow); + private Task Run(string json, JsonRpcSerializer serializer = null, object context = null, CancellationToken token = default) => JsonRpcProcessor.ProcessAsync(_session, json, context, serializer, token); + private static TaskCompletionSource Gate() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private static string Request(string method, string parameters = null, string id = "1") => "{\"method\":\"" + method + "\"" + (parameters == null ? "" : ",\"params\":" + parameters) + (id == null ? "" : ",\"id\":" + id) + "}"; + private static void Error(string json, int code) => Assert.AreEqual(code, (int)JObject.Parse(json)["error"]["code"], json); + + private static IEnumerable Shapes() + { + foreach (var serializer in Serializers) + foreach (var shape in new[] { "task", "taskResult", "valueTask", "valueTaskResult" }) + for (int mode = 0; mode < 5; mode++) + foreach (bool hooks in new[] { false, true }) + yield return new TestCaseData(serializer, shape, mode, hooks); + } + + private static Task TaskResult(int mode) + { + switch (mode) + { + case 1: throw new InvalidOperationException("synchronous throw"); + case 2: return Task.FromException(new InvalidOperationException("completed fault")); + case 3: return Task.FromCanceled(new CancellationToken(true)); + case 4: return YieldResult(); + default: return Task.FromResult(7); + } + } + private static async Task YieldResult() { await Task.Yield(); return 7; } + private static Task PlainTask(int mode) => TaskResult(mode); + private static ValueTask ValueTaskResult(int mode) => mode == 0 ? new ValueTask(7) : new ValueTask(TaskResult(mode)); + private static ValueTask PlainValueTask(int mode) => mode == 0 ? default : new ValueTask(TaskResult(mode)); + + [TestCaseSource(nameof(Shapes))] + public async Task AllAwaitableShapes(string serializerName, string shape, int mode, bool hooks) + { + Delegate method = shape == "task" ? (Delegate)new Func(PlainTask) + : shape == "taskResult" ? new Func>(TaskResult) + : shape == "valueTask" ? new Func(PlainValueTask) + : new Func>(ValueTaskResult); + Bind("run", method); + int pre = 0, post = 0, errors = 0; + if (hooks) + { + Handler.GetSessionHandler(_session).SetPreProcessHandler((r, c) => { pre++; return null; }); + Handler.GetSessionHandler(_session).SetPostProcessHandler((r, response, c) => { post++; return null; }); + Config.SetErrorHandler(_session, (r, e) => { errors++; return e; }); + } + var json = await Run(Request("run", "[" + mode + "]"), SerializerCatalog.Create(serializerName)); + if (mode == 0 || mode == 4) + { + var result = JObject.Parse(json)["result"]; + if (shape.EndsWith("Result")) Assert.AreEqual(7, (int)result); + else Assert.AreEqual(JTokenType.Null, result.Type); + } + else Error(json, -32603); + if (hooks) { Assert.AreEqual(1, pre); Assert.AreEqual(1, post); Assert.AreEqual(mode > 0 && mode < 4 ? 1 : 0, errors); } + } + + private sealed class OnceSource : IValueTaskSource + { + private ManualResetValueTaskSourceCore _core; + internal int Consumed; + internal OnceSource() { _core.RunContinuationsAsynchronously = true; } + internal ValueTask Value => new ValueTask(this, _core.Version); + internal void Complete() => _core.SetResult(7); + public int GetResult(short token) { if (Interlocked.Increment(ref Consumed) != 1) throw new InvalidOperationException("consumed twice"); return _core.GetResult(token); } + public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token); + public void OnCompleted(Action continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags) => _core.OnCompleted(continuation, state, token, flags); + } + + [TestCase("jsmn", false)] [TestCase("jsmn", true)] + [TestCase("newtonsoft", false)] [TestCase("newtonsoft", true)] + [TestCase("stj", false)] [TestCase("stj", true)] + public async Task SourceBackedValueTask_IsConsumedOnce(string name, bool suspend) + { + var source = new OnceSource(); + Bind("run", new Func>(() => source.Value)); + if (!suspend) source.Complete(); + var pending = Run(Request("run"), SerializerCatalog.Create(name)); + if (suspend) { Assert.IsFalse(pending.IsCompleted); await Task.Run(source.Complete); } + Assert.AreEqual(7, (int)JObject.Parse(await pending)["result"]); + Assert.AreEqual(1, source.Consumed); + } + + [TestCaseSource(nameof(Serializers))] + public async Task NullTask_AndSynchronousRejection(string name) + { + int invoked = 0; + Bind("null", new Func>(() => { invoked++; return null; })); + var s = SerializerCatalog.Create(name); + var sync = JsonRpcProcessor.ProcessSync(_session, Request("null"), null, s); + Error(sync, -32603); + StringAssert.Contains("is asynchronous; process the request with JsonRpcProcessor.ProcessAsync", sync); + Assert.AreEqual(0, invoked); + var json = await Run(Request("null"), s); + Error(json, -32603); + StringAssert.Contains("returned a null Task", json); + Assert.AreEqual(1, invoked); + } + + private sealed class AutoAsyncService : JsonRpcService + { + internal AutoAsyncService(string session) : base(session) { } + [JsonRpcMethod("run")] public Task Run() => Task.FromResult(7); + } + + [TestCase(0)] [TestCase(1)] [TestCase(2)] + public async Task CompatibilityRegistrationSurfaces_SupportAsyncMethods(int surface) + { + var types = new Dictionary { ["returns"] = typeof(Task) }; + var method = new Func>(() => Task.FromResult(7)); + var handler = Handler.GetSessionHandler(_session); + if (surface == 0) handler.MetaData.Services["run"] = new SMDService("POST", "JSON-RPC-2.0", types, new Dictionary(), method); + else if (surface == 1) handler.RegisterFuction("run", types, null, method); + else _ = new AutoAsyncService(_session); + Assert.AreEqual(7, (int)JObject.Parse(await Run(Request("run")))["result"]); + Assert.AreEqual(typeof(int), handler.MetaData.Services["run"].Method.ResultType); + var rejected = JsonRpcProcessor.ProcessSync(_session, Request("run"), null); + Error(rejected, -32603); + StringAssert.Contains("Method 'run' is asynchronous", rejected); + } + + private sealed class InvalidService + { + [JsonRpcMethod] public async void Invalid() => await Task.Yield(); + } + private sealed class InvalidAutoService : JsonRpcService + { + internal InvalidAutoService(string session) : base(session) { } + [JsonRpcMethod] public async void Invalid() => await Task.Yield(); + } + private static Task MissingToken(CancellationToken token) => Task.FromResult(1); + private static Task RefParameter(ref int value) => Task.FromResult(value); + private static Task RefError(ref JsonRpcException error) => Task.FromResult(1); + private delegate Task RefDelegate(ref int value); + private delegate Task RefErrorDelegate(ref JsonRpcException error); + private readonly struct CustomAwaitable { public TaskAwaiter GetAwaiter() => Task.CompletedTask.GetAwaiter(); } + + [TestCase(0)] [TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)] [TestCase(6)] + public void AsyncVoid_IsRejectedOnEverySurface(int surface) + { + Action invalid = async () => await Task.Yield(); + var types = new Dictionary { ["returns"] = typeof(void) }; + TestDelegate registration = surface switch + { + 0 => () => RpcMethod.FromMethod("invalid", typeof(InvalidService).GetMethod("Invalid"), new InvalidService()), + 1 => () => RpcMethod.FromDelegate("invalid", invalid), + 2 => () => ServiceBinder.BindService(_session, new InvalidService()), + 3 => () => Bind("invalid", invalid), + 4 => () => new InvalidAutoService(_session), + 5 => () => new SMDService("POST", "JSON-RPC-2.0", types, new Dictionary(), invalid), + _ => () => Handler.GetSessionHandler(_session).RegisterFuction("invalid", types, null, invalid) + }; + StringAssert.Contains("async void", Assert.Throws(registration).Message); + } + + [Test] + public void UnsupportedSignatures_AreRejected() + { + StringAssert.Contains("[JsonRpcCancellation]", Assert.Throws(() => Bind("token", new Func>(MissingToken))).Message); + StringAssert.Contains("by-ref", Assert.Throws(() => Bind("ref", new RefDelegate(RefParameter))).Message); + Assert.Throws(() => Bind("refError", new RefErrorDelegate(RefError))); + Assert.Throws(() => Bind("custom", new Func(() => default))); + Assert.Throws(() => Bind("nested", new Func>(() => Task.FromResult(Task.CompletedTask)))); + } + + private sealed class TokenService + { + internal CancellationToken Seen; + internal TaskCompletionSource Wait; + [JsonRpcMethod("token")] + public Task Run(int value, [JsonRpcCancellation] CancellationToken token, int more = 2) + { + Seen = token; + return Wait?.Task ?? Task.FromResult(value + more); + } + } + + [TestCaseSource(nameof(Serializers))] + public async Task CancellationParameter_IsInjectedAndExcludedFromSmd(string name) + { + var service = new TokenService(); + ServiceBinder.BindService(_session, service); + using var cts = new CancellationTokenSource(); + var s = SerializerCatalog.Create(name); + Assert.AreEqual(5, (int)JObject.Parse(await Run(Request("token", "{\"value\":3}"), s, token: cts.Token))["result"]); + Assert.AreEqual(cts.Token, service.Seen); + var metadata = Handler.GetSessionHandler(_session).MetaData.Services["token"]; + CollectionAssert.AreEqual(new[] { "value", "more" }, metadata.parameters.Select(p => p.Name)); + Assert.AreEqual(typeof(int), metadata.Method.ResultType); + Assert.AreEqual("int32", SMD.Types[metadata.returns.Type]["__name"]); + Error(await Run(Request("token", "{\"value\":3,\"token\":null}"), s), -32602); + } + + [TestCaseSource(nameof(Serializers))] + public async Task Flow_ContextIdAndErrorSurviveAwaits(string name) + { + var s = SerializerCatalog.Create(name); + object context = new object(); + JsonRpcRequestId captured = default; + Bind("flow", new Func>(async () => + { + Assert.AreSame(context, Handler.RpcContext()); + Assert.AreSame(context, JsonRpcContext.Current().Value); + captured = Handler.RpcRequestId(); + Handler.RpcSetException(new JsonRpcException(-32010, "before", null)); + await Task.Yield(); + await Task.Run(() => { Assert.AreSame(context, Handler.RpcContext()); Assert.AreEqual(captured, Handler.RpcRequestId()); }); + Assert.AreSame(context, JsonRpcContext.Current().Value); + Assert.AreEqual(captured, Handler.RpcRequestId()); + Handler.RpcSetException(new JsonRpcException(-32011, "after", null)); + return 7; + })); + foreach (var id in new[] { "\"a\\\"b\\u00e9\\n\"", "123456789012345678901234567890" }) + { + var json = await Run(Request("flow", id: id), s, context); + Error(json, -32011); + StringAssert.EndsWith("\"id\":" + id + "}", json); + Assert.AreEqual(JsonRpcRequestId.FromRaw(Encoding.UTF8.GetBytes(id)), captured); + } + Assert.IsNull(Handler.RpcContext()); + Assert.IsTrue(Handler.RpcRequestId().IsAbsent); + } + + [Test] + public async Task None_SnapshotSurvives_AndAmbientDoesNotFlow() + { + var gate = Gate(); + object context = new object(); + JsonRpcRequestId snapshot = default; + Bind("none", new Func>(async () => + { + snapshot = Handler.RpcRequestId(); + var savedContext = Handler.RpcContext(); + Assert.AreSame(context, savedContext); + await gate.Task.ConfigureAwait(false); + Assert.IsNull(Handler.RpcContext()); + Assert.IsNull(JsonRpcContext.Current().Value); + Assert.IsTrue(Handler.RpcRequestId().IsAbsent); + Assert.AreSame(context, savedContext); + return (int)(long)snapshot.ToObject(); + }), RpcContextFlow.None); + var pending = Run(Request("none", id: "7"), context: context); + Assert.IsFalse(pending.IsCompleted); + new Thread(() => gate.SetResult(0)).Start(); + Assert.AreEqual(7, (int)JObject.Parse(await pending)["result"]); + } + + [TestCaseSource(nameof(Serializers))] + public async Task NestedDispatch_RestoresParentsBeforeAndAfterAwait(string name) + { + var s = SerializerCatalog.Create(name); + Bind("sync", new Func(() => (string)Handler.RpcContext() + "/" + Handler.RpcRequestId())); + Bind("child", new Func>(async () => { await Task.Yield(); Handler.RpcSetException(new JsonRpcException(-32022, "child", null)); return 0; })); + Bind("parent", new Func>(async () => + { + Handler.RpcSetException(new JsonRpcException(-32021, "parent", null)); + foreach (var phase in new[] { 0, 1 }) + { + if (phase == 1) await Task.Yield(); + var child = JsonRpcProcessor.ProcessSync(_session, Request("sync", id: "2"), "child", s); + Assert.AreEqual("child/2", (string)JObject.Parse(child)["result"]); + Assert.AreEqual("parent", Handler.RpcContext()); + Assert.AreEqual(JsonRpcRequestId.FromInt64(1), Handler.RpcRequestId()); + } + Error(await Run(Request("child", id: "3"), s, "child"), -32022); + Assert.AreEqual("parent", Handler.RpcContext()); + Assert.AreEqual(JsonRpcRequestId.FromInt64(1), Handler.RpcRequestId()); + return 7; + })); + Error(await Run(Request("parent"), s, "parent"), -32021); + } + + [Test] + public async Task FlowFrame_IsClearedForEscapedExecutionContext() + { + var gate = Gate(); + Task escaped = null; + Bind("run", new Func>(() => + { + escaped = Task.Run(async () => { await gate.Task; Assert.IsNull(Handler.RpcContext()); Assert.IsTrue(Handler.RpcRequestId().IsAbsent); }); + return Task.FromResult(7); + })); + await Run(Request("run"), context: new object()); + gate.SetResult(0); + await escaped; + } + + [TestCaseSource(nameof(Serializers))] + public async Task Batch_IsSequential_AwaitsNotifications_AndIsolatesFaults(string name) + { + var order = new List(); + var gate = Gate(); + Bind("first", new Func(() => { order.Add(1); return 1; })); + Bind("slow", new Func>(async () => { order.Add(2); await gate.Task; order.Add(3); return 2; })); + Bind("fault", new Func>(() => { order.Add(4); return Task.FromException(new Exception("fault")); })); + Bind("last", new Func>(() => { order.Add(5); return new ValueTask(3); })); + var pending = Run("[" + Request("first") + "," + Request("slow", id: null) + "," + Request("fault", id: "2") + "," + Request("last", id: "3") + "]", SerializerCatalog.Create(name)); + CollectionAssert.AreEqual(new[] { 1, 2 }, order); + Assert.IsFalse(pending.IsCompleted); + gate.SetResult(0); + var response = JArray.Parse(await pending); + CollectionAssert.AreEqual(new[] { 1, 2, 3, 4, 5 }, order); + CollectionAssert.AreEqual(new[] { 1, 2, 3 }, response.Select(r => (int)r["id"])); + Assert.AreEqual(-32603, (int)response[1]["error"]["code"]); + Assert.AreEqual(3, (int)response[2]["result"]); + Assert.AreEqual("", await Run("[" + Request("slow", id: null) + "," + Request("fault", id: null) + "]")); + } + + [Test] + public void CompletedDocuments_ReturnCachedTask_IncludingSyncBatches() + { + Bind("sync", new Func(() => 7)); + Bind("async", new Func>(() => Task.FromResult(7)), RpcContextFlow.None); + using var output = new PooledByteBufferWriter(); + foreach (var json in new[] { Request("sync"), Request("async"), "[" + Request("sync") + "," + Request("sync", id: null) + "]" }) + { + output.Clear(); + var task = JsonRpcProcessor.ProcessAsync(_session, (ReadOnlyMemory)Encoding.UTF8.GetBytes(json), output); + Assert.IsTrue(task.IsCompleted); + Assert.AreSame(Task.CompletedTask, task); + StringAssert.Contains("\"result\":7", output.ToString()); + } + } + + [TestCaseSource(nameof(Serializers))] + public async Task Hooks_ReplaceMethodParamsId_AndRunAfterCompletion(string name) + { + var gate = Gate(); + var calls = new List(); + Bind("target", new Func>(async value => + { + calls.Add("invoke"); + Assert.AreEqual("changed", Handler.RpcRequestId().GetString()); + await gate.Task; + Assert.AreEqual("changed", Handler.RpcRequestId().GetString()); + return value; + })); + var handler = Handler.GetSessionHandler(_session); + handler.SetPreProcessHandler((r, c) => { calls.Add("pre"); r.Method = "target"; r.Params = new object[] { 9 }; r.Id = "changed"; return null; }); + handler.SetPostProcessHandler((r, response, c) => { calls.Add("post"); Assert.AreEqual(9, response.Result); return null; }); + var pending = Run(Request("missing"), SerializerCatalog.Create(name)); + CollectionAssert.AreEqual(new[] { "pre", "invoke" }, calls); + gate.SetResult(0); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":9,\"id\":\"changed\"}", await pending); + CollectionAssert.AreEqual(new[] { "pre", "invoke", "post" }, calls); + } + + [TestCaseSource(nameof(Serializers))] + public async Task ConversionFailures_AreAttributedAfterSuspension(string name) + { + Bind("convert", new Func>(async value => { await Task.Yield(); throw new FormatException("method failure"); })); + var s = SerializerCatalog.Create(name); + Error(await Run(Request("convert", "[1]"), s), -32603); + var error = JObject.Parse(await Run(Request("convert", "[\"bad\"]"), s))["error"]; + Assert.AreEqual(-32602, (int)error["code"]); + Assert.AreEqual("value", (string)error["data"]["parameter"]); + Assert.AreEqual("conversion", (string)error["data"]["reason"]); + } + + [TestCase(0)] [TestCase(1)] [TestCase(2)] + public async Task Cancellation_DiscardsDocument_AndWaitsForTerminalState(int phase) + { + using var cts = new CancellationTokenSource(); + var service = new TokenService { Wait = Gate() }; + ServiceBinder.BindService(_session, service); + int last = 0; + Bind("cancel", new Func(() => { cts.Cancel(); return 1; })); + Bind("last", new Func(() => ++last)); + using var output = new PooledByteBufferWriter(); + output.Write((byte)'!'); + string json = phase == 1 ? "[" + Request("cancel") + "," + Request("last") + "]" : Request("token", "[1]"); + if (phase == 0) cts.Cancel(); + var pending = JsonRpcProcessor.ProcessAsync(_session, (ReadOnlyMemory)Encoding.UTF8.GetBytes(json), output, cancellationToken: cts.Token); + if (phase == 2) + { + Assert.AreEqual(cts.Token, service.Seen); + cts.Cancel(); + Assert.IsFalse(pending.IsCompleted, "the ignored cancellation cannot return borrowed storage early"); + service.Wait.SetResult(7); + } + try { await pending; Assert.Fail("expected canceled task"); } + catch (OperationCanceledException) { } + Assert.IsTrue(pending.IsCanceled); + Assert.AreEqual("!", output.ToString()); + Assert.AreEqual(0, last); + } + + [Test] + public async Task MethodOwnedCancellation_IsAnOrdinaryError() + { + Bind("run", new Func>(async () => { await Task.Yield(); throw new OperationCanceledException("method-owned"); })); + var result = await Run(Request("run")); + Error(result, -32603); + StringAssert.Contains("method-owned", result); + } + + [TestCase(false)] [TestCase(true)] + public async Task Aggregates_OnlySingleInnerIsUnwrapped(bool multiple) + { + var authored = new JsonRpcException(-32040, "authored", null); + Bind("run", new Func>(async () => { await Task.Yield(); throw multiple ? new AggregateException(authored, new Exception("second")) : new AggregateException(authored); })); + Error(await Run(Request("run")), multiple ? -32603 : -32040); + } + + private sealed class Segment : ReadOnlySequenceSegment + { + internal Segment(ReadOnlyMemory memory) { Memory = memory; } + internal Segment Append(ReadOnlyMemory memory) { var next = new Segment(memory) { RunningIndex = RunningIndex + Memory.Length }; Next = next; return next; } + } + + [TestCase(0)] [TestCase(1)] [TestCase(2)] + public async Task InputOwnership_AndCrossThreadCompletion(int overload) + { + var gate = Gate(); + JsonRpcRequestId snapshot = default; + Bind("run", new Func>(async () => { snapshot = Handler.RpcRequestId(); await gate.Task; Assert.AreEqual(snapshot, Handler.RpcRequestId()); return 7; })); + byte[] bytes = Encoding.UTF8.GetBytes(Request("run", id: "\"owned\"")); + using var output = new PooledByteBufferWriter(); + Task pending; + if (overload == 0) pending = JsonRpcProcessor.ProcessAsync(_session, new ReadOnlyMemory(bytes), output); + else if (overload == 1) pending = JsonRpcProcessor.ProcessAsync(_session, new ReadOnlySpan(bytes), output); + else + { + var first = new Segment(bytes.AsMemory(0, 5)); + var last = first.Append(bytes.AsMemory(5)); + pending = JsonRpcProcessor.ProcessAsync(_session, new ReadOnlySequence(first, 0, last, last.Memory.Length), output); + } + Assert.IsFalse(pending.IsCompleted); + if (overload != 0) Array.Fill(bytes, (byte)'?'); + new Thread(() => gate.SetResult(7)).Start(); + await pending; + Array.Fill(bytes, (byte)'!'); + Assert.AreEqual("owned", snapshot.GetString()); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":\"owned\"}", output.ToString()); + await Run(Request("run", id: "2")); + StringAssert.Contains("\"id\":\"owned\"", output.ToString()); + } + + private sealed class BrokenResult { public int Value => throw new InvalidOperationException("writer failed"); } + [TestCaseSource(nameof(Serializers))] + public async Task ResultWriteFailure_RewindsAfterAwait(string name) + { + var gate = Gate(); + Bind("broken", new Func>(async () => { await gate.Task; return new BrokenResult(); })); + Bind("good", new Func(() => 7)); + var pending = Run("[" + Request("broken") + "," + Request("good", id: "2") + "]", SerializerCatalog.Create(name)); + Assert.IsFalse(pending.IsCompleted); + gate.SetResult(0); + var result = JArray.Parse(await pending); + Assert.AreEqual(-32603, (int)result[0]["error"]["code"]); + Assert.AreEqual(7, (int)result[1]["result"]); + } + + [TestCaseSource(nameof(Serializers))] + public async Task PreHookAuthoredException_IsConsumedByAsyncInvocation(string name) + { + var gate = Gate(); + Bind("run", new Func>(async () => { await gate.Task; return 7; })); + var handler = Handler.GetSessionHandler(_session); + handler.SetPreProcessHandler((r, c) => { Handler.RpcSetException(new JsonRpcException(-32070, "pre error", null)); return null; }); + handler.SetPostProcessHandler((r, response, c) => + { + Assert.AreEqual(-32070, response.Error.code); + Assert.IsNull(Handler.RpcGetAndRemoveRpcException()); + return null; + }); + var pending = Run(Request("run"), SerializerCatalog.Create(name)); + gate.SetResult(0); + Error(await pending, -32070); + } + + private sealed class AttributeNoneService + { + [JsonRpcMethod("none", ContextFlow = RpcContextFlow.None)] + public Task Get() => Task.FromResult(7); + } + + [Test] + public void AttributeSelectsNoneAtRegistration() + { + ServiceBinder.BindService(_session, new AttributeNoneService()); + Assert.AreEqual(RpcContextFlow.None, Handler.GetSessionHandler(_session).MetaData.Services["none"].Method.ContextFlow); + } + + [Test] + public async Task DefaultSessionStringConvenience() + { + string name = _session + "-default"; + ServiceBinder.BindMethod(name, new Func>(() => new ValueTask(7))); + try { Assert.AreEqual(7, (int)JObject.Parse(await JsonRpcProcessor.ProcessAsync(Request(name)))["result"]); } + finally { ServiceBinder.UnbindMethod(name); } + } + + private sealed class TrackingSerializer : JsonRpcSerializer + { + private readonly JsonRpcSerializer _inner; + internal readonly List Readers = new List(); + internal Action OnWrite; + internal TrackingSerializer(JsonRpcSerializer inner) { _inner = inner; } + public override string Name => "tracking"; + public override JsonRpcRequestReader CreateReader() { var reader = new TrackingReader(_inner.CreateReader()); Readers.Add(reader); return reader; } + public override T Read(ReadOnlySpan bytes) => _inner.Read(bytes); + public override object Read(ReadOnlySpan bytes, Type type) => _inner.Read(bytes, type); + public override void Write(IBufferWriter output, T value) { OnWrite?.Invoke(); _inner.Write(output, value); } + public override void Write(IBufferWriter output, object value, Type type) { OnWrite?.Invoke(); _inner.Write(output, value, type); } + } + + private sealed class TrackingReader : JsonRpcRequestReader + { + private readonly JsonRpcRequestReader _inner; + internal bool Active; + internal int Reads; + internal int Releases; + private readonly byte[] _idBuffer = new byte[128]; + internal TrackingReader(JsonRpcRequestReader inner) { _inner = inner; } + private JsonRpcRequestReader Current { get { Assert.IsTrue(Active, "reader lease must still be active"); return _inner; } } + public override bool TryParse(ReadOnlyMemory bytes, out string error) { Assert.IsFalse(Active); Active = true; return _inner.TryParse(bytes, out error); } + public override ReadOnlyMemory Document => Current.Document; + public override bool IsBatch => Current.IsBatch; + public override int Count => Current.Count; + public override bool Select(int index) => Current.Select(index); + public override bool HasMethod => Current.HasMethod; + public override string Method => Current.Method; + public override ReadOnlySpan MethodUtf8 { get { Array.Fill(_idBuffer, (byte)'?'); return Current.MethodUtf8; } } + public override JsonRpcIdKind IdKind => Current.IdKind; + public override ReadOnlySpan IdRaw { get { var id = Current.IdRaw; id.CopyTo(_idBuffer); return _idBuffer.AsSpan(0, id.Length); } } + public override object IdValue => Current.IdValue; + public override JsonRpcParamsKind ParamsKind => Current.ParamsKind; + public override int ParamCount => Current.ParamCount; + public override ReadOnlySpan ParamNameUtf8(int i) => Current.ParamNameUtf8(i); + public override ReadOnlySpan ParamRaw(int i) => Current.ParamRaw(i); + public override bool ParamIsNull(int i) => Current.ParamIsNull(i); + public override T ReadParam(int i) { Reads++; return Current.ReadParam(i); } + public override object ReadParam(int i, Type type) { Reads++; return Current.ReadParam(i, type); } + public override object ParamsValue => Current.ParamsValue; + public override void Release() { Assert.IsTrue(Active); Active = false; Releases++; _inner.Release(); } + } + + [TestCaseSource(nameof(Serializers))] + public async Task CustomReader_AndRentedMapRemainLeasedThroughFailure(string name) + { + var gate = Gate(); + Bind("read", new Func>(async (a, b) => { await gate.Task; throw new FormatException("after binding"); })); + var serializer = new TrackingSerializer(SerializerCatalog.Create(name)); + var pending = Run(Request("read", "{\"b\":2,\"a\":1}", "\"escaped\\\"id\""), serializer); + var reader = serializer.Readers.Single(); + Assert.IsTrue(reader.Active); + Assert.AreEqual(0, reader.Releases); + gate.SetResult(0); + var json = await pending; + Error(json, -32603); + StringAssert.EndsWith("\"id\":\"escaped\\\"id\"}", json); + Assert.GreaterOrEqual(reader.Reads, 4, "binding re-read after suspended conversion-shaped failure"); + Assert.IsFalse(reader.Active); + Assert.AreEqual(1, reader.Releases); + using var cts = new CancellationTokenSource(); cts.Cancel(); + try { await Run(Request("read"), serializer, token: cts.Token); } catch (OperationCanceledException) { } + Assert.AreEqual(1, reader.Releases, "pre-cancellation must not release an idle cached reader again"); + } + + [Test] + public async Task CancellationDuringResultWrite_DiscardsStaging() + { + using var cts = new CancellationTokenSource(); + var serializer = new TrackingSerializer(SerializerCatalog.Create("jsmn")) { OnWrite = cts.Cancel }; + Bind("run", new Func>(async () => { await Task.Yield(); return 7; })); + using var output = new PooledByteBufferWriter(); + var pending = JsonRpcProcessor.ProcessAsync(_session, (ReadOnlyMemory)Encoding.UTF8.GetBytes(Request("run")), output, serializer: serializer, cancellationToken: cts.Token); + try { await pending; Assert.Fail("expected cancellation"); } catch (OperationCanceledException) { } + Assert.IsTrue(pending.IsCanceled); + Assert.AreEqual(0, output.WrittenCount); + } + + [Test] + public async Task NoneNestedInFlow_DoesNotInheritParentAmbient() + { + var gate = Gate(); + Bind("none", new Func>(async () => + { + Assert.AreEqual("child", Handler.RpcContext()); + await gate.Task; + Assert.IsNull(Handler.RpcContext()); + Assert.IsTrue(Handler.RpcRequestId().IsAbsent); + return 7; + }), RpcContextFlow.None); + Bind("flow", new Func>(async () => + { + var child = Run(Request("none", id: "2"), context: "child"); + Assert.AreEqual("parent", Handler.RpcContext()); + gate.SetResult(1); + Assert.AreEqual(7, (int)JObject.Parse(await child)["result"]); + Assert.AreEqual("parent", Handler.RpcContext()); + return 7; + })); + Assert.AreEqual(7, (int)JObject.Parse(await Run(Request("flow"), context: "parent"))["result"]); + } + + [Test] + public async Task AsyncStartedInsideSync_DoesNotCaptureReusableParentFrame() + { + var gate = Gate(); + Task child = null; + Bind("child", new Func>(async () => + { + Assert.AreEqual("child", Handler.RpcContext()); + await gate.Task; + Assert.AreEqual("child", Handler.RpcContext()); + Assert.AreEqual(JsonRpcRequestId.FromInt64(2), Handler.RpcRequestId()); + return 7; + })); + Bind("parent", new Func(() => + { + child = Run(Request("child", id: "2"), context: "child"); + Assert.AreEqual("parent", Handler.RpcContext()); + Handler.RpcSetException(new JsonRpcException(-32050, "parent", null)); + return 1; + })); + Error(JsonRpcProcessor.ProcessSync(_session, Request("parent"), "parent"), -32050); + gate.SetResult(0); + Assert.AreEqual(7, (int)JObject.Parse(await child)["result"]); + } + + [Test] + public async Task ConcurrentDocuments_OwnIndependentFramesAndScratch() + { + var gate = Gate(); + Bind("run", new Func>(async () => + { + int value = (int)Handler.RpcContext(); + var id = Handler.RpcRequestId(); + await gate.Task; + Assert.AreEqual(value, Handler.RpcContext()); + Assert.AreEqual(id, Handler.RpcRequestId()); + return value; + })); + var tasks = Enumerable.Range(0, 80).Select(i => Run(Request("run", id: i.ToString()), context: i)).ToArray(); + Assert.IsTrue(tasks.All(t => !t.IsCompleted)); + gate.SetResult(0); + var results = await Task.WhenAll(tasks); + for (int i = 0; i < results.Length; i++) + { + var response = JObject.Parse(results[i]); + Assert.AreEqual(i, (int)response["id"]); + Assert.AreEqual(i, (int)response["result"]); + } + } + + private static int SyncToken([JsonRpcCancellation] CancellationToken token) => token.CanBeCanceled ? 7 : 0; + private delegate int SyncRefTokenDelegate(CancellationToken token, ref JsonRpcException error); + private static int SyncRefToken([JsonRpcCancellation] CancellationToken token, ref JsonRpcException error) + { + if (token.CanBeCanceled) error = new JsonRpcException(-32060, "token injected", null); + return 0; + } + + [Test] + public async Task SynchronousMethod_CanRequestInjectedCancellation() + { + Bind("token", new Func(SyncToken)); + Bind("ref", new SyncRefTokenDelegate(SyncRefToken)); + using var cts = new CancellationTokenSource(); + Assert.AreEqual(7, (int)JObject.Parse(await Run(Request("token"), token: cts.Token))["result"]); + Assert.AreEqual(0, (int)JObject.Parse(JsonRpcProcessor.ProcessSync(_session, Request("token"), null))["result"]); + Error(await Run(Request("ref"), token: cts.Token), -32060); + Assert.AreEqual(0, Handler.GetSessionHandler(_session).MetaData.Services["token"].parameters.Length); + } + + private static IEnumerable PrimitiveResults() + { + foreach (bool suspend in new[] { false, true }) + foreach (var type in new[] { typeof(int), typeof(long), typeof(double), typeof(float), typeof(bool), typeof(decimal), typeof(string), typeof(int?), typeof(long?), typeof(double?), typeof(float?), typeof(bool?), typeof(decimal?), typeof(Guid) }) + yield return new TestCaseData(type, suspend); + } + private static Task ResultTask(T value, bool suspend) => suspend ? Delayed(value) : Task.FromResult(value); + private static async Task Delayed(T value) { await Task.Yield(); return value; } + + [TestCaseSource(nameof(PrimitiveResults))] + public async Task TypedWriters_HandlePrimitiveNullableAndFallbackResults(Type type, bool suspend) + { + object value = type == typeof(string) ? "text" : type == typeof(Guid) ? Guid.Parse("00112233-4455-6677-8899-aabbccddeeff") : Convert.ChangeType(1, Nullable.GetUnderlyingType(type) ?? type); + var factory = GetType().GetMethod(nameof(MakeResultDelegate), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static).MakeGenericMethod(type); + Bind("run", (Delegate)factory.Invoke(null, new[] { value, (object)suspend })); + var result = JObject.Parse(await Run(Request("run")))["result"]; + Assert.IsNotNull(result); + Assert.IsTrue(JToken.DeepEquals(JToken.Parse(SerializerCatalog.Create("jsmn").Serialize(value, type)), result)); + } + private static Delegate MakeResultDelegate(object value, bool suspend) => new Func>(() => ResultTask((T)value, suspend)); + + [Test] + public void CompletedNone_AllocatesZero_AndFlowIsMeasured() + { + var cached = Task.FromResult(7); + Bind("none", new Func>(() => cached), RpcContextFlow.None); + Bind("flow", new Func>(() => cached)); + long none = MeasureAllocations(Request("none")); + long flow = MeasureAllocations(Request("flow")); + Assert.AreEqual(0, none, "undivided total across 2000 requests"); + Assert.Greater(flow, 0); + TestContext.WriteLine("Completed Task: None = " + none + " B / 2000 requests; Flow = " + flow + " B / 2000 requests (" + flow / 2000 + " B/request)."); + } + + private long MeasureAllocations(string request) + { + var input = (ReadOnlyMemory)Encoding.UTF8.GetBytes(request); + using var output = new PooledByteBufferWriter(256); + for (int i = 0; i < 500; i++) { output.Clear(); JsonRpcProcessor.ProcessAsync(_session, input, output).GetAwaiter().GetResult(); } + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 2000; i++) { output.Clear(); JsonRpcProcessor.ProcessAsync(_session, input, output).GetAwaiter().GetResult(); } + return GC.GetAllocatedBytesForCurrentThread() - before; + } + } +} diff --git a/AustinHarris.JsonRpcTestN/AustinHarris.JsonRpcTestN.csproj b/AustinHarris.JsonRpcTestN/AustinHarris.JsonRpcTestN.csproj index c5db038..2d35f0e 100644 --- a/AustinHarris.JsonRpcTestN/AustinHarris.JsonRpcTestN.csproj +++ b/AustinHarris.JsonRpcTestN/AustinHarris.JsonRpcTestN.csproj @@ -1,21 +1,27 @@ - + Austin Harris - netcoreapp3.0;netcoreapp3.1 + net8.0;net10.0 + false - - - - - + + + + + + + + + + - \ No newline at end of file + diff --git a/AustinHarris.JsonRpcTestN/DelegateBindingTests.cs b/AustinHarris.JsonRpcTestN/DelegateBindingTests.cs new file mode 100644 index 0000000..af1cc59 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/DelegateBindingTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// (issue #6): + /// any delegate becomes a method without attributes or a service class. + /// + [TestFixture] + public class DelegateBindingTests + { + private const string Session = "delegate-binding"; + private static readonly string[] Serializers = { "jsmn", "newtonsoft", "stj" }; + + private sealed class Counter + { + public int Count; + public int Next() => ++Count; + public string Describe(string prefix, int n) => prefix + n; + public static double Half(double x) => x / 2; + } + + private static string Run(string json, JsonRpcSerializer serializer = null) + { + return JsonRpcProcessor.ProcessSync(Session, json, null, serializer); + } + + [TearDown] + public void Clean() + { + Handler.DestroySession(Session); + } + + [TestCaseSource(nameof(Serializers))] + public void Lambdas_KeepTheirParameterNames(string name) + { + var s = SerializerCatalog.Create(name); + ServiceBinder.BindMethod(Session, "add", (int left, int right) => left + right); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":1}", Run("{\"method\":\"add\",\"params\":[1,2],\"id\":1}", s)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":1}", Run("{\"method\":\"add\",\"params\":{\"right\":2,\"left\":1},\"id\":1}", s)); + var response = JObject.Parse(Run("{\"method\":\"add\",\"params\":{\"l\":1,\"r\":2},\"id\":1}", s)); + Assert.AreEqual(-32602, (int)response["error"]["code"]); + StringAssert.Contains("'l'", (string)response["error"]["data"]); + } + + [TestCaseSource(nameof(Serializers))] + public void CapturingLambdas_MethodGroups_StaticAndInstance(string name) + { + var s = SerializerCatalog.Create(name); + var counter = new Counter(); + ServiceBinder.BindMethod(Session, "next", () => counter.Next()); + ServiceBinder.BindMethod(Session, "describe", new Func(counter.Describe)); + ServiceBinder.BindMethod(Session, "half", new Func(Counter.Half)); + ServiceBinder.BindMethod(Session, "greet", (string who) => "hi " + who); + + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}", Run("{\"method\":\"next\",\"id\":1}", s)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":2}", Run("{\"method\":\"next\",\"id\":2}", s)); + Assert.AreEqual(2, counter.Count); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"n=7\",\"id\":1}", Run("{\"method\":\"describe\",\"params\":{\"prefix\":\"n=\",\"n\":7},\"id\":1}", s), "a method group keeps the target's parameter names"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":2.5,\"id\":1}", Run("{\"method\":\"half\",\"params\":[5],\"id\":1}", s)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"hi you\",\"id\":1}", Run("{\"method\":\"greet\",\"params\":[\"you\"],\"id\":1}", s)); + } + + [TestCaseSource(nameof(Serializers))] + public void ExplicitNames_AndDefaults(string name) + { + var s = SerializerCatalog.Create(name); + ServiceBinder.BindMethod(Session, "scale", (double value, double factor) => value * factor, + parameterNames: new[] { "v", null }, defaults: new Dictionary { ["factor"] = 10 }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":20.0,\"id\":1}", Run("{\"method\":\"scale\",\"params\":{\"v\":2},\"id\":1}", s), "null keeps the lambda's name; the default is converted to the parameter type"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":6.0,\"id\":1}", Run("{\"method\":\"scale\",\"params\":{\"v\":2,\"factor\":3},\"id\":1}", s)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":20.0,\"id\":1}", Run("{\"method\":\"scale\",\"params\":[2],\"id\":1}", s)); + var response = JObject.Parse(Run("{\"method\":\"scale\",\"params\":{\"value\":2},\"id\":1}", s)); + Assert.AreEqual(-32602, (int)response["error"]["code"], "the renamed parameter is not reachable by its CLR name"); + + var smd = Handler.GetSessionHandler(Session).MetaData.Services["scale"]; + Assert.AreEqual("v", smd.Method.Parameters[0].Name); + Assert.AreEqual("factor", smd.Method.Parameters[1].Name); + Assert.IsTrue(smd.Method.Parameters[1].HasDefault); + Assert.AreEqual(typeof(double), smd.Method.ReturnType); + } + + [TestCaseSource(nameof(Serializers))] + public void ClosedDelegate_WithoutRecoverableNames_UsesArgN(string name) + { + var s = SerializerCatalog.Create(name); + // a delegate closed over its first argument: the target's parameter list no longer describes the delegate + var closed = (Func)Delegate.CreateDelegate(typeof(Func), "prefix-", typeof(DelegateBindingTests).GetMethod(nameof(Concat), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)); + ServiceBinder.BindMethod(Session, "closed", closed); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"prefix-4\",\"id\":1}", Run("{\"method\":\"closed\",\"params\":[4],\"id\":1}", s)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"prefix-4\",\"id\":1}", Run("{\"method\":\"closed\",\"params\":{\"arg\":4},\"id\":1}", s)); + Assert.AreEqual("arg", Handler.GetSessionHandler(Session).MetaData.Services["closed"].Method.Parameters[0].Name); + + ServiceBinder.BindMethod(Session, "closedNamed", closed, new[] { "n" }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"prefix-5\",\"id\":1}", Run("{\"method\":\"closedNamed\",\"params\":{\"n\":5},\"id\":1}", s)); + } + + private static string Concat(string prefix, int n) => prefix + n; + + [TestCaseSource(nameof(Serializers))] + public void VoidDelegates_AnswerNull(string name) + { + var s = SerializerCatalog.Create(name); + int calls = 0; + ServiceBinder.BindMethod(Session, "fire", (int n) => { calls += n; }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":null,\"id\":1}", Run("{\"method\":\"fire\",\"params\":[3],\"id\":1}", s)); + Assert.AreEqual("", Run("{\"method\":\"fire\",\"params\":[4]}", s)); + Assert.AreEqual(7, calls); + } + + [Test] + public void Names_MustBeFree_AndUnbindFreesThem() + { + ServiceBinder.BindMethod(Session, "m", () => 1); + var ex = Assert.Throws(() => ServiceBinder.BindMethod(Session, "m", () => 2)); + StringAssert.Contains("already registered", ex.Message); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}", Run("{\"method\":\"m\",\"id\":1}"), "the first registration stands"); + + Assert.IsTrue(ServiceBinder.UnbindMethod(Session, "m")); + Assert.IsFalse(ServiceBinder.UnbindMethod(Session, "m")); + Assert.AreEqual(-32601, (int)JObject.Parse(Run("{\"method\":\"m\",\"id\":1}"))["error"]["code"]); + ServiceBinder.BindMethod(Session, "m", () => 2); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":2,\"id\":1}", Run("{\"method\":\"m\",\"id\":1}")); + + // the legacy surface keeps replacing silently + Handler.GetSessionHandler(Session).RegisterFuction("m", new Dictionary { ["returns"] = typeof(int) }, null, new Func(() => 3)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":1}", Run("{\"method\":\"m\",\"id\":1}")); + } + + [Test] + public void Rejections() + { + Assert.Throws(() => ServiceBinder.BindMethod(Session, "x", null)); + Assert.Throws(() => ServiceBinder.BindMethod(Session, "", () => 1)); + Assert.Throws(() => ServiceBinder.BindMethod(Session, " ", () => 1)); + Assert.Throws(() => ServiceBinder.BindMethod(null, "x", () => 1)); + + Func multicast = () => 1; + multicast += () => 2; + var ex = Assert.Throws(() => ServiceBinder.BindMethod(Session, "multi", multicast)); + StringAssert.Contains("multicast", ex.Message); + + ex = Assert.Throws(() => ServiceBinder.BindMethod(Session, "dup", (int a, int b) => a + b, new[] { "x", "x" })); + StringAssert.Contains("'x'", ex.Message); + + Assert.IsFalse(Handler.GetSessionHandler(Session).MetaData.Services.ContainsKey("multi")); + Assert.IsFalse(Handler.GetSessionHandler(Session).MetaData.Services.ContainsKey("dup")); + } + + [Test] + public void DefaultSessionOverloads() + { + try + { + ServiceBinder.BindMethod("db.default", (string s) => s + "!"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"a!\",\"id\":1}", JsonRpcProcessor.ProcessSync("{\"method\":\"db.default\",\"params\":[\"a\"],\"id\":1}")); + } + finally + { + Assert.IsTrue(ServiceBinder.UnbindMethod("db.default")); + } + } + + [TestCaseSource(nameof(Serializers))] + public void RequestIdAndContext_AreAvailableToDelegates(string name) + { + var s = SerializerCatalog.Create(name); + ServiceBinder.BindMethod(Session, "who", () => Handler.RpcRequestId() + "/" + Handler.RpcContext()); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"abc/ctx\",\"id\":\"abc\"}", JsonRpcProcessor.ProcessSync(Session, "{\"method\":\"who\",\"id\":\"abc\"}", "ctx", s)); + } + } +} diff --git a/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs b/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs new file mode 100644 index 0000000..19c4f95 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs @@ -0,0 +1,653 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Dispatch and handler hardening from the 2.0 review: pre-process mutations are dispatched (4), nested + /// calls keep their invocation frame (5), exception details are redacted by default (6), hook + /// materialisation stays inside the error boundary (7), SMD service edits are honoured (12), notifications + /// never answer (13), batches always answer with an array (14), async methods require async processing (16), + /// unknown named parameters are rejected (19) and the Process overloads bind to the intended session (20). + /// Every scenario runs against the three serializers, passed explicitly to the processor. + /// + [TestFixture] + public class DispatchHardeningTests + { + private const string Session = "dispatch-hardening"; + + /// The serializer of the scenario in flight; the nested-call method needs it. + private static JsonRpcSerializer _current; + + private static readonly string[] Serializers = { "jsmn", "newtonsoft", "stj" }; + + private class HardeningService + { + public static object ContextAfterNested; + public static string InnerResponse; + + [JsonRpcMethod("ping")] + public int Ping() => 7; + + [JsonRpcMethod("echo")] + public string Echo(string s) => s; + + [JsonRpcMethod("accept")] + public int Accept(object o) => 7; + + [JsonRpcMethod("optional")] + public int Optional(int a = 9) => a; + + [JsonRpcMethod("sum")] + public int Sum(int a, int b) => a + b; + + [JsonRpcMethod("frac")] + public double Frac(double d) => d; + + [JsonRpcMethod("ctx")] + public string Ctx() => (string)Handler.RpcContext(); + + [JsonRpcMethod("nested")] + public string Nested() + { + Handler.RpcSetException(new JsonRpcException(-32001, "outer failure", null)); + InnerResponse = JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"ctx\",\"id\":2}", "inner", _current); + ContextAfterNested = Handler.RpcContext(); + return "outer result"; + } + + [JsonRpcMethod("throws")] + public int Throws() => throw new InvalidOperationException("private database /server/secret"); + + /// + /// Three levels deep. The dispatcher reports the inner exception of a wrapped one (legacy behaviour), + /// so the client sees the ArgumentException; its own InnerException is the redaction test's chain. + /// + [JsonRpcMethod("throwsInner")] + public int ThrowsInner() + { + try + { + try + { + throw new Exception("innermost message"); + } + catch (Exception innermost) + { + throw new ArgumentException("inner message", innermost); + } + } + catch (Exception inner) + { + throw new InvalidOperationException("outer message", inner); + } + } + + [JsonRpcMethod("rpcex")] + public int RpcEx() => throw new JsonRpcException(-32005, "application error", "authored data"); + } + + private class SessionProbe + { + private readonly string _name; + public SessionProbe(string name) { _name = name; } + + [JsonRpcMethod("dh.whichSession")] + public string WhichSession() => _name; + } + + private class TaskReturningService + { + [JsonRpcMethod("asyncTask")] + public Task AsyncTask() => Task.FromResult(7); + } + + private class ValueTaskReturningService + { + [JsonRpcMethod("asyncValueTask")] + public ValueTask AsyncValueTask() => new ValueTask(7); + } + + private class PlainTaskReturningService + { + [JsonRpcMethod("asyncPlain")] + public Task AsyncPlain() => Task.CompletedTask; + } + + private class PlainValueTaskReturningService + { + [JsonRpcMethod("asyncPlainValue")] + public ValueTask AsyncPlainValue() => default; + } + + private class AsyncVoidService + { + [JsonRpcMethod("asyncVoid")] + public async void AsyncVoid() => await Task.Yield(); + } + + [OneTimeSetUp] + public void BindServices() + { + ServiceBinder.BindService(Session, new HardeningService()); + ServiceBinder.BindService(Session, new SessionProbe(Session)); + ServiceBinder.BindService(Handler.DefaultSessionId(), new SessionProbe("default")); + } + + [OneTimeTearDown] + public void DestroySessions() + { + Handler.DestroySession(Session); + Handler.DefaultHandler.UnRegisterFunction("dh.whichSession"); + } + + private static string Run(string json, object context = null, JsonRpcSerializer serializer = null, string session = Session) + { + return JsonRpcProcessor.ProcessSync(session, json, context, serializer); + } + + private static JObject Parse(string response) + { + Assert.IsFalse(string.IsNullOrEmpty(response), "expected a response"); + return JObject.Parse(response); + } + + // ------------------------------------------------------------------ 4: pre-process mutations + + [TestCaseSource(nameof(Serializers))] + public void PreProcessHandler_ReplacingMethodAndParams_ChangesWhatRuns(string name) + { + var serializer = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + try + { + handler.SetPreProcessHandler((request, context) => + { + request.Method = "ping"; + request.Params = null; + return null; + }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}", Run("{\"method\":\"echo\",\"params\":[\"original\"],\"id\":1}", null, serializer)); + + handler.SetPreProcessHandler((request, context) => + { + request.Params = new object[] { "replaced" }; + return null; + }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"replaced\",\"id\":1}", Run("{\"method\":\"echo\",\"params\":[\"original\"],\"id\":1}", null, serializer)); + + handler.SetPreProcessHandler((request, context) => + { + request.Params = new Dictionary { ["b"] = 40, ["a"] = 2 }; + return null; + }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":42,\"id\":1}", Run("{\"method\":\"sum\",\"params\":[1,1],\"id\":1}", null, serializer)); + } + finally + { + handler.SetPreProcessHandler(null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void PreProcessHandler_ReplacingId_IsEchoed(string name) + { + var serializer = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + try + { + handler.SetPreProcessHandler((request, context) => { request.Id = "changed"; return null; }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":\"changed\"}", Run("{\"method\":\"ping\",\"id\":1}", null, serializer)); + handler.SetPreProcessHandler((request, context) => { request.Id = 99L; return null; }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":99}", Run("{\"method\":\"ping\",\"id\":\"x\"}", null, serializer)); + } + finally + { + handler.SetPreProcessHandler(null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void PreProcessHandler_LeavingRequestAlone_IsByteIdenticalToFastPath(string name) + { + var serializer = SerializerCatalog.Create(name); + var requests = new[] + { + "{\"jsonrpc\":\"2.0\",\"method\":\"echo\",\"params\":[\"a\\\"b\\u00e9\\n\"],\"id\":1}", + "{\"method\":\"sum\",\"params\":{\"b\":2,\"a\":40},\"id\":\"s\"}", + "{\"method\":\"optional\",\"params\":{},\"id\":3}", + "{\"method\":\"frac\",\"params\":[1.5],\"id\":4}", + "{\"method\":\"frac\",\"params\":[2],\"id\":5}", + "{\"method\":\"missing\",\"id\":6}", + "{\"method\":\"sum\",\"params\":[1],\"id\":7}", + "{\"method\":\"throws\",\"id\":8}", + "{\"method\":\"rpcex\",\"id\":null}", + "[{\"method\":\"ping\",\"id\":1},{\"method\":\"ping\"},{\"method\":\"echo\",\"params\":[\"x\"],\"id\":2}]", + }; + var expected = new string[requests.Length]; + for (int i = 0; i < requests.Length; i++) expected[i] = Run(requests[i], null, serializer); + + var handler = Handler.GetSessionHandler(Session); + try + { + int calls = 0; + handler.SetPreProcessHandler((request, context) => { calls++; return null; }); + for (int i = 0; i < requests.Length; i++) + { + Assert.AreEqual(expected[i], Run(requests[i], null, serializer), requests[i]); + } + Assert.AreEqual(requests.Length + 2, calls, "the pre-handler ran once per request"); + } + finally + { + handler.SetPreProcessHandler(null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void PreProcessHandler_Throwing_IsAnInternalError(string name) + { + var serializer = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + try + { + handler.SetPreProcessHandler((request, context) => throw new InvalidOperationException("hook failed")); + var response = Parse(Run("{\"method\":\"ping\",\"id\":1}", null, serializer)); + Assert.AreEqual(-32603, (int)response["error"]["code"]); + Assert.AreEqual("hook failed", (string)response["error"]["data"]["Message"]); + Assert.AreEqual(1, (int)response["id"]); + } + finally + { + handler.SetPreProcessHandler(null); + } + } + + // ------------------------------------------------------------------ 5: nested calls + + [TestCaseSource(nameof(Serializers))] + public void NestedProcessing_RestoresContextAndException(string name) + { + var serializer = SerializerCatalog.Create(name); + _current = serializer; + HardeningService.ContextAfterNested = null; + HardeningService.InnerResponse = null; + + var outer = Run("{\"jsonrpc\":\"2.0\",\"method\":\"nested\",\"id\":1}", "outer", serializer); + + Assert.AreEqual("outer", HardeningService.ContextAfterNested, "the outer context must survive the nested call"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"inner\",\"id\":2}", HardeningService.InnerResponse, "the inner call sees its own context and no inherited error"); + var response = Parse(outer); + Assert.AreEqual(-32001, (int)response["error"]["code"], "the outer call keeps the exception it set: " + outer); + Assert.AreEqual(1, (int)response["id"]); + Assert.IsNull(Handler.RpcContext(), "no context outside an invocation"); + Assert.IsNull(Handler.RpcGetAndRemoveRpcException(), "no exception leaks out of the invocation"); + } + + [TestCaseSource(nameof(Serializers))] + public void NestedProcessing_RestoresContextAndException_WithHooks(string name) + { + var serializer = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + try + { + handler.SetPostProcessHandler((request, response, context) => null); + NestedProcessing_RestoresContextAndException(name); + } + finally + { + handler.SetPostProcessHandler(null); + } + } + + // ------------------------------------------------------------------ 6: exception disclosure + + [TestCaseSource(nameof(Serializers))] + public void ExceptionDetails_AreRedactedByDefault(string name) + { + var serializer = SerializerCatalog.Create(name); + Assert.IsFalse(Config.IncludeExceptionDetails, "the default is off"); + + var response = Parse(Run("{\"method\":\"throws\",\"id\":1}", null, serializer)); + Assert.AreEqual(-32603, (int)response["error"]["code"]); + var data = (JObject)response["error"]["data"]; + Assert.AreEqual("System.InvalidOperationException", (string)data["ClassName"]); + Assert.AreEqual("private database /server/secret", (string)data["Message"]); + Assert.AreEqual(JTokenType.Null, data["Source"].Type); + Assert.AreEqual(JTokenType.Null, data["StackTraceString"].Type); + Assert.AreEqual(0, (int)data["HResult"]); + Assert.AreEqual(JTokenType.Null, data["InnerException"].Type); + + response = Parse(Run("{\"method\":\"throwsInner\",\"id\":1}", null, serializer)); + data = (JObject)response["error"]["data"]; + Assert.AreEqual("System.ArgumentException", (string)data["ClassName"], "a wrapped exception is reported through its inner exception (unchanged legacy behaviour)"); + Assert.AreEqual("inner message", (string)data["Message"]); + Assert.AreEqual(JTokenType.Null, data["StackTraceString"].Type); + Assert.AreEqual(JTokenType.Null, data["InnerException"].Type, "the inner chain is not sent"); + StringAssert.DoesNotContain("innermost message", response.ToString()); + } + + [TestCaseSource(nameof(Serializers))] + public void ExceptionDetails_AreSentWhenEnabled(string name) + { + var serializer = SerializerCatalog.Create(name); + try + { + Config.IncludeExceptionDetails = true; + var response = Parse(Run("{\"method\":\"throwsInner\",\"id\":1}", null, serializer)); + var data = (JObject)response["error"]["data"]; + Assert.AreEqual("System.ArgumentException", (string)data["ClassName"]); + Assert.AreEqual("inner message", (string)data["Message"]); + StringAssert.Contains("ThrowsInner", (string)data["StackTraceString"]); + Assert.AreEqual(new ArgumentException().HResult, (int)data["HResult"]); + Assert.AreEqual("innermost message", (string)data["InnerException"]["Message"]); + Assert.AreEqual("System.Exception", (string)data["InnerException"]["ClassName"]); + StringAssert.Contains("ThrowsInner", (string)data["InnerException"]["StackTraceString"]); + + response = Parse(Run("{\"method\":\"throws\",\"id\":1}", null, serializer)); + data = (JObject)response["error"]["data"]; + Assert.AreEqual("System.InvalidOperationException", (string)data["ClassName"]); + Assert.AreEqual(-2146233079, (int)data["HResult"]); + StringAssert.Contains("Throws", (string)data["StackTraceString"]); + Assert.IsNotNull((string)data["Source"]); + } + finally + { + Config.IncludeExceptionDetails = false; + } + } + + [TestCaseSource(nameof(Serializers))] + public void ApplicationJsonRpcException_KeepsItsData(string name) + { + var serializer = SerializerCatalog.Create(name); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32005,\"message\":\"application error\",\"data\":\"authored data\"},\"id\":1}", + Run("{\"method\":\"rpcex\",\"id\":1}", null, serializer)); + } + + // ------------------------------------------------------------------ 7: hook materialisation error boundary + + /// + /// System.Text.Json for everything, except that converting a value to (what the + /// hook path needs for accept(object)) fails: a stand-in for any parameter conversion that throws. + /// + private sealed class ObjectConversionFailsSerializer : JsonRpcSerializer + { + private readonly JsonRpcSerializer _inner = new AustinHarris.JsonRpc.SystemTextJson.SystemTextJsonRpcSerializer(); + public override string Name => "stj-failing-object"; + public override int MaxDepth => _inner.MaxDepth; + public override T Read(ReadOnlySpan utf8Json) => (T)Read(utf8Json, typeof(T)); + public override object Read(ReadOnlySpan utf8Json, Type type) + { + if (type == typeof(object)) throw new InvalidCastException("simulated parameter conversion failure"); + return _inner.Read(utf8Json, type); + } + public override void Write(IBufferWriter output, T value) => _inner.Write(output, value); + public override void Write(IBufferWriter output, object value, Type type) => _inner.Write(output, value, type); + } + + [Test] + public void HookMaterialisation_ConversionFailure_IsAnInternalError() + { + var serializer = new ObjectConversionFailsSerializer(); + var handler = Handler.GetSessionHandler(Session); + string deep = "{\"jsonrpc\":\"2.0\",\"method\":\"accept\",\"params\":[[1]],\"id\":2}"; + + // without hooks the failure happens while binding the argument: the client's fault, -32602 naming the parameter + var plain = Parse(Run(deep, null, serializer)); + Assert.AreEqual(-32602, (int)plain["error"]["code"], plain.ToString()); + Assert.AreEqual("o", (string)plain["error"]["data"]["parameter"]); + Assert.AreEqual("object", (string)plain["error"]["data"]["expectedType"]); + + // with a hook the same failure happens while materialising params for the hook: that is not an argument, -32603 + + JsonRequest seenByErrorHandler = null; + try + { + handler.SetPreProcessHandler((request, context) => null); + Config.SetErrorHandler(Session,(request, ex) => { seenByErrorHandler = request; return ex; }); + + string single = null; + Assert.DoesNotThrow(() => single = Run(deep, null, serializer)); + var response = Parse(single); + Assert.AreEqual(-32603, (int)response["error"]["code"], single); + Assert.AreEqual(2, (int)response["id"]); + Assert.IsNotNull(seenByErrorHandler, "the error handler still runs"); + Assert.AreEqual("accept", seenByErrorHandler.Method); + Assert.IsNull(seenByErrorHandler.Params, "params are unavailable when their conversion is the failure"); + + string batchJson = "[{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}," + deep + ",{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":3}]"; + string batch = null; + Assert.DoesNotThrow(() => batch = Run(batchJson, null, serializer)); + var responses = JArray.Parse(batch); + Assert.AreEqual(3, responses.Count, batch); + Assert.AreEqual(7, (int)responses[0]["result"]); + Assert.AreEqual(-32603, (int)responses[1]["error"]["code"]); + Assert.AreEqual(2, (int)responses[1]["id"]); + Assert.AreEqual(7, (int)responses[2]["result"]); + Assert.AreEqual(3, (int)responses[2]["id"]); + } + finally + { + handler.SetPreProcessHandler(null); + Config.SetErrorHandler(Session,null); + } + } + + // ------------------------------------------------------------------ 12: SMD service edits + + [TestCaseSource(nameof(Serializers))] + public void ServicesDictionary_AddRemoveReplace_AreHonoured(string name) + { + var serializer = SerializerCatalog.Create(name); + var services = Handler.GetSessionHandler(Session).MetaData.Services; + const string ok7 = "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}"; + const string ping = "{\"method\":\"ping\",\"id\":1}"; + + Assert.AreEqual(ok7, Run(ping, null, serializer)); + Assert.IsTrue(services.ContainsKey("ping")); + var original = services["ping"]; + int count = services.Count; + + Assert.IsTrue(services.Remove("ping")); + Assert.AreEqual(-32601, (int)Parse(Run(ping, null, serializer))["error"]["code"], "removed from the dictionary means unreachable"); + Assert.AreEqual(count - 1, services.Count); + + services.Add("ping", original); + Assert.AreEqual(ok7, Run(ping, null, serializer), "added back"); + Assert.AreEqual(count, services.Count); + + var replacement = new SMDService("POST", "JSON-RPC-2.0", + new Dictionary { ["returns"] = typeof(int) }, new Dictionary(), new Func(() => 8)); + services["ping"] = replacement; + Assert.AreEqual(count, services.Count, "same count"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":8,\"id\":1}", Run(ping, null, serializer), "same-count replacement is dispatched"); + + services["ping"] = original; + Assert.AreEqual(ok7, Run(ping, null, serializer)); + + Assert.IsFalse(services.Remove("never-registered")); + Assert.Throws(() => services.Add("ping", original), "Add rejects a duplicate like a dictionary"); + } + + // ------------------------------------------------------------------ 13: notifications never answer + + [TestCaseSource(nameof(Serializers))] + public void Notifications_NeverGetAResponse(string name) + { + var serializer = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + int errorsSeen = 0; + try + { + Config.SetErrorHandler(Session,(request, ex) => { errorsSeen++; return ex; }); + + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"missing\"}", null, serializer), "method not found"); + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"sum\",\"params\":[1]}", null, serializer), "binding failure"); + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"sum\",\"params\":[\"x\",\"y\"]}", null, serializer), "conversion failure"); + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"throws\"}", null, serializer), "method exception"); + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"rpcex\"}", null, serializer), "JsonRpcException"); + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}", null, serializer), "success"); + Assert.GreaterOrEqual(errorsSeen, 4, "the error handler still runs for notifications"); + + // an invalid request object is not a notification + var invalid = Parse(Run("{\"jsonrpc\":\"2.0\",\"params\":[1]}", null, serializer)); + Assert.AreEqual(-32600, (int)invalid["error"]["code"]); + Assert.AreEqual(JTokenType.Null, invalid["id"].Type); + invalid = Parse(Run("{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"params\":1}", null, serializer)); + Assert.AreEqual(-32600, (int)invalid["error"]["code"]); + + // in a batch an errored notification contributes nothing + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}]", + Run("[{\"method\":\"missing\"},{\"method\":\"ping\",\"id\":1},{\"method\":\"throws\"}]", null, serializer)); + Assert.AreEqual("", Run("[{\"method\":\"missing\"},{\"method\":\"throws\"},{\"method\":\"ping\"}]", null, serializer), "a batch of notifications only"); + } + finally + { + Config.SetErrorHandler(Session,null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void Notifications_NeverGetAResponse_WithHooks(string name) + { + var serializer = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + int postSeen = 0; + try + { + handler.SetPreProcessHandler((request, context) => null); + handler.SetPostProcessHandler((request, response, context) => { postSeen++; return null; }); + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"missing\"}", null, serializer)); + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"throws\"}", null, serializer)); + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}", null, serializer)); + Assert.AreEqual(3, postSeen, "the post-process handler still runs"); + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}]", + Run("[{\"method\":\"missing\"},{\"method\":\"ping\",\"id\":1}]", null, serializer)); + } + finally + { + handler.SetPreProcessHandler(null); + handler.SetPostProcessHandler(null); + } + } + + // ------------------------------------------------------------------ 14: batch shape + + [TestCaseSource(nameof(Serializers))] + public void Batch_AlwaysAnswersWithAnArray(string name) + { + var serializer = SerializerCatalog.Create(name); + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}]", Run("[{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}]", null, serializer)); + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}]", Run("[{\"method\":\"ping\",\"id\":1},{\"method\":\"ping\"}]", null, serializer)); + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1},{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":2}]", + Run("[{\"method\":\"ping\",\"id\":1},{\"method\":\"ping\",\"id\":2}]", null, serializer)); + Assert.AreEqual("", Run("[{\"method\":\"ping\"}]", null, serializer)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}", Run("{\"method\":\"ping\",\"id\":1}", null, serializer), "a single request is not a batch"); + var empty = Parse(Run("[]", null, serializer)); + Assert.AreEqual(-32600, (int)empty["error"]["code"]); + } + + // ------------------------------------------------------------------ 16: async return types + + [Test] + public void AsyncReturnTypes_RequireAsyncProcessing_AsyncVoidIsRejected() + { + const string session = "dispatch-hardening-async"; + try + { + ServiceBinder.BindService(session, new TaskReturningService()); + ServiceBinder.BindService(session, new ValueTaskReturningService()); + ServiceBinder.BindService(session, new PlainTaskReturningService()); + ServiceBinder.BindService(session, new PlainValueTaskReturningService()); + foreach (var method in new[] { "asyncTask", "asyncValueTask", "asyncPlain", "asyncPlainValue" }) + { + var response = Parse(Run("{\"method\":\"" + method + "\",\"id\":1}", session: session)); + Assert.AreEqual(-32603, (int)response["error"]["code"]); + StringAssert.Contains("is asynchronous", (string)response["error"]["message"]); + } + var ex = Assert.Throws(() => ServiceBinder.BindService(session, new AsyncVoidService())); + StringAssert.Contains("async void", ex.Message); + Assert.Throws(() => ServiceBinder.BindMethod(session, "boundAsyncVoid", new Action(async () => await Task.Yield()))); + } + finally { Handler.DestroySession(session); } + } + + // ------------------------------------------------------------------ 19: named parameters + + [TestCaseSource(nameof(Serializers))] + public void NamedParameters_UnknownNamesAreRejected(string name) + { + var serializer = SerializerCatalog.Create(name); + + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":9,\"id\":1}", Run("{\"method\":\"optional\",\"params\":{},\"id\":1}", null, serializer), "absent optional takes its default"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":4,\"id\":1}", Run("{\"method\":\"optional\",\"params\":{\"a\":4},\"id\":1}", null, serializer)); + + var response = Parse(Run("{\"method\":\"optional\",\"params\":{\"typo\":4},\"id\":1}", null, serializer)); + Assert.AreEqual(-32602, (int)response["error"]["code"], "an unknown name is not silently replaced by the default"); + StringAssert.Contains("typo", (string)response["error"]["data"]); + + response = Parse(Run("{\"method\":\"sum\",\"params\":{\"a\":1,\"b\":2,\"c\":3},\"id\":1}", null, serializer)); + Assert.AreEqual(-32602, (int)response["error"]["code"]); + StringAssert.Contains("'c'", (string)response["error"]["data"]); + + response = Parse(Run("{\"method\":\"sum\",\"params\":{\"a\":1,\"c\":3},\"id\":1}", null, serializer)); + Assert.AreEqual(-32602, (int)response["error"]["code"], "unknown names are reported even when the count matches"); + StringAssert.Contains("'c'", (string)response["error"]["data"]); + + response = Parse(Run("{\"method\":\"sum\",\"params\":{\"a\":1},\"id\":1}", null, serializer)); + Assert.AreEqual(-32602, (int)response["error"]["code"]); + StringAssert.Contains("'b'", (string)response["error"]["data"]); + + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":42,\"id\":1}", Run("{\"method\":\"sum\",\"params\":{\"b\":40,\"a\":2},\"id\":1}", null, serializer)); + } + + [TestCaseSource(nameof(Serializers))] + public void NamedParameters_DuplicateNamesAreRejected(string name) + { + var serializer = SerializerCatalog.Create(name); + var response = Parse(Run("{\"method\":\"sum\",\"params\":{\"a\":1,\"a\":2,\"b\":3},\"id\":1}", null, serializer)); + Assert.AreEqual(-32602, (int)response["error"]["code"]); + StringAssert.Contains("'a'", (string)response["error"]["data"]); + StringAssert.Contains("more than once", (string)response["error"]["data"]); + + response = Parse(Run("{\"method\":\"optional\",\"params\":{\"a\":1,\"a\":2},\"id\":1}", null, serializer)); + Assert.AreEqual(-32602, (int)response["error"]["code"]); + } + + // ------------------------------------------------------------------ 20: Process overloads bind to the intended session + + [Test] + public void ProcessOverloads_BindToTheIntendedSession() + { + const string json = "{\"jsonrpc\":\"2.0\",\"method\":\"dh.whichSession\",\"id\":1}"; + const string ctx = "{\"jsonrpc\":\"2.0\",\"method\":\"ctx\",\"id\":1}"; + var serializer = AustinHarris.JsonRpc.Jsmn.JsmnSerializer.Instance; + const string onDefault = "{\"jsonrpc\":\"2.0\",\"result\":\"default\",\"id\":1}"; + const string onSession = "{\"jsonrpc\":\"2.0\",\"result\":\"dispatch-hardening\",\"id\":1}"; + + Assert.AreEqual(onDefault, JsonRpcProcessor.Process(json).Result, "Process(json)"); + Assert.AreEqual(onDefault, JsonRpcProcessor.Process(json, (object)"ctx").Result, "Process(json, (object)ctx)"); + Assert.AreEqual(onDefault, JsonRpcProcessor.ProcessSync(json, null), "ProcessSync(json, null)"); + Assert.AreEqual(onDefault, JsonRpcProcessor.ProcessSync(json, (object)"ctx"), "ProcessSync(json, (object)ctx)"); + Assert.AreEqual(onSession, JsonRpcProcessor.Process(Session, json, null).Result, "Process(sessionId, json, null)"); + Assert.AreEqual(onSession, JsonRpcProcessor.ProcessSync(Session, json, null), "ProcessSync(sessionId, json, null)"); + Assert.AreEqual(onDefault, JsonRpcProcessor.Process(serializer, json).Result, "Process(serializer, json)"); + Assert.AreEqual(onDefault, JsonRpcProcessor.Process(serializer, json, null).Result, "Process(serializer, json, null)"); + Assert.AreEqual(onDefault, JsonRpcProcessor.ProcessSync(serializer, json), "ProcessSync(serializer, json)"); + Assert.AreEqual(onSession, JsonRpcProcessor.ProcessSync(Session, json, null, serializer), "ProcessSync(sessionId, json, null, serializer)"); + + // the context reaches the method through every shape + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"c1\",\"id\":1}", JsonRpcProcessor.Process(Session, ctx, "c1").Result); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"c2\",\"id\":1}", JsonRpcProcessor.ProcessSync(Session, ctx, "c2")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"c3\",\"id\":1}", JsonRpcProcessor.ProcessSync(Session, ctx, "c3", serializer)); + } + } +} diff --git a/AustinHarris.JsonRpcTestN/ErrorDataTests.cs b/AustinHarris.JsonRpcTestN/ErrorDataTests.cs new file mode 100644 index 0000000..04f36e4 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/ErrorDataTests.cs @@ -0,0 +1,336 @@ +using System; +using System.Collections.Generic; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Invocation; +using AustinHarris.JsonRpc.Serialization; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Structured error data (issues #123 and #145): an argument the serializer cannot convert is the client's + /// fault (-32602, naming the parameter) instead of an internal error, and "Method not found" says which method. + /// Every scenario runs against the three serializers. + /// + [TestFixture] + public class ErrorDataTests + { + private const string Session = "error-data"; + private static readonly string[] Serializers = { "jsmn", "newtonsoft", "stj" }; + + public class Order + { + public int Quantity { get; set; } + public string Sku { get; set; } + } + + private class Service + { + [JsonRpcMethod("ed.int")] + public int Int(int value) => value; + + [JsonRpcMethod("ed.two")] + public string Two(string first, Guid second) => first + second; + + [JsonRpcMethod("ed.optional")] + public int Optional(int a, int b = 2) => a + b; + + [JsonRpcMethod("ed.order")] + public int OrderQuantity(Order order) => order.Quantity; + + [JsonRpcMethod("ed.nullable")] + public int? Nullable(int? value) => value; + + [JsonRpcMethod("ed.list")] + public int List(List values) => values.Count; + + [JsonRpcMethod("ed.dateTime")] + public string DateTimeArg(DateTime when) => when.Year.ToString(); + + /// The same exception type a converter would throw, but from inside the method: not a binding failure. + [JsonRpcMethod("ed.parses")] + public int Parses(string text) => int.Parse(text); + + [JsonRpcMethod("ed.throwsFormat")] + public int ThrowsFormat() => throw new FormatException("from the method"); + } + + [OneTimeSetUp] + public void Bind() + { + ServiceBinder.BindService(Session, new Service()); + } + + [OneTimeTearDown] + public void Destroy() + { + Handler.DestroySession(Session); + } + + private static JObject Run(string json, JsonRpcSerializer serializer) + { + var response = JsonRpcProcessor.ProcessSync(Session, json, null, serializer); + Assert.IsFalse(string.IsNullOrEmpty(response), "expected a response"); + return JObject.Parse(response); + } + + private static JObject InvalidParams(string json, JsonRpcSerializer serializer, string parameter, int index, string expectedType) + { + var response = Run(json, serializer); + Assert.IsNotNull(response["error"], json + " -> " + response.ToString(Newtonsoft.Json.Formatting.None)); + Assert.AreEqual(-32602, (int)response["error"]["code"], json + " -> " + response.ToString(Newtonsoft.Json.Formatting.None)); + Assert.AreEqual("Invalid params", (string)response["error"]["message"]); + var data = (JObject)response["error"]["data"]; + Assert.AreEqual("conversion", (string)data["reason"], response.ToString()); + Assert.AreEqual(parameter, (string)data["parameter"], response.ToString()); + Assert.AreEqual(index, (int)data["index"], response.ToString()); + Assert.AreEqual(expectedType, (string)data["expectedType"], response.ToString()); + return response; + } + + // ------------------------------------------------------------------ #123: conversion failures are -32602 + + [TestCaseSource(nameof(Serializers))] + public void WrongType_ForAPrimitive(string name) + { + var s = SerializerCatalog.Create(name); + var response = InvalidParams("{\"method\":\"ed.int\",\"params\":[\"abc\"],\"id\":1}", s, "value", 0, "int32"); + Assert.AreEqual(1, (int)response["id"]); + Assert.IsNull(response["error"]["data"]["message"], "the serializer's message (which may echo the value) is not sent by default"); + StringAssert.DoesNotContain("abc", response["error"]["data"].ToString(), "the offending value is not echoed"); + + InvalidParams("{\"method\":\"ed.int\",\"params\":{\"value\":\"abc\"},\"id\":1}", s, "value", 0, "int32"); + InvalidParams("{\"method\":\"ed.int\",\"params\":[{\"a\":1}],\"id\":1}", s, "value", 0, "int32"); + InvalidParams("{\"method\":\"ed.int\",\"params\":[null],\"id\":1}", s, "value", 0, "int32"); + InvalidParams("{\"method\":\"ed.int\",\"params\":[99999999999],\"id\":1}", s, "value", 0, "int32"); + } + + [TestCaseSource(nameof(Serializers))] + public void TheFailingParameter_IsTheOneNamed(string name) + { + var s = SerializerCatalog.Create(name); + InvalidParams("{\"method\":\"ed.two\",\"params\":[\"ok\",\"not-a-guid\"],\"id\":1}", s, "second", 1, "guid"); + InvalidParams("{\"method\":\"ed.two\",\"params\":{\"second\":\"not-a-guid\",\"first\":\"ok\"},\"id\":1}", s, "second", 1, "guid"); + InvalidParams("{\"method\":\"ed.two\",\"params\":[{\"a\":1},\"not-a-guid\"],\"id\":1}", s, "first", 0, "string"); + InvalidParams("{\"method\":\"ed.optional\",\"params\":{\"a\":1,\"b\":\"x\"},\"id\":1}", s, "b", 1, "int32"); + InvalidParams("{\"method\":\"ed.optional\",\"params\":[\"x\"],\"id\":1}", s, "a", 0, "int32"); + Assert.AreEqual(3, (int)Run("{\"method\":\"ed.optional\",\"params\":[1],\"id\":1}", s)["result"]); + } + + [TestCaseSource(nameof(Serializers))] + public void TypeSpellings(string name) + { + var s = SerializerCatalog.Create(name); + InvalidParams("{\"method\":\"ed.nullable\",\"params\":[\"x\"],\"id\":1}", s, "value", 0, "int32?"); + InvalidParams("{\"method\":\"ed.list\",\"params\":[[1,\"x\"]],\"id\":1}", s, "values", 0, "List"); + InvalidParams("{\"method\":\"ed.list\",\"params\":[5],\"id\":1}", s, "values", 0, "List"); + InvalidParams("{\"method\":\"ed.dateTime\",\"params\":[\"yesterday\"],\"id\":1}", s, "when", 0, "datetime"); + InvalidParams("{\"method\":\"ed.order\",\"params\":[{\"Quantity\":\"many\"}],\"id\":1}", s, "order", 0, "Order"); + InvalidParams("{\"method\":\"ed.order\",\"params\":[[1,2]],\"id\":1}", s, "order", 0, "Order"); + Assert.AreEqual(3, (int)Run("{\"method\":\"ed.order\",\"params\":[{\"Quantity\":3}],\"id\":1}", s)["result"]); + } + + [TestCaseSource(nameof(Serializers))] + public void Message_IsSentOnlyWithExceptionDetails(string name) + { + var s = SerializerCatalog.Create(name); + try + { + Config.IncludeExceptionDetails = true; + var response = InvalidParams("{\"method\":\"ed.int\",\"params\":[\"abc\"],\"id\":1}", s, "value", 0, "int32"); + var message = (string)response["error"]["data"]["message"]; + Assert.IsFalse(string.IsNullOrEmpty(message), response.ToString()); + } + finally + { + Config.IncludeExceptionDetails = false; + } + } + + [TestCaseSource(nameof(Serializers))] + public void TheSameExceptionFromInsideTheMethod_StaysInternal(string name) + { + var s = SerializerCatalog.Create(name); + var response = Run("{\"method\":\"ed.parses\",\"params\":[\"abc\"],\"id\":1}", s); + Assert.AreEqual(-32603, (int)response["error"]["code"], "a FormatException thrown by the method is not a binding failure"); + Assert.AreEqual("System.FormatException", (string)response["error"]["data"]["ClassName"]); + + response = Run("{\"method\":\"ed.throwsFormat\",\"id\":1}", s); + Assert.AreEqual(-32603, (int)response["error"]["code"]); + Assert.AreEqual("from the method", (string)response["error"]["data"]["Message"]); + } + + [TestCaseSource(nameof(Serializers))] + public void ErrorHandler_SeesTheStructuredData(string name) + { + var s = SerializerCatalog.Create(name); + object seen = null; + try + { + Config.SetErrorHandler(Session, (request, ex) => { seen = ex.data; return ex; }); + InvalidParams("{\"method\":\"ed.two\",\"params\":[\"ok\",\"bad\"],\"id\":1}", s, "second", 1, "guid"); + var info = seen as ParameterErrorInfo; + Assert.IsNotNull(info, "the handler gets the ParameterErrorInfo object"); + Assert.AreEqual("second", info.Parameter); + Assert.AreEqual(1, info.Index); + Assert.AreEqual("guid", info.ExpectedType); + Assert.AreEqual("conversion", info.Reason); + Assert.IsNotNull(info.Cause); + Assert.IsTrue(ParameterErrorInfo.IsConversionFailure(info.Cause), info.Cause.GetType().FullName); + + // the handler can replace it with a plain string, as before + Config.SetErrorHandler(Session, (request, ex) => new JsonRpcException(ex.code, ex.message, "bad " + ((ParameterErrorInfo)ex.data).Parameter)); + var response = Run("{\"method\":\"ed.two\",\"params\":[\"ok\",\"bad\"],\"id\":1}", s); + Assert.AreEqual("bad second", (string)response["error"]["data"]); + } + finally + { + Config.SetErrorHandler(Session, null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void WithHooks_TheSameCodesAndData(string name) + { + var s = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + try + { + handler.SetPreProcessHandler((request, context) => null); + handler.SetPostProcessHandler((request, response, context) => null); + InvalidParams("{\"method\":\"ed.two\",\"params\":[\"ok\",\"not-a-guid\"],\"id\":1}", s, "second", 1, "guid"); + InvalidParams("{\"method\":\"ed.int\",\"params\":{\"value\":\"abc\"},\"id\":1}", s, "value", 0, "int32"); + + // a hook that replaces params is dispatched from the re-parsed request: same reporting + handler.SetPreProcessHandler((request, context) => { request.Params = new object[] { "x" }; return null; }); + InvalidParams("{\"method\":\"ed.int\",\"params\":[1],\"id\":1}", s, "value", 0, "int32"); + } + finally + { + handler.SetPreProcessHandler(null); + handler.SetPostProcessHandler(null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void HandleJsonRequest_ReportsTheSame(string name) + { + var s = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + var saved = handler.Serializer; + try + { + handler.Serializer = s; + var response = handler.Handle(new JsonRequest("ed.int", new object[] { "abc" }, 1L)); + Assert.IsNotNull(response.Error); + Assert.AreEqual(-32602, response.Error.code); + Assert.AreEqual("value", ((ParameterErrorInfo)response.Error.data).Parameter); + } + finally + { + handler.Serializer = saved; + } + } + + [Test] + public void ConversionFailureFamily() + { + Assert.IsTrue(ParameterErrorInfo.IsConversionFailure(new JsonRpcBindException("x"))); + Assert.IsTrue(ParameterErrorInfo.IsConversionFailure(new FormatException())); + Assert.IsTrue(ParameterErrorInfo.IsConversionFailure(new OverflowException())); + Assert.IsTrue(ParameterErrorInfo.IsConversionFailure(new InvalidCastException())); + Assert.IsTrue(ParameterErrorInfo.IsConversionFailure(new System.Text.Json.JsonException("x"))); + Assert.IsTrue(ParameterErrorInfo.IsConversionFailure(new Newtonsoft.Json.JsonSerializationException("x"))); + Assert.IsTrue(ParameterErrorInfo.IsConversionFailure(new Newtonsoft.Json.JsonReaderException("x"))); + Assert.IsFalse(ParameterErrorInfo.IsConversionFailure(new NotSupportedException())); + Assert.IsFalse(ParameterErrorInfo.IsConversionFailure(new InvalidOperationException())); + Assert.IsFalse(ParameterErrorInfo.IsConversionFailure(new ArgumentException())); + Assert.IsFalse(ParameterErrorInfo.IsConversionFailure(null)); + } + + [Test] + public void ExpectedTypeSpelling() + { + Assert.AreEqual("int32", ParameterErrorInfo.Describe(typeof(int))); + Assert.AreEqual("int64?", ParameterErrorInfo.Describe(typeof(long?))); + Assert.AreEqual("string[]", ParameterErrorInfo.Describe(typeof(string[]))); + Assert.AreEqual("guid", ParameterErrorInfo.Describe(typeof(Guid))); + Assert.AreEqual("boolean", ParameterErrorInfo.Describe(typeof(bool))); + Assert.AreEqual("uint8[]", ParameterErrorInfo.Describe(typeof(byte[]))); + Assert.AreEqual("Dictionary", ParameterErrorInfo.Describe(typeof(Dictionary))); + Assert.AreEqual("object", ParameterErrorInfo.Describe(typeof(object))); + Assert.AreEqual("datetimeoffset", ParameterErrorInfo.Describe(typeof(DateTimeOffset))); + } + + /// A type the built-in serializer cannot read at all is a server-side limitation, not the client's fault. + [Test] + public void UnsupportedType_StaysInternal() + { + const string session = "error-data-unsupported"; + try + { + ServiceBinder.BindMethod(session, "takesDelegate", new Func(a => 1)); + var response = JObject.Parse(JsonRpcProcessor.ProcessSync(session, "{\"method\":\"takesDelegate\",\"params\":[{}],\"id\":1}", null, SerializerCatalog.Create("jsmn"))); + Assert.AreEqual(-32603, (int)response["error"]["code"], response.ToString()); + Assert.AreEqual("System.NotSupportedException", (string)response["error"]["data"]["ClassName"]); + } + finally + { + Handler.DestroySession(session); + } + } + + // ------------------------------------------------------------------ #145: method not found names the method + + [TestCaseSource(nameof(Serializers))] + public void MethodNotFound_NamesTheMethod(string name) + { + var s = SerializerCatalog.Create(name); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\",\"data\":{\"method\":\"no.such\"}},\"id\":1}", + JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"no.such\",\"id\":1}", null, s)); + // the decoded name, re-escaped as any string + var escaped = JObject.Parse(JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"n\\u00f6/\\\"x\\\"\",\"id\":\"q\"}", null, s)); + Assert.AreEqual(-32601, (int)escaped["error"]["code"]); + Assert.AreEqual("nö/\"x\"", (string)escaped["error"]["data"]["method"]); + // in a batch, per request + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\",\"data\":{\"method\":\"a\"}},\"id\":1},{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":2}]", + JsonRpcProcessor.ProcessSync(Session, "[{\"method\":\"a\",\"id\":1},{\"method\":\"b\"},{\"method\":\"ed.int\",\"params\":[3],\"id\":2}]", null, s)); + } + + [TestCaseSource(nameof(Serializers))] + public void MethodNotFound_ReachesTheErrorHandler_OnBothPaths(string name) + { + var s = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + var seen = new List(); + try + { + Config.SetErrorHandler(Session, (request, ex) => + { + if (ex.data is MethodNotFoundInfo info) seen.Add(request.Method + "=" + info.Method + "/" + ex.code); + return new JsonRpcException(ex.code, ex.message, "try " + ((MethodNotFoundInfo)ex.data).Method + " later"); + }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\",\"data\":\"try nope later\"},\"id\":1}", + JsonRpcProcessor.ProcessSync(Session, "{\"method\":\"nope\",\"id\":1}", null, s)); + Assert.AreEqual("", JsonRpcProcessor.ProcessSync(Session, "{\"method\":\"nope2\"}", null, s), "a notification still answers nothing"); + + handler.SetPreProcessHandler((request, context) => null); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\",\"data\":\"try nope3 later\"},\"id\":1}", + JsonRpcProcessor.ProcessSync(Session, "{\"method\":\"nope3\",\"id\":1}", null, s)); + + // a hook that redirects to a missing method: the effective name is reported + handler.SetPreProcessHandler((request, context) => { request.Method = "renamed"; return null; }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,\"message\":\"Method not found\",\"data\":\"try renamed later\"},\"id\":1}", + JsonRpcProcessor.ProcessSync(Session, "{\"method\":\"ed.int\",\"params\":[1],\"id\":1}", null, s)); + + Assert.AreEqual(new[] { "nope=nope/-32601", "nope2=nope2/-32601", "nope3=nope3/-32601", "renamed=renamed/-32601" }, seen); + } + finally + { + handler.SetPreProcessHandler(null); + Config.SetErrorHandler(Session, null); + } + } + } +} diff --git a/AustinHarris.JsonRpcTestN/InterfaceBindingTests.cs b/AustinHarris.JsonRpcTestN/InterfaceBindingTests.cs new file mode 100644 index 0000000..84d77d7 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/InterfaceBindingTests.cs @@ -0,0 +1,842 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + [TestFixture] + public sealed class InterfaceBindingTests + { + private const string Session = "interface-binding"; + private static readonly string[] Serializers = { "jsmn", "newtonsoft", "stj" }; + private static SMDServiceCollection Services => Handler.GetSessionHandler(Session).MetaData.Services; + + [TearDown] + public void Clean() => Handler.DestroySession(Session); + + private static string Run(string json, JsonRpcSerializer serializer = null, object context = null) + => JsonRpcProcessor.ProcessSync(Session, json, context, serializer); + + private static JObject Call(string method, string parameters = null, JsonRpcSerializer serializer = null) + => JObject.Parse(Run("{\"method\":\"" + method + "\",\"id\":1" + (parameters == null ? "" : ",\"params\":" + parameters) + "}", serializer)); + + private static JToken Result(string method, string parameters = null, JsonRpcSerializer serializer = null) + { + var response = Call(method, parameters, serializer); + Assert.IsNull(response["error"], response.ToString()); + return response["result"]; + } + + private static void Absent(params string[] names) + { + foreach (string name in names) + { + Assert.IsFalse(Services.ContainsKey(name), name); + Assert.AreEqual(-32601, (int)Call(name)["error"]["code"], name); + } + } + + private interface IWorld + { + ICharacter Character { get; } + ISession Session { get; } + IObserver Observer { get; } + IAdmin Admin { get; } + } + private interface ICharacter + { + float MoveAndRotate(float distance, float roll = 0, int ticks = 1); + string Use(); + } + private interface ISession { void Reset(); } + private interface IObserver { List Observe(); } + private interface IAdmin + { + IAdminCharacter Character { get; } + IAdminObserver Observer { get; } + } + private interface IAdminCharacter + { + void Teleport(int x, int y); + string Use(string item, int x, int y); + } + private interface IAdminObserver { Dictionary Describe(); } + private sealed class World : IWorld, ICharacter, ISession, IObserver, IAdmin, IAdminCharacter, IAdminObserver + { + public int Resets; + public int Position; + public ICharacter Character => this; + public ISession Session => this; + public IObserver Observer => this; + public IAdmin Admin => this; + IAdminCharacter IAdmin.Character => this; + IAdminObserver IAdmin.Observer => this; + public float MoveAndRotate(float distance, float roll = 99, int ticks = 99) => distance + roll + ticks; + public string Use() => "ordinary"; + public void Reset() => Resets++; + public List Observe() => new List { 1, 2, 3 }; + public void Teleport(int x, int y) => Position = x + y; + public string Use(string item, int x, int y) => item + ":" + (x + y); + public Dictionary Describe() => new Dictionary { ["role"] = "admin" }; + } + + [TestCaseSource(nameof(Serializers))] + public void ThreeLevelTree_DefaultsVoidCollectionsAndDistinctPaths(string name) + { + var serializer = SerializerCatalog.Create(name); + var world = new World(); + using var binding = ServiceBinder.BindInterface(Session, world); + CollectionAssert.AreEquivalent(new[] { "Character.MoveAndRotate", "Character.Use", "Session.Reset", "Observer.Observe", "Admin.Character.Teleport", "Admin.Character.Use", "Admin.Observer.Describe" }, binding.Methods); + Assert.AreEqual(3f, (float)Result("Character.MoveAndRotate", "[2]", serializer)); + Assert.AreEqual(3f, (float)Result("Character.MoveAndRotate", "{\"distance\":2}", serializer)); + Assert.AreEqual(9f, (float)Result("Character.MoveAndRotate", "{\"ticks\":4,\"roll\":3,\"distance\":2}", serializer)); + Assert.AreEqual(JTokenType.Null, Result("Session.Reset", null, serializer).Type); + Assert.AreEqual(1, world.Resets); + Assert.AreEqual(JTokenType.Null, Result("Admin.Character.Teleport", "[4,5]", serializer).Type); + Assert.AreEqual(9, world.Position); + CollectionAssert.AreEqual(new[] { 1, 2, 3 }, Result("Observer.Observe", null, serializer).Values()); + Assert.AreEqual("admin", (string)Result("Admin.Observer.Describe", null, serializer)["role"]); + Assert.AreEqual("ordinary", (string)Result("Character.Use", null, serializer)); + Assert.AreEqual("key:7", (string)Result("Admin.Character.Use", "[\"key\",3,4]", serializer)); + } + + private interface IAdd { int Add(int left, int right); } + private sealed class Adder : IAdd { public int Add(int a, int b) => a + b; } + private sealed class ExplicitAdder : IAdd + { + int IAdd.Add(int a, int b) => a + b; + [JsonRpcMethod("secret")] public int Secret() => 99; + } + + private interface IRefError { int Check(int value, ref JsonRpcException error); } + private sealed class RefError : IRefError + { + public int Check(int value, ref JsonRpcException error) + { + if (value < 0) error = new JsonRpcException(-32001, "negative", null); + return value; + } + } + + [TestCaseSource(nameof(Serializers))] + public void TrailingRefError_UsesCompiledInvokerWithoutLegacyDelegate(string name) + { + using var binding = ServiceBinder.BindInterface(Session, new RefError()); + var serializer = SerializerCatalog.Create(name); + Assert.AreEqual(3, (int)Result("Check", "[3]", serializer)); + Assert.AreEqual(-32001, (int)Call("Check", "[-1]", serializer)["error"]["code"]); + Assert.IsNull(Services["Check"].dele); + Assert.AreEqual(1, Services["Check"].parameters.Length); + } + + [TestCaseSource(nameof(Serializers))] + public void ExplicitImplementation_UsesOnlyContractMetadata(string name) + { + using var binding = ServiceBinder.BindInterface(Session, new ExplicitAdder()); + Assert.AreEqual(7, (int)Result("Add", "{\"right\":4,\"left\":3}", SerializerCatalog.Create(name))); + CollectionAssert.AreEqual(new[] { "Add" }, binding.Methods); + Absent("secret", "Secret", "ToString", "Equals", "GetHashCode", "GetType"); + var legacy = (Func)Services["Add"].dele; + Assert.AreEqual(9, legacy(4, 5)); + } + + private interface IBase { int Base(); } + private interface ILeft : IBase { } + private interface IRight : IBase { } + private interface IDiamond : ILeft, IRight { int Leaf(); } + private sealed class Diamond : IDiamond + { + public int Base() => 1; + public int Leaf() => 2; + } + + [Test] + public void InheritedDiamond_VisitsDeclarationOnce() + { + using var binding = ServiceBinder.BindInterface(Session, new Diamond()); + CollectionAssert.AreEquivalent(new[] { "Base", "Leaf" }, binding.Methods); + Assert.AreEqual(1, (int)Result("Base")); + Assert.AreEqual(2, (int)Result("Leaf")); + } + + private interface IGeneric { T Echo(T value); } + private sealed class Generic : IGeneric { public T Echo(T item) => item; } + + private interface IGenericPair : IGeneric, IGeneric { } + private sealed class GenericPair : IGenericPair + { + public int Echo(int value) => value; + public string Echo(string value) => value; + } + + [Test] + public void DifferentClosedGenericDeclarations_AreNotDeduplicated() + { + using var binding = ServiceBinder.BindInterface(Session, new GenericPair(), new RpcInterfaceBindingOptions + { + NameRule = m => m.Interface.GetGenericArguments()[0].Name + "." + m.Leaf + }); + Assert.AreEqual(4, (int)Result("Int32.Echo", "[4]")); + Assert.AreEqual("four", (string)Result("String.Echo", "[\"four\"]")); + } + + [TestCaseSource(nameof(Serializers))] + public void ClosedGenericInterface(string name) + { + RpcInterfaceMethod seen = null; + using var binding = ServiceBinder.BindInterface>(Session, new Generic(), new RpcInterfaceBindingOptions { Include = m => { seen = m; return true; } }); + Assert.AreEqual(typeof(IGeneric), seen.Interface); + Assert.AreEqual(12, (int)Result("Echo", "{\"value\":12}", SerializerCatalog.Create(name))); + } + + [Test] + public void OpenGenericInterface_IsRejectedByTypeValidation() + { + // The generic public API cannot be invoked with an open T; exercise the same validator directly. + var validate = typeof(ServiceBinder).GetMethod("ValidateInterface", BindingFlags.NonPublic | BindingFlags.Static); + var ex = Assert.Throws(() => validate.Invoke(null, new object[] { typeof(IGeneric<>) })); + Assert.IsInstanceOf(ex.InnerException); + StringAssert.Contains("closed interface", ex.InnerException.Message); + Assert.AreEqual(0, Services.Count); + } + + private interface IGenericMethod { int Good(); T Echo(T value); } + private sealed class GenericMethod : IGenericMethod + { + public int Good() => 1; + public T Echo(T value) => value; + } + + [Test] + public void GenericMethod_IsRejectedAtomically() + { + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new GenericMethod())); + StringAssert.Contains("Generic interface method", ex.Message); + Absent("Good", "Echo"); + } + + [AttributeUsage(AttributeTargets.Method)] + private sealed class ExportAttribute : Attribute { } + private interface IAliases + { + [Export, JsonRpcMethod("ALIAS"), JsonRpcMethod("OtherAlias")] + int Value([JsonRpcParam("n")] int number = 7); + [JsonRpcMethod] int Plain(); + } + private sealed class Aliases : IAliases + { + [JsonRpcMethod("implementation")] + public int Value([JsonRpcParam("wrong")] int different = 99) => different; + public int Plain() => 3; + } + + [TestCaseSource(nameof(Serializers))] + public void AliasesRenamesAndDefaults_ComeFromInterface(string name) + { + using var binding = ServiceBinder.BindInterface(Session, new Aliases()); + var serializer = SerializerCatalog.Create(name); + Assert.AreEqual(7, (int)Result("ALIAS", "{}", serializer)); + Assert.AreEqual(8, (int)Result("OtherAlias", "{\"n\":8}", serializer)); + Assert.AreEqual(3, (int)Result("Plain", null, serializer)); + Assert.AreEqual(-32602, (int)Call("ALIAS", "{\"wrong\":8}", serializer)["error"]["code"]); + Absent("Value", "implementation"); + var service = Services["ALIAS"]; + Assert.AreEqual("n", service.parameters.Single().Name); + Assert.AreEqual(7, service.defaultValues.Single().Value); + Assert.AreEqual(5, ((Func)service.dele)(5)); + } + + [Test] + public void Include_ReadsHostAttributeForEveryAlias() + { + var seen = new List(); + using var binding = ServiceBinder.BindInterface(Session, new Aliases(), new RpcInterfaceBindingOptions + { + Include = method => { seen.Add(method); return method.Method.IsDefined(typeof(ExportAttribute), false); } + }); + Assert.AreEqual(3, seen.Count); + CollectionAssert.AreEquivalent(new[] { "ALIAS", "OtherAlias" }, binding.Methods); + Assert.AreEqual(typeof(IAliases), seen[0].Interface); + Assert.IsEmpty(seen[0].Path); + Absent("Plain"); + } + + [Test] + public void NameRule_ReceivesFullPathAndReplacesDefaultName() + { + var descriptions = new List(); + using var binding = ServiceBinder.BindInterface(Session, new World(), new RpcInterfaceBindingOptions + { + Prefix = "unused:", + NameRule = method => { descriptions.Add(method); return "v1/" + string.Join("/", method.Path.Concat(new[] { method.Leaf })).ToLowerInvariant(); } + }); + var teleport = descriptions.Single(m => m.Leaf == "Teleport"); + CollectionAssert.AreEqual(new[] { "Admin", "Character" }, teleport.Path); + Assert.AreEqual("unused:Admin.Character.Teleport", teleport.DefaultName); + Assert.AreEqual(typeof(IAdminCharacter), teleport.Interface); + Assert.AreEqual("ordinary", (string)Result("v1/character/use")); + Assert.AreEqual(JTokenType.Null, Result("v1/admin/character/teleport", "[1,2]").Type); + } + + [Test] + public void CamelCasePrefixAndSeparator_LeaveAliasesLiteral() + { + using var tree = ServiceBinder.BindInterface(Session, new World(), new RpcInterfaceBindingOptions { Prefix = "V1:", Separator = "/", Casing = RpcNameCasing.CamelCase }); + Assert.AreEqual(3f, (float)Result("V1:character/moveAndRotate", "[2]")); + using var aliases = ServiceBinder.BindInterface(Session, new Aliases(), new RpcInterfaceBindingOptions { Prefix = "V1:", Casing = RpcNameCasing.CamelCase }); + CollectionAssert.AreEquivalent(new[] { "V1:ALIAS", "V1:OtherAlias", "V1:plain" }, aliases.Methods); + } + + private interface ICasing { int ID(); int URLValue(); } + private sealed class Casing : ICasing + { + public int ID() => 1; + public int URLValue() => 2; + } + + [Test] + public void CamelCase_IsInvariantAndHandlesAcronyms() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); + using var binding = ServiceBinder.BindInterface(Session, new Casing(), new RpcInterfaceBindingOptions { Casing = RpcNameCasing.CamelCase }); + CollectionAssert.AreEquivalent(new[] { "id", "urlValue" }, binding.Methods); + } + finally { CultureInfo.CurrentCulture = previous; } + } + + private interface IMount { int Root(); IAdd Child { get; } } + private sealed class Mount : IMount + { + public Func GetChild = () => new Adder(); + public int Reads; + public int Root() => 4; + public IAdd Child { get { Reads++; return GetChild(); } } + } + + private interface IMountLeft : IMount { } + private interface IMountRight : IMount { } + private interface IMountDiamond : IMountLeft, IMountRight { } + private sealed class MountDiamond : IMountDiamond + { + public int Reads; + public int Root() => 1; + public IAdd Child { get { Reads++; return new Adder(); } } + } + + [Test] + public void InheritedPropertyDiamond_EvaluatesGetterOnce() + { + var target = new MountDiamond(); + using var binding = ServiceBinder.BindInterface(Session, target); + Assert.AreEqual(1, target.Reads); + CollectionAssert.AreEquivalent(new[] { "Root", "Child.Add" }, binding.Methods); + } + + [Test] + public void RecursionOff_DoesNotEvaluateChildren() + { + var mount = new Mount { GetChild = () => throw new InvalidOperationException() }; + using var binding = ServiceBinder.BindInterface(Session, mount, new RpcInterfaceBindingOptions { Recursive = false }); + Assert.AreEqual(0, mount.Reads); + Assert.AreEqual(4, (int)Result("Root")); + Absent("Child.Add"); + } + + [TestCase(false)] + [TestCase(true)] + public void NullOrThrowingChild_LeavesNoPartialTree(bool throws) + { + ServiceBinder.BindMethod(Session, "before", () => 17); + var mount = new Mount { GetChild = () => throws ? throw new InvalidOperationException("getter failed") : null }; + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, mount)); + StringAssert.Contains("Child", ex.Message); + if (throws) Assert.IsInstanceOf(ex.InnerException); + Assert.AreEqual(1, mount.Reads); + Absent("Root", "Child.Add"); + Assert.AreEqual(17, (int)Result("before")); + } + + private interface IA { int Root(); IB B { get; } } + private interface IB { IA A { get; } } + private sealed class Cycle : IA, IB + { + public int Root() => 1; + public IB B => this; + public IA A => this; + } + + [Test] + public void Cycle_UsesReferenceAndClosedInterfaceOnActivePath() + { + ServiceBinder.BindMethod(Session, "before", () => 17); + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new Cycle())); + StringAssert.Contains("cycle", ex.Message); + StringAssert.Contains("B.A", ex.Message); + Absent("Root", "B.A.Root"); + Assert.AreEqual(17, (int)Result("before")); + } + + private interface IChain { int Value(); IChain Next { get; } } + private sealed class Chain : IChain + { + public int Value() => 1; + public IChain Next => new Chain(); + } + + [Test] + public void FreshObjectRecursion_StopsAtDepthLimit() + { + ServiceBinder.BindMethod(Session, "before", () => 17); + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new Chain())); + StringAssert.Contains("depth exceeds 32", ex.Message); + CollectionAssert.AreEqual(new[] { "before" }, Services.Keys); + Absent("Value", "Next.Value"); + Assert.AreEqual(17, (int)Result("before")); + } + + private interface ITwoMounts { IMount Left { get; } IMount Right { get; } } + private sealed class TwoMounts : ITwoMounts + { + public readonly Mount Shared = new Mount(); + public int Reads; + public IMount Left { get { Reads++; return Shared; } } + public IMount Right { get { Reads++; return Shared; } } + } + + [Test] + public void SameInstanceAtTwoPaths_EvaluatesGettersOncePerMount() + { + var root = new TwoMounts(); + using var binding = ServiceBinder.BindInterface(Session, root); + Assert.AreEqual(2, root.Reads); + Assert.AreEqual(2, root.Shared.Reads); + root.Shared.GetChild = () => throw new InvalidOperationException("must never be read per request"); + Assert.AreEqual(3, (int)Result("Left.Child.Add", "[1,2]")); + Assert.AreEqual(7, (int)Result("Right.Child.Add", "[3,4]")); + Assert.AreEqual(2, root.Shared.Reads); + } + + [Test] + public void OptionsAndCallbackPaths_AreSnapshots() + { + var options = new RpcInterfaceBindingOptions { Prefix = "fixed:" }; + options.Include = method => + { + options.Prefix = "changed:"; + options.Separator = "/"; + options.Recursive = false; + options.NameRule = _ => "wrong"; + var path = method.Path; + if (path.Length != 0) path[0] = "changed"; + return true; + }; + using var binding = ServiceBinder.BindInterface(Session, new Mount(), options); + CollectionAssert.AreEquivalent(new[] { "fixed:Root", "fixed:Child.Add" }, binding.Methods); + } + + [TestCase(false)] + [TestCase(true)] + public void ThrowingCallbacks_LeaveNoPartialTree(bool naming) + { + ServiceBinder.BindMethod(Session, "before", () => 17); + var options = new RpcInterfaceBindingOptions(); + if (naming) options.NameRule = m => m.Path.Length == 0 ? m.DefaultName : throw new InvalidOperationException("name failed"); + else options.Include = m => m.Path.Length == 0 ? true : throw new InvalidOperationException("include failed"); + Assert.Throws(() => ServiceBinder.BindInterface(Session, new Mount(), options)); + Absent("Root", "Child.Add"); + Assert.AreEqual(17, (int)Result("before")); + } + + [Test] + public void ExistingCollision_RejectsWholeTree() + { + ServiceBinder.BindMethod(Session, "Child.Add", () => 17); + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new Mount())); + StringAssert.Contains("already registered", ex.Message); + Absent("Root"); + Assert.AreEqual(17, (int)Result("Child.Add")); + } + + [Test] + public void CollisionAddedDuringDiscovery_IsRecheckedAtPublication() + { + var mount = new Mount { GetChild = () => { ServiceBinder.BindMethod(Session, "Root", () => 17); return new Adder(); } }; + Assert.Throws(() => ServiceBinder.BindInterface(Session, mount)); + Assert.AreEqual(17, (int)Result("Root")); + Absent("Child.Add"); + } + + private interface IDuplicateAliases { [JsonRpcMethod("same"), JsonRpcMethod("same")] int Value(); } + private sealed class DuplicateAliases : IDuplicateAliases { public int Value() => 1; } + private interface IDuplicateParameters { int Value([JsonRpcParam("same")] int a, [JsonRpcParam("same")] int b); } + private sealed class DuplicateParameters : IDuplicateParameters { public int Value(int a, int b) => a + b; } + + [Test] + public void DuplicateParameterNames_AreRejected() + { + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new DuplicateParameters())); + StringAssert.Contains("duplicate parameter name", ex.Message); + Absent("Value"); + } + private interface IOverloads { int Same(); int Same(int x); } + private sealed class Overloads : IOverloads + { + public int Same() => 1; + public int Same(int x) => x; + } + + [Test] + public void DuplicateAliases_RejectWholeTree() + { + Assert.Throws(() => ServiceBinder.BindInterface(Session, new DuplicateAliases())); + Absent("same"); + } + + [Test] + public void OverloadsWithSameLeaf_RejectWholeTree() + { + Assert.Throws(() => ServiceBinder.BindInterface(Session, new Overloads())); + Absent("Same"); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("rpc.reserved")] + public void InvalidNames_RejectWholeTree(string name) + { + Assert.Throws(() => ServiceBinder.BindInterface(Session, new Adder(), new RpcInterfaceBindingOptions { NameRule = _ => name })); + Assert.AreEqual(0, Services.Count); + } + + [Test] + public void InvalidArguments_AreRejected() + { + Assert.Throws(() => ServiceBinder.BindInterface(null, new Adder())); + Assert.Throws(() => ServiceBinder.BindInterface(Session, null)); + Assert.Throws(() => ServiceBinder.BindInterface(Session, new Adder())); + Assert.Throws(() => ServiceBinder.BindInterface(Session, new Adder(), new RpcInterfaceBindingOptions { Prefix = null })); + Assert.Throws(() => ServiceBinder.BindInterface(Session, new Adder(), new RpcInterfaceBindingOptions { Separator = null })); + Assert.Throws(() => ServiceBinder.BindInterface(Session, new Adder(), new RpcInterfaceBindingOptions { Casing = (RpcNameCasing)99 })); + } + + [Test] + public void Dispose_RemovesOwnedNamesAndIsIdempotent() + { + ServiceBinder.BindMethod(Session, "before", () => 17); + var binding = ServiceBinder.BindInterface(Session, new Mount()); + Assert.AreEqual(Session, binding.SessionId); + Assert.Throws(() => ((IList)binding.Methods)[0] = "other"); + binding.Dispose(); + binding.Dispose(); + Absent("Root", "Child.Add"); + Assert.AreEqual(17, (int)Result("before")); + Assert.AreEqual(2, binding.Methods.Count); + } + + [Test] + public void Dispose_LeavesLaterRegistrationAlone() + { + var binding = ServiceBinder.BindInterface(Session, new Mount()); + ServiceBinder.UnbindMethod(Session, "Root"); + ServiceBinder.BindMethod(Session, "Root", () => 99); + binding.Dispose(); + binding.Dispose(); + Assert.AreEqual(99, (int)Result("Root")); + Absent("Child.Add"); + } + + private sealed class Replacement { [JsonRpcMethod("Add")] public int Add() => 99; } + + [Test] + public void Dispose_LeavesLegacyReplacementAlone() + { + var binding = ServiceBinder.BindInterface(Session, new Adder()); + ServiceBinder.BindService(Session, new Replacement()); + binding.Dispose(); + Assert.AreEqual(99, (int)Result("Add")); + } + + [Test] + public void Dispose_DoesNotTouchRecreatedSession() + { + var binding = ServiceBinder.BindInterface(Session, new Adder()); + Handler.DestroySession(Session); + ServiceBinder.BindMethod(Session, "Add", () => 99); + binding.Dispose(); + Assert.AreEqual(99, (int)Result("Add")); + } + + private interface IVirtual { int Value(); } + private class VirtualBase : IVirtual { public virtual int Value() => 1; } + private class VirtualDerived : VirtualBase { public override int Value() => 2; } + private sealed class SealedValue : IVirtual { public int Value() => 3; } + + [TestCase(false)] + [TestCase(true)] + public void MappedCall_PreservesOverridesAndSealedTargets(bool sealedTarget) + { + IVirtual target = sealedTarget ? new SealedValue() : new VirtualDerived(); + using var binding = ServiceBinder.BindInterface(Session, target); + Assert.AreEqual(sealedTarget ? 3 : 2, (int)Result("Value")); + } + + private interface ITaskContract + { + Task Value(int x); + [JsonRpcMethod("delayed", ContextFlow = RpcContextFlow.Flow)] ValueTask Delayed(string text); + Task Fire(); + } + private sealed class TaskContract : ITaskContract + { + public int Fired; + public Task Value(int x) => Task.FromResult(x + 1); + public async ValueTask Delayed(string text) { await Task.Yield(); return text + "!"; } + public Task Fire() { Fired++; return Task.CompletedTask; } + } + private interface IVoidContract { void Fire(); } + private sealed class AsyncVoidContract : IVoidContract { public async void Fire() { await Task.Yield(); } } + + [Test] + public async Task TaskReturn_IsServedThroughProcessAsync() + { + var impl = new TaskContract(); + using (ServiceBinder.BindInterface(Session, impl)) + { + Assert.IsTrue(Services["Value"].Method.IsAsync); + Assert.AreEqual(RpcContextFlow.None, Services["Value"].Method.ContextFlow); + Assert.AreEqual(RpcContextFlow.Flow, Services["delayed"].Method.ContextFlow); + Assert.AreEqual(typeof(int), Services["Value"].Method.ResultType); + Assert.AreEqual(typeof(void), Services["Fire"].Method.ResultType); + + var value = JObject.Parse(await JsonRpcProcessor.ProcessAsync(Session, "{\"method\":\"Value\",\"params\":[41],\"id\":1}")); + Assert.AreEqual(42, (int)value["result"]); + var delayed = JObject.Parse(await JsonRpcProcessor.ProcessAsync(Session, "{\"method\":\"delayed\",\"params\":{\"text\":\"hi\"},\"id\":2}")); + Assert.AreEqual("hi!", (string)delayed["result"]); + var fire = JObject.Parse(await JsonRpcProcessor.ProcessAsync(Session, "{\"method\":\"Fire\",\"id\":3}")); + Assert.IsTrue(fire["result"].Type == JTokenType.Null, fire.ToString()); + Assert.AreEqual(1, impl.Fired); + + // The synchronous entry points refuse to run an asynchronous registration. + var sync = Call("Value", "[1]"); + Assert.AreEqual(-32603, (int)sync["error"]["code"]); + StringAssert.Contains("is asynchronous", (string)sync["error"]["message"]); + } + Absent("Value", "delayed", "Fire"); + } + + [Test] + public void AsyncVoidImplementation_IsRejected() + { + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new AsyncVoidContract())); + StringAssert.Contains("async void", ex.Message); + Absent("Fire"); + } + + private interface IDefault { int Default() => 1; } + private sealed class DefaultBody : IDefault { } + + [Test] + public void DefaultInterfaceBody_IsRejectedClearly() + { + var ex = Assert.Throws(() => ServiceBinder.BindInterface(Session, new DefaultBody())); + StringAssert.Contains("Default interface member", ex.Message); + Absent("Default"); + } + + [Test] + public void BuiltInNumericInvocation_AllocatesNothing() + { + using var binding = ServiceBinder.BindInterface(Session, new Adder()); + var serializer = SerializerCatalog.Create("jsmn"); + const string request = "{\"jsonrpc\":\"2.0\",\"method\":\"Add\",\"params\":[1,2],\"id\":1}"; + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":1}", Run(request, serializer)); + var memory = new ReadOnlyMemory(Encoding.UTF8.GetBytes(request)); + using var output = new PooledByteBufferWriter(256); + for (int i = 0; i < 500; i++) { output.Clear(); JsonRpcProcessor.Process(Session, memory, output, null, serializer); } + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 2000; i++) { output.Clear(); JsonRpcProcessor.Process(Session, memory, output, null, serializer); } + long total = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.AreEqual(0, total, "undivided allocation total across 2000 warmed requests"); + } + + private interface IProtocol : IAdd + { + int Throw(int value); + void Notify(); + string Context(); + } + private sealed class Protocol : IProtocol + { + public int Notifications; + public int Add(int x, int y) => x + y; + public int Throw(int value) => throw new FormatException("method failure"); + public void Notify() => Notifications++; + public string Context() => Handler.RpcRequestId() + "/" + JsonRpcContext.Current().Value; + } + + [TestCaseSource(nameof(Serializers))] + public void BatchNotificationsAndErrors_UseExistingProtocol(string name) + { + var serializer = SerializerCatalog.Create(name); + var target = new Protocol(); + using var binding = ServiceBinder.BindInterface(Session, target); + var batch = JArray.Parse(Run("[{\"method\":\"Add\",\"params\":[1,2],\"id\":1},{\"method\":\"Notify\"},{\"method\":\"Throw\",\"params\":[1],\"id\":2}]", serializer)); + Assert.AreEqual(2, batch.Count); + Assert.AreEqual(3, (int)batch[0]["result"]); + Assert.AreEqual(1, (int)batch[0]["id"]); + Assert.AreEqual(-32603, (int)batch[1]["error"]["code"]); + Assert.AreEqual(2, (int)batch[1]["id"]); + Assert.AreEqual(1, target.Notifications); + Assert.AreEqual("", Run("[{\"method\":\"Notify\"},{\"method\":\"Throw\",\"params\":[1]}]", serializer)); + Assert.AreEqual(2, target.Notifications); + var error = Call("Add", "{\"left\":1,\"right\":\"bad\"}", serializer)["error"]; + Assert.AreEqual(-32602, (int)error["code"]); + Assert.AreEqual("conversion", (string)error["data"]["reason"]); + Assert.AreEqual("right", (string)error["data"]["parameter"]); + Assert.AreEqual(1, (int)error["data"]["index"]); + Assert.AreEqual("int32", (string)error["data"]["expectedType"]); + } + + [TestCaseSource(nameof(Serializers))] + public void ContextAndRequestId_AreAvailable(string name) + { + using var binding = ServiceBinder.BindInterface(Session, new Protocol()); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"abc/ctx\",\"id\":\"abc\"}", Run("{\"method\":\"Context\",\"id\":\"abc\"}", SerializerCatalog.Create(name), "ctx")); + Assert.IsTrue(Handler.RpcRequestId().IsAbsent); + Assert.IsNull(Handler.RpcContext()); + } + + [TestCaseSource(nameof(Serializers))] + public void HookedRequests_UseTheSameMappedInvoker(string name) + { + using var binding = ServiceBinder.BindInterface(Session, new ExplicitAdder()); + Handler.GetSessionHandler(Session).SetPreProcessHandler((request, context) => null); + Assert.AreEqual(7, (int)Result("Add", "[3,4]", SerializerCatalog.Create(name))); + } + + [Test] + public void DefaultSessionOverload() + { + using var binding = ServiceBinder.BindInterface(new Adder(), new RpcInterfaceBindingOptions { Prefix = "iface.default." }); + Assert.AreEqual(Handler.DefaultSessionId(), binding.SessionId); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":1}", JsonRpcProcessor.ProcessSync("{\"method\":\"iface.default.Add\",\"params\":[1,2],\"id\":1}")); + } + + private interface ISpecialMembers + { + int Value(); + IAdd this[int index] { get; } + IAdd WriteOnly { set; } + event Action Changed; + static int Static() => 99; + private int Hidden() => 99; + } + private sealed class SpecialMembers : ISpecialMembers + { + public int Value() => 1; + public IAdd this[int index] => throw new InvalidOperationException(); + public IAdd WriteOnly { set => throw new InvalidOperationException(); } + public event Action Changed { add { } remove { } } + } + + [Test] + public void AccessorsEventsStaticsAndPrivateHelpers_AreExcluded() + { + using var binding = ServiceBinder.BindInterface(Session, new SpecialMembers()); + CollectionAssert.AreEqual(new[] { "Value" }, binding.Methods); + } + + [Test] + public void ConcurrentPublicationAndDisposal_ExposeWholeSnapshots() + { + ServiceBinder.BindMethod(Session, "before", () => 17); + var services = Services; + var table = typeof(SMDServiceCollection).GetField("_table", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(services); + var snapshotField = table.GetType().GetField("_snapshot", BindingFlags.NonPublic | BindingFlags.Instance); + var entriesField = snapshotField.FieldType.GetField("Entries"); + object firstSnapshot = snapshotField.GetValue(table); + int done = 0; + Exception failure = null; + using var started = new ManualResetEventSlim(); + using var observed = new ManualResetEventSlim(); + var worker = new Thread(() => + { + try + { + for (int i = 0; i < 12; i++) + { + using var binding = ServiceBinder.BindInterface(Session, new World()); + if (i == 0) + { + started.Set(); + if (!observed.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException(); + } + } + } + catch (Exception ex) { failure = ex; } + finally { Volatile.Write(ref done, 1); started.Set(); } + }); + worker.Start(); + try + { + Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(10))); + object published = snapshotField.GetValue(table); + Assert.AreEqual(8, ((Array)entriesField.GetValue(published)).Length); + observed.Set(); + while (Volatile.Read(ref done) == 0) + { + var snapshot = snapshotField.GetValue(table); + int count = ((Array)entriesField.GetValue(snapshot)).Length; + Assert.That(count, Is.EqualTo(1).Or.EqualTo(8), "one immutable table contains either the old registry or the whole tree"); + Assert.That(services.Count, Is.EqualTo(1).Or.EqualTo(8)); + } + Assert.AreEqual(8, ((Array)entriesField.GetValue(published)).Length, "published snapshots stay immutable after disposal"); + } + finally { observed.Set(); Assert.IsTrue(worker.Join(TimeSpan.FromSeconds(10))); } + Assert.IsNull(failure); + Assert.AreEqual(1, ((Array)entriesField.GetValue(firstSnapshot)).Length); + Assert.AreEqual(1, services.Count); + Assert.AreEqual(17, (int)Result("before")); + } + + [Test] + public void Discovery_IsInvisibleUntilAllGettersComplete() + { + ServiceBinder.BindMethod(Session, "before", () => 17); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + RpcBinding binding = null; + Exception failure = null; + var mount = new Mount { GetChild = () => { entered.Set(); if (!release.Wait(TimeSpan.FromSeconds(10))) throw new TimeoutException(); return new Adder(); } }; + var worker = new Thread(() => { try { binding = ServiceBinder.BindInterface(Session, mount); } catch (Exception ex) { failure = ex; } }); + worker.Start(); + try + { + Assert.IsTrue(entered.Wait(TimeSpan.FromSeconds(10))); + CollectionAssert.AreEqual(new[] { "before" }, Services.Keys); + Absent("Root", "Child.Add"); + Assert.AreEqual(17, (int)Result("before")); + } + finally { release.Set(); Assert.IsTrue(worker.Join(TimeSpan.FromSeconds(10))); } + Assert.IsNull(failure); + using (binding) + { + Assert.AreEqual(4, (int)Result("Root")); + Assert.AreEqual(3, (int)Result("Child.Add", "[1,2]")); + } + } + } +} diff --git a/AustinHarris.JsonRpcTestN/NewtonsoftTests.cs b/AustinHarris.JsonRpcTestN/NewtonsoftTests.cs new file mode 100644 index 0000000..58aa357 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/NewtonsoftTests.cs @@ -0,0 +1,307 @@ +using System; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Jsmn; +using AustinHarris.JsonRpc.Newtonsoft; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Behaviour that is specific to the Json.NET adapter: lenient request syntax, JsonSerializerSettings being + /// honoured, the settings-based process helpers, the Json.NET object model reaching handlers, and the + /// pooled read/write plumbing. + /// + [TestFixture] + public class NewtonsoftTests + { + private const string Ok7 = "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}"; + + [OneTimeSetUp] + public void SelectSerializer() + { + // CalculatorService is registered once, by the main fixture's static constructor; make sure that has run + RuntimeHelpers.RunClassConstructor(typeof(Test).TypeHandle); + Config.SetSerializer(new NewtonsoftJsonRpcSerializer()); + } + + [OneTimeTearDown] + public void RestoreSerializer() + { + Config.SetSerializer(null); + } + + // ------------------------------------------------------------------ leniency + + [Test] + public void LenientRequest_UnquotedKeysAndSingleQuotedStrings() + { + var result = JsonRpcProcessor.ProcessSync("{method:'IntToInt',params:[7],id:1}", null); + Assert.AreEqual(Ok7, result); + } + + [Test] + public void LenientRequest_SingleQuotedStringParamAndId() + { + var result = JsonRpcProcessor.ProcessSync("{method:'internal.echo',params:['hi there'],id:'abc'}", null); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"hi there\",\"id\":\"abc\"}", result); + } + + [Test] + public void LenientRequest_SingleQuotedNamedParams() + { + var result = JsonRpcProcessor.ProcessSync("{method:'TestOptionalParamchar',params:{input:'z'},id:1}", null); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"z\",\"id\":1}", result); + } + + [Test] + public void LenientRequest_IsRejectedByTheStrictBuiltInSerializer() + { + var result = JsonRpcProcessor.ProcessSync(Handler.DefaultSessionId(), "{method:'IntToInt',params:[7],id:1}", null, JsmnSerializer.Instance); + StringAssert.Contains("-32700", result); + } + + // ------------------------------------------------------------------ settings + + [Test] + public void Settings_DateFormatString_IsHonoured() + { + var settings = new JsonSerializerSettings { DateFormatString = "yyyy-MM-dd" }; + var serializer = new NewtonsoftJsonRpcSerializer(settings); + var result = JsonRpcProcessor.ProcessSync(Handler.DefaultSessionId(), "{\"method\":\"ReturnsDateTime\",\"params\":[],\"id\":1}", null, serializer); + Assert.IsTrue(Regex.IsMatch(result, "^\\{\"jsonrpc\":\"2.0\",\"result\":\"\\d{4}-\\d{2}-\\d{2}\",\"id\":1\\}$"), result); + } + + [Test] + public void Settings_Converter_IsHonouredOnReadAndWrite() + { + var settings = new JsonSerializerSettings { Converters = { new ShoutingStringConverter() } }; + var serializer = new NewtonsoftJsonRpcSerializer(settings); + // read appends "!" then echo returns it, write upper-cases it + var result = JsonRpcProcessor.ProcessSync(Handler.DefaultSessionId(), "{\"method\":\"internal.echo\",\"params\":[\"abc\"],\"id\":1}", null, serializer); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"ABC!\",\"id\":1}", result); + } + + [Test] + public void Settings_PerSession_OverridesGlobal() + { + var sessionId = "newtonsoft per-session"; + try + { + var h = Handler.GetSessionHandler(sessionId); + h.RegisterFuction("echo", new System.Collections.Generic.Dictionary { { "s", typeof(string) }, { "returns", typeof(string) } }, null, new Func(s => s)); + h.Serializer = new NewtonsoftJsonRpcSerializer(new JsonSerializerSettings { Converters = { new ShoutingStringConverter() } }); + // four arguments on purpose: ProcessSync(sessionId, json, null) binds to the (jsonRpc, context, serializer) overload + var result = JsonRpcProcessor.ProcessSync(sessionId, "{\"method\":\"echo\",\"params\":[\"abc\"],\"id\":1}", null, null); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"ABC!\",\"id\":1}", result); + } + finally + { + Handler.DestroySession(sessionId); + } + } + + [Test] + public void Settings_DefaultsProduceTheSharedWireFormat() + { + var s = new NewtonsoftJsonRpcSerializer(); + Assert.AreEqual("3.0", s.Serialize(3.0)); + Assert.AreEqual("71.0", s.Serialize(71f)); + Assert.AreEqual("71.0", s.Serialize(71m)); + Assert.AreEqual("1.2345", s.Serialize(1.2345f)); + Assert.AreEqual("\"x\"", s.Serialize('x')); + Assert.AreEqual("null", s.Serialize(null)); + // ISO-8601 with the offset; Json.NET drops trailing zeros of the fraction (the built-in serializer always writes seven digits) + Assert.AreEqual("\"2020-01-02T03:04:05.1234567Z\"", s.Serialize(new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc).AddTicks(1234567))); + Assert.AreEqual("\"2020-01-02T03:04:05Z\"", s.Serialize(new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc))); + Assert.AreEqual("{\"str\":null}", s.Serialize(new CalculatorService.CustomString())); + Assert.AreEqual("{\"NodeId\":1,\"Leafs\":null}", s.Serialize(new TreeNode { NodeId = 1 })); + } + + // ------------------------------------------------------------------ settings-based helpers + + [Test] + public void ProcessSyncHelper_UsesSettings() + { + var settings = new JsonSerializerSettings { DateFormatString = "yyyy" }; + var result = NewtonsoftJsonRpc.ProcessSync(Handler.DefaultSessionId(), "{\"method\":\"ReturnsDateTime\",\"params\":[],\"id\":1}", null, settings); + Assert.IsTrue(Regex.IsMatch(result, "^\\{\"jsonrpc\":\"2.0\",\"result\":\"\\d{4}\",\"id\":1\\}$"), result); + } + + [Test] + public void ProcessHelper_UsesSettings() + { + var settings = new JsonSerializerSettings { Converters = { new ShoutingStringConverter() } }; + var task = NewtonsoftJsonRpc.Process("{\"method\":\"internal.echo\",\"params\":[\"abc\"],\"id\":1}", null, settings); + task.Wait(); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"ABC!\",\"id\":1}", task.Result); + } + + [Test] + public void Helper_NullSettings_UsesJsonNetDefaults() + { + Assert.AreEqual(Ok7, NewtonsoftJsonRpc.ProcessSync("{\"method\":\"IntToInt\",\"params\":[7],\"id\":1}", null, null)); + } + + [Test] + public void Helper_CachesOneSerializerPerSettingsInstance() + { + var settings = new JsonSerializerSettings(); + Assert.AreSame(NewtonsoftJsonRpc.SerializerFor(settings), NewtonsoftJsonRpc.SerializerFor(settings)); + Assert.AreNotSame(NewtonsoftJsonRpc.SerializerFor(settings), NewtonsoftJsonRpc.SerializerFor(new JsonSerializerSettings())); + Assert.AreSame(NewtonsoftJsonRpc.SerializerFor(null), NewtonsoftJsonRpc.SerializerFor(null)); + } + + // ------------------------------------------------------------------ object model + + [Test] + public void PreProcessHandler_ReceivesJsonNetObjectModel() + { + object seenParams = null; + Config.SetPreProcessHandler((rpc, ctx) => { seenParams = rpc.Params; return null; }); + try + { + Assert.AreEqual(Ok7, JsonRpcProcessor.ProcessSync("{\"method\":\"IntToInt\",\"params\":{\"input\":7},\"id\":1}", null)); + Assert.IsInstanceOf(seenParams); + Assert.AreEqual(7, (int)((JObject)seenParams)["input"]); + + Assert.AreEqual(Ok7, JsonRpcProcessor.ProcessSync("{\"method\":\"IntToInt\",\"params\":[7],\"id\":1}", null)); + Assert.IsInstanceOf(seenParams); + } + finally + { + Config.SetPreProcessHandler(null); + } + } + + [Test] + public void HandlerHandle_RoundTripsJsonRequestThroughJsonNet() + { + var response = Handler.DefaultHandler.Handle(new JsonRequest("IntToInt", new JArray(5), 9L)); + Assert.IsNull(response.Error); + Assert.AreEqual(5, response.Result); + Assert.AreEqual(9L, response.Id); + + response = Handler.DefaultHandler.Handle(new JsonRequest("IntToInt", new JObject { ["input"] = 6 }, "x")); + Assert.IsNull(response.Error); + Assert.AreEqual(6, response.Result); + Assert.AreEqual("x", response.Id); + } + + [Test] + public void ReadObject_MatchesJsonConvertDeserializeObject() + { + var s = new NewtonsoftJsonRpcSerializer(); + Assert.AreEqual(12L, s.Deserialize("12", typeof(object))); + Assert.AreEqual("abc", s.Deserialize("\"abc\"", typeof(object))); + Assert.AreEqual("abc", s.Deserialize("'abc'", typeof(object))); + Assert.IsNull(s.Deserialize("null", typeof(object))); + Assert.IsInstanceOf(s.Deserialize("{\"a\":1}", typeof(object))); + Assert.IsInstanceOf(s.Deserialize("[1,2]", typeof(object))); + Assert.AreEqual(true, s.Deserialize("true", typeof(object))); + } + + [Test] + public void Read_ConversionFailure_Throws() + { + var s = new NewtonsoftJsonRpcSerializer(); + Assert.Catch(() => s.Deserialize("\"abc\"")); + Assert.Catch(() => s.Deserialize("null")); + Assert.Catch(() => s.Deserialize("[1]")); + } + + // ------------------------------------------------------------------ plumbing + + [Test] + public void Write_LongNonAsciiString_RoundTripsThroughTheChunkedEncoder() + { + var s = new NewtonsoftJsonRpcSerializer(); + var sb = new StringBuilder(); + for (int i = 0; i < 20000; i++) sb.Append("aé€\U0001F600\"\\\n"); + var text = sb.ToString(); + var json = s.Serialize(text); + Assert.AreEqual(text, JsonConvert.DeserializeObject(json)); + Assert.AreEqual(text, s.Deserialize(json)); + } + + [Test] + public void Write_IsReusableAfterAFailedWrite() + { + var s = new NewtonsoftJsonRpcSerializer(); + Assert.Catch(() => s.Serialize(new Explodes())); + Assert.AreEqual("[1,2]", s.Serialize(new[] { 1, 2 })); + Assert.AreEqual("{\"str\":\"ok\"}", s.Serialize(new CalculatorService.CustomString { str = "ok" })); + } + + [Test] + public void Write_ReentrantFromAConverter_Works() + { + // the converter list is snapshotted when the serializer is built, so register the converter first and bind it afterwards + var converter = new ReentrantConverter(); + var s = new NewtonsoftJsonRpcSerializer(new JsonSerializerSettings { Converters = { converter } }); + converter.Rpc = s; + Assert.AreEqual("{\"inner\":{\"str\":\"in\"}}", s.Serialize(new Wrapped { Inner = new CalculatorService.CustomString { str = "in" } })); + Assert.AreEqual("[1]", s.Serialize(new[] { 1 })); + } + + [Test] + public void Read_ManyValuesOnOneThread_ReusesTheDecoder() + { + var s = new NewtonsoftJsonRpcSerializer(); + for (int i = 0; i < 1000; i++) + { + Assert.AreEqual(i, s.Deserialize(i.ToString())); + Assert.AreEqual("v" + i, s.Deserialize("\"v" + i + "\"")); + } + } + + // ------------------------------------------------------------------ helpers + + private sealed class ShoutingStringConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, string value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToUpperInvariant()); + } + + public override string ReadJson(JsonReader reader, Type objectType, string existingValue, bool hasExistingValue, JsonSerializer serializer) + { + return reader.Value == null ? null : Convert.ToString(reader.Value) + "!"; + } + } + + private sealed class Explodes + { + public int Fine => 1; + public int Boom => throw new InvalidOperationException("boom"); + } + + private sealed class Wrapped + { + public CalculatorService.CustomString Inner { get; set; } + } + + /// Serializes the wrapped value by calling back into the RPC serializer from inside a write. + private sealed class ReentrantConverter : JsonConverter + { + public NewtonsoftJsonRpcSerializer Rpc { get; set; } + + public override void WriteJson(JsonWriter writer, Wrapped value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("inner"); + writer.WriteRawValue(Rpc.Serialize(value.Inner)); + writer.WriteEndObject(); + } + + public override Wrapped ReadJson(JsonReader reader, Type objectType, Wrapped existingValue, bool hasExistingValue, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + } + } +} diff --git a/AustinHarris.JsonRpcTestN/ParserHardeningTests.cs b/AustinHarris.JsonRpcTestN/ParserHardeningTests.cs new file mode 100644 index 0000000..5a5eef7 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/ParserHardeningTests.cs @@ -0,0 +1,569 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Jsmn; +using AustinHarris.JsonRpc.Newtonsoft; +using AustinHarris.JsonRpc.Serialization; +using AustinHarris.JsonRpc.SystemTextJson; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Parser hardening from the 2026-09-23 review: findings 1 (nesting depth, O(1) container close), + /// 2 (strict grammar and UTF-8 validation), 8 (lenient ids aliasing the name-decode buffer) and + /// 15 (escaped envelope member names). The envelope reader is shared by every serializer, so each case + /// runs against strict jsmn, lenient jsmn, Json.NET (lenient) and System.Text.Json (strict). + /// + [TestFixture] + public class ParserHardeningTests + { + private const string Session = "parser-hardening"; + private const string Ok7 = "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}"; + + public static int CountedCalls; + + /// A plain class: JsonRpcService's constructor would bind it to the default session. + public class HardeningService + { + [JsonRpcMethod] public int ping() => 7; + [JsonRpcMethod] public int counted() { Interlocked.Increment(ref CountedCalls); return 7; } + [JsonRpcMethod] public string echo(string s) => s; + [JsonRpcMethod] public int accept(object o) => 7; + [JsonRpcMethod] public string named(string a, string b) => a + "|" + b; + } + + private static readonly object BindLock = new object(); + private static bool _bound; + + [OneTimeSetUp] + public void Bind() + { + lock (BindLock) + { + if (_bound) return; + ServiceBinder.BindService(Session, new HardeningService()); + _bound = true; + } + } + + public static IEnumerable Serializers() + { + yield return new TestCaseData(JsmnSerializer.Instance).SetArgDisplayNames("jsmn"); + yield return new TestCaseData(new JsmnSerializer(lenient: true)).SetArgDisplayNames("jsmn-lenient"); + yield return new TestCaseData(new NewtonsoftJsonRpcSerializer()).SetArgDisplayNames("newtonsoft"); + yield return new TestCaseData(new SystemTextJsonRpcSerializer()).SetArgDisplayNames("stj"); + } + + public static IEnumerable LenientSerializers() + { + yield return new TestCaseData(new JsmnSerializer(lenient: true)).SetArgDisplayNames("jsmn-lenient"); + yield return new TestCaseData(new NewtonsoftJsonRpcSerializer()).SetArgDisplayNames("newtonsoft"); + } + + // ------------------------------------------------------------------ helpers + + private static string Run(JsonRpcSerializer s, string json) => JsonRpcProcessor.ProcessSync(Session, json, null, s); + + private static string Run(JsonRpcSerializer s, byte[] utf8) + { + var w = new ArrayBufferWriter(); + JsonRpcProcessor.Process(Session, new ReadOnlyMemory(utf8), w, null, s); + return Encoding.UTF8.GetString(w.WrittenSpan); + } + + private static byte[] Bytes(params object[] parts) + { + var list = new List(); + foreach (var p in parts) + { + if (p is string str) list.AddRange(Encoding.UTF8.GetBytes(str)); + else if (p is byte[] arr) list.AddRange(arr); + else if (p is byte b) list.Add(b); + else if (p is int i) list.Add(checked((byte)i)); + else throw new ArgumentException(p.GetType().Name); + } + return list.ToArray(); + } + + private static void AssertParseError(string response, string detail = null, string because = null) + { + StringAssert.Contains("\"code\":-32700", response, because); + StringAssert.EndsWith("\"id\":null}", response, because); + if (detail != null) StringAssert.Contains(detail, response, because); + } + + private static string ResultOf(string response) + { + StringAssert.DoesNotContain("\"error\"", response); + return JObject.Parse(response)["result"].Value(); + } + + /// {"method":…,"unused":[[[…0…]]],"id":1} with open containers including the root object. + private static string Nested(int depth, string method = "ping") + { + return "{\"method\":\"" + method + "\",\"unused\":" + new string('[', depth - 1) + "0" + new string(']', depth - 1) + ",\"id\":1}"; + } + + // ------------------------------------------------------------------ finding 1: nesting depth + + [Test] + public void Depth_DefaultIs64() + { + Assert.AreEqual(64, JsmnTokenizer.DefaultMaxDepth); + Assert.AreEqual(64, JsmnSerializer.Instance.MaxDepth); + Assert.AreEqual(64, new JsmnSerializer(lenient: true).MaxDepth); + } + + [TestCaseSource(nameof(Serializers))] + public void Depth_AtDefaultLimit_Dispatches(JsonRpcSerializer s) + { + Assert.AreEqual(Ok7, Run(s, Nested(64))); + } + + [TestCaseSource(nameof(Serializers))] + public void Depth_OverDefaultLimit_IsParseErrorAndDoesNotDispatch(JsonRpcSerializer s) + { + int before = CountedCalls; + var r = Run(s, Nested(65, "counted")); + AssertParseError(r, "depth"); + StringAssert.Contains("64", r); + Assert.AreEqual(before, CountedCalls, "the method must not run"); + } + + [Test] + public void Depth_ConfigurableOnJsmnSerializer() + { + var s = new JsmnSerializer(lenient: false, maxDepth: 8); + Assert.AreEqual(8, s.MaxDepth); + Assert.AreEqual(Ok7, Run(s, Nested(8))); + AssertParseError(Run(s, Nested(9)), "depth of 8"); + Assert.Throws(() => new JsmnSerializer(false, 0)); + } + + [Test] + public void Depth_FollowsTheSerializerOptions() + { + // JsonRpcSerializer.MaxDepth is virtual: the envelope reader enforces whatever limit the JSON library + // itself was configured with, so an envelope is never accepted that the value converter would reject + var stj = new SystemTextJsonRpcSerializer(new System.Text.Json.JsonSerializerOptions { MaxDepth = 128 }); + Assert.AreEqual(128, stj.MaxDepth); + Assert.AreEqual(Ok7, Run(stj, Nested(100))); + AssertParseError(Run(stj, Nested(129)), "depth of 128"); + + var nsj = new NewtonsoftJsonRpcSerializer(new Newtonsoft.Json.JsonSerializerSettings { MaxDepth = 8 }); + Assert.AreEqual(8, nsj.MaxDepth); + Assert.AreEqual(Ok7, Run(nsj, Nested(8))); + AssertParseError(Run(nsj, Nested(9)), "depth of 8"); + + Assert.AreEqual(64, new SystemTextJsonRpcSerializer().MaxDepth); + Assert.AreEqual(64, new NewtonsoftJsonRpcSerializer().MaxDepth); + } + + [Test] + public void Depth_ValueReaderHonoursLimit() + { + // Read/Deserialize tokenize with the same limit, so a bare value is bounded as well + var s = new JsmnSerializer(lenient: false, maxDepth: 4); + Assert.IsNotNull(s.Deserialize("[[[[1]]]]")); + var ex = Assert.Throws(() => s.Deserialize("[[[[[1]]]]]")); + StringAssert.Contains("depth", ex.Message); + } + + [TestCaseSource(nameof(Serializers))] + public void Depth_BoundsRecursiveParameterBinding(JsonRpcSerializer s) + { + // accept(object) descends the value recursively (JsmnMapper.ReadDynamic for jsmn); the tokenizer's + // limit runs before any binding, so the recursion can never exceed MaxDepth + string Deep(int arrays) => "{\"method\":\"accept\",\"params\":[" + new string('[', arrays) + "1" + new string(']', arrays) + "],\"id\":1}"; + Assert.AreEqual(Ok7, Run(s, Deep(62))); // root + params + 62 = 64 + AssertParseError(Run(s, Deep(63)), "depth"); + } + + [Test] + public void Depth_1000To8000_RejectedQuickly() + { + foreach (int depth in new[] { 1000, 2000, 4000, 8000 }) + { + var json = Nested(depth); + var sw = Stopwatch.StartNew(); + var r = Run(JsmnSerializer.Instance, json); + sw.Stop(); + AssertParseError(r, "depth", "depth " + depth); + Assert.Less(sw.ElapsedMilliseconds, 100, "depth " + depth); + } + } + + [Test] + public void Depth_Raised_ParseTimeGrowsLinearly() + { + var s = new JsmnSerializer(lenient: false, maxDepth: 10000); + foreach (int depth in new[] { 1000, 2000, 4000, 8000 }) + Assert.AreEqual(Ok7, Run(s, Nested(depth)), "depth " + depth); + + var tok = new JsmnTokenizer { MaxDepth = 10000 }; + double t1000 = MinTicks(tok, Encoding.UTF8.GetBytes(Nested(1000))); + double t8000 = MinTicks(tok, Encoding.UTF8.GetBytes(Nested(8000))); + // 8x the input: linear ~8x; the old parent-chain walk was quadratic (~64x, measured 18-40x) + Assert.Less(t8000 / t1000, 32.0, "1000 deep: " + t1000 + " ticks, 8000 deep: " + t8000 + " ticks"); + } + + private static double MinTicks(JsmnTokenizer tok, byte[] doc) + { + const int Inner = 20; + long best = long.MaxValue; + for (int rep = 0; rep < 15; rep++) + { + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Inner; i++) + { + if (tok.Parse(doc) <= 0) Assert.Fail("parse failed"); + } + sw.Stop(); + if (sw.ElapsedTicks < best) best = sw.ElapsedTicks; + } + return best; + } + + // ------------------------------------------------------------------ finding 2: grammar + + private static readonly string[] ValidInAllModes = + { + "{\"method\":\"ping\",\"id\":1}", + " \r\n\t{\"method\":\"ping\",\"id\":1}\r\n ", + "{\"Method\":\"ping\",\"ID\":1}", + "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"params\":[],\"id\":1}", + "{\"method\":\"ping\",\"params\":null,\"id\":1}", + "{\"method\":\"ping\",\"ignored\":[true,false,null,-0,0,1.5,-1.5e+3,2E-2,1e10,0.5e-3,\"\\u00e9\\n\\\"\\\\\\/\\b\\f\\r\\t\"],\"id\":1}", + "{\"method\":\"ping\",\"ignored\":{\"a\":{},\"b\":[],\"c\":[{}],\"d\":\"\",\"e\":[[],[[]]]},\"id\":1}", + "{\"method\":\"ping\",\"ignored\":\"\\uD83D\\uDE00 \u00e9 \u20ac \U0001F600 \u007f\",\"id\":1}", + "{ \"method\" : \"ping\" , \"ignored\" : [ 1 , 2 ] , \"id\" : 1 }", + }; + + [TestCaseSource(nameof(Serializers))] + public void Grammar_ValidDocumentsStillDispatch(JsonRpcSerializer s) + { + foreach (var json in ValidInAllModes) + Assert.AreEqual(Ok7, Run(s, json), json); + } + + private static readonly string[] InvalidInAllModes = + { + // trailing content after the single root + "{\"method\":\"ping\",\"id\":1}{}", + "{\"method\":\"ping\",\"id\":1}x", + "{\"method\":\"ping\",\"id\":1} 2", + "{\"method\":\"ping\",\"id\":1}\"\"", + "[{\"method\":\"ping\",\"id\":1}][]", + // literals must be exactly true/false/null + "{\"method\":\"ping\",\"ignored\":truX,\"id\":1}", + "{\"method\":\"ping\",\"ignored\":tru,\"id\":1}", + "{\"method\":\"ping\",\"ignored\":True,\"id\":1}", + "{\"method\":\"ping\",\"ignored\":truee,\"id\":1}", + "{\"method\":\"ping\",\"ignored\":nul,\"id\":1}", + "{\"method\":\"ping\",\"ignored\":fals,\"id\":1}", + "{\"method\":\"ping\",\"ignored\":falsey,\"id\":1}", + "{\"method\":\"ping\",\"id\":nxxx}", + // number grammar + "{\"method\":\"ping\",\"id\":01}", + "{\"method\":\"ping\",\"id\":-}", + "{\"method\":\"ping\",\"id\":1.}", + "{\"method\":\"ping\",\"id\":.5}", + "{\"method\":\"ping\",\"id\":+1}", + "{\"method\":\"ping\",\"id\":1e}", + "{\"method\":\"ping\",\"id\":1e+}", + "{\"method\":\"ping\",\"id\":1.5.5}", + "{\"method\":\"ping\",\"id\":0x10}", + "{\"method\":\"ping\",\"id\":1x}", + "{\"method\":\"ping\",\"id\":--1}", + // separators and structure + "{\"method\":\"ping\",\"id\":1 2}", + "{\"method\":\"ping\",\"id\":1,,}", + "{\"method\":\"ping\",,\"id\":1}", + "{,\"method\":\"ping\",\"id\":1}", + "{\"method\" \"ping\",\"id\":1}", + "{\"method\"::\"ping\",\"id\":1}", + "{\"method\":,\"id\":1}", + "{\"method\":\"ping\",\"id\":}", + "{\"method\":\"ping\" \"id\":1}", + "{:\"ping\",\"id\":1}", + "{\"method\":\"ping\",\"params\":[1 2],\"id\":1}", + "{\"method\":\"ping\",\"params\":[\"a\":1],\"id\":1}", + "{\"method\":\"ping\",\"params\":[1},\"id\":1}", + "{\"method\":\"ping\",\"params\":{\"a\":1],\"id\":1}", + "{\"method\":\"ping\",\"params\":{\"a\"},\"id\":1}", + "{\"method\":\"ping\",\"params\":[],\"id\":1", + "{\"method\":\"ping\",\"params\":[,],\"id\":1}", + "[,]", + "]", + "}", + // strings: unescaped control characters and bad escapes + "{\"method\":\"echo\",\"params\":[\"a\tb\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"a\nb\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"a\u0001b\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"\\x\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"\\u12\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"\\uZZZZ\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"\\uD800\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"\\uDC00\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"\\uD800\\u0041\"],\"id\":1}", + "{\"method\":\"echo\",\"params\":[\"\\'\"],\"id\":1}", + }; + + [TestCaseSource(nameof(Serializers))] + public void Grammar_InvalidDocumentsAreParseErrors(JsonRpcSerializer s) + { + foreach (var json in InvalidInAllModes) + { + // the lenient configurations accept \' inside strings; every other case is invalid for everyone + if (s.Lenient && json.Contains("\\'")) continue; + AssertParseError(Run(s, json), null, json); + } + } + + private static readonly string[] LenientOnlySyntax = + { + "{\"method\":\"ping\",\"id\":1,}", + "{\"method\":\"ping\",\"ignored\":[1,],\"id\":1}", + "{\"method\":\"ping\",\"ignored\":{\"a\":1,},\"id\":1}", + "{method:\"ping\",id:1}", + "{'method':'ping','id':1}", + "{method:'ping',ignored:'it\\'s',id:1}", + "{\"method\":\"ping\",\"ignored\":{1:2},\"id\":1}", // a bare-word member name + }; + + [TestCaseSource(nameof(Serializers))] + public void Grammar_LenientSyntaxIsRejectedInStrictModeOnly(JsonRpcSerializer s) + { + foreach (var json in LenientOnlySyntax) + { + var r = Run(s, json); + if (s.Lenient) Assert.AreEqual(Ok7, r, json); + else AssertParseError(r, null, json); + } + } + + private static readonly string[] InvalidEvenWhenLenient = + { + "{method:'ping',id:1}{}", + "{method:'ping',id:1}x", + "{method:'ping',ignored:truX,id:1}", + "{method:'ping',ignored:[abc],id:1}", // a bare word is only a member name, never a value + "{method:'ping',ignored:undefined,id:1}", + "{method:'ping',id:01}", + "{method:'ping',id:nxxx}", + "{method:'ping',id:1.}", + "{method:'echo',params:['a\tb'],id:1}", + "{method:'echo',params:['\\uD800'],id:1}", + "{method:'echo',params:['\\q'],id:1}", + "{method:'ping',,id:1}", + "{,method:'ping',id:1}", + "{method:'ping',id:1,,}", + "{method 'ping',id:1}", + "{method:'ping',id}", + "[,]", + }; + + [TestCaseSource(nameof(LenientSerializers))] + public void Grammar_LenientModeStillValidatesLiteralsNumbersAndStrings(JsonRpcSerializer s) + { + foreach (var json in InvalidEvenWhenLenient) + AssertParseError(Run(s, json), null, json); + } + + [TestCaseSource(nameof(Serializers))] + public void Grammar_TrailingCommaInBatch(JsonRpcSerializer s) + { + var r = Run(s, "[{\"method\":\"ping\",\"id\":1},]"); + if (s.Lenient) StringAssert.Contains("\"result\":7", r); + else AssertParseError(r); + } + + [TestCaseSource(nameof(Serializers))] + public void Grammar_NoStateLeaksBetweenDocuments(JsonRpcSerializer s) + { + // the pooled reader is reused per thread: a rejected document must not affect the next one + AssertParseError(Run(s, "{\"method\":\"ping\",\"id\":1}{}")); + Assert.AreEqual(Ok7, Run(s, "{\"method\":\"ping\",\"id\":1}")); + AssertParseError(Run(s, Nested(65))); + Assert.AreEqual(Ok7, Run(s, Nested(64))); + AssertParseError(Run(s, "{\"method\":\"ping\",\"params\":[[[[1")); + Assert.AreEqual(Ok7, Run(s, "{\"method\":\"ping\",\"id\":1}")); + } + + // ------------------------------------------------------------------ finding 2: UTF-8 + + private static IEnumerable InvalidUtf8InString() + { + byte[] Doc(params byte[] id) => Bytes("{\"method\":\"echo\",\"params\":[\"x\"],\"id\":\"", id, "\"}"); + yield return Doc(0xFF); + yield return Doc(0x61, 0xFF, 0x62); + yield return Doc(0xFE); + yield return Doc(0x80); // stray continuation byte + yield return Doc(0xBF); + yield return Doc(0xC0, 0x80); // overlong 2-byte + yield return Doc(0xC1, 0xBF); + yield return Doc(0xC2, 0x41); // bad continuation + yield return Doc(0xC2); // truncated by the closing quote + yield return Doc(0xE0, 0x80, 0x80); // overlong 3-byte + yield return Doc(0xE0, 0x9F, 0xBF); + yield return Doc(0xE2, 0x82); // truncated 3-byte + yield return Doc(0xE2, 0x82, 0x41); + yield return Doc(0xED, 0xA0, 0x80); // UTF-16 surrogate U+D800 + yield return Doc(0xED, 0xBF, 0xBF); + yield return Doc(0xF0, 0x80, 0x80, 0x80); // overlong 4-byte + yield return Doc(0xF0, 0x8F, 0xBF, 0xBF); + yield return Doc(0xF0, 0x9F, 0x98); // truncated 4-byte + yield return Doc(0xF4, 0x90, 0x80, 0x80); // above U+10FFFF + yield return Doc(0xF5, 0x80, 0x80, 0x80); + yield return Doc(0xF8, 0x80, 0x80, 0x80, 0x80); + } + + [TestCaseSource(nameof(Serializers))] + public void Utf8_InvalidSequencesInsideStringsAreParseErrors(JsonRpcSerializer s) + { + foreach (var doc in InvalidUtf8InString()) + AssertParseError(Run(s, doc), null, BitConverter.ToString(doc)); + } + + [TestCaseSource(nameof(LenientSerializers))] + public void Utf8_InvalidSequencesInsideSingleQuotedStringsAreParseErrors(JsonRpcSerializer s) + { + AssertParseError(Run(s, Bytes("{method:'echo',params:['x'],id:'a", 0xFF, "b'}"))); + AssertParseError(Run(s, Bytes("{method:'echo',params:['a", 0xC0, 0x80, "'],id:1}"))); + AssertParseError(Run(s, Bytes("{method:'echo',params:['x'],id:'", 0xED, 0xA0, 0x80, "'}"))); + } + + [TestCaseSource(nameof(Serializers))] + public void Utf8_InvalidBytesOutsideStringsAreParseErrors(JsonRpcSerializer s) + { + AssertParseError(Run(s, Bytes("{\"method\":\"ping\",\"id\":1}", 0xC2, 0xA0))); // NBSP after the root + AssertParseError(Run(s, Bytes("{\"method\":\"ping\",", 0xC2, 0xA0, "\"id\":1}"))); + AssertParseError(Run(s, Bytes("{\"method\":\"ping\",\"id\":", 0xFF, "}"))); + } + + [TestCaseSource(nameof(Serializers))] + public void Utf8_WellFormedSequencesRoundTrip(JsonRpcSerializer s) + { + const string text = "h\u00e9llo \u20ac \U0001F600 \u0800 \uFFFD \uD7FF \uE000 \U0010FFFF"; + var r = Run(s, Bytes("{\"method\":\"echo\",\"params\":[\"", text, "\"],\"id\":1}")); + Assert.AreEqual(text, ResultOf(r)); + // the same text as a string id is echoed byte for byte + var raw = Encoding.UTF8.GetBytes(text); + var r2 = Run(s, Bytes("{\"method\":\"ping\",\"id\":\"", raw, "\"}")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":\"" + text + "\"}", r2); + } + + // ------------------------------------------------------------------ finding 8: lenient ids and the decode buffer + + [TestCaseSource(nameof(LenientSerializers))] + public void LenientId_SurvivesEscapedMethodName(JsonRpcSerializer s) + { + var r = Run(s, "{method:'\\u0065cho',params:['x'],id:'abc'}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":\"abc\"}", r); + } + + [TestCaseSource(nameof(LenientSerializers))] + public void LenientId_SurvivesEscapedMethodAndParameterNames(JsonRpcSerializer s) + { + var r = Run(s, "{method:'n\\u0061med',params:{'\\u0061':'1',b:'2'},id:'abc'}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"1|2\",\"id\":\"abc\"}", r); + // decoded parameter names that are long enough to outgrow the initial decode buffer + var longName = new string('p', 100); + var r2 = Run(s, "{method:'\\u0065cho',params:{'\\u0073':'v'},id:'" + longName + "'}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"v\",\"id\":\"" + longName + "\"}", r2); + } + + [TestCaseSource(nameof(LenientSerializers))] + public void LenientId_ShortAndLongIds(JsonRpcSerializer s) + { + foreach (var id in new[] { "a", "ab", "abc", new string('k', 63), new string('k', 64), new string('k', 65), new string('k', 500) }) + { + var r = Run(s, "{method:'\\u0065cho',params:{'\\u0073':'v'},id:'" + id + "'}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"v\",\"id\":\"" + id + "\"}", r, "id length " + id.Length); + } + } + + [TestCaseSource(nameof(LenientSerializers))] + public void LenientId_EscapesInsideSingleQuotesAreNormalised(JsonRpcSerializer s) + { + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":\"abc\"}", Run(s, "{method:'\\u0065cho',params:['x'],id:'a\\u0062c'}")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":\"say \\\"hi\\\"\"}", Run(s, "{method:'\\u0065cho',params:['x'],id:'say \"hi\"'}")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":\"a\\\\b\\nc\"}", Run(s, "{method:'\\u0065cho',params:['x'],id:'a\\\\b\\nc'}")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":\"it's\"}", Run(s, "{method:'\\u0065cho',params:['x'],id:'it\\'s'}")); + } + + [TestCaseSource(nameof(LenientSerializers))] + public void LenientId_InBatchWithEscapedMethods(JsonRpcSerializer s) + { + var r = Run(s, "[{method:'\\u0065cho',params:['x'],id:'abc'},{method:'\\u0065cho',params:['y'],id:'de'},{method:'n\\u0061med',params:{'\\u0061':'1',b:'2'},id:'f'}]"); + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":\"abc\"},{\"jsonrpc\":\"2.0\",\"result\":\"y\",\"id\":\"de\"},{\"jsonrpc\":\"2.0\",\"result\":\"1|2\",\"id\":\"f\"}]", r); + } + + [TestCaseSource(nameof(Serializers))] + public void EscapedDoubleQuotedIdIsEchoedByteForByte(JsonRpcSerializer s) + { + var r = Run(s, "{\"method\":\"\\u0065cho\",\"params\":{\"\\u0073\":\"x\"},\"id\":\"a\\u0062c\"}"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":\"a\\u0062c\"}", r); + } + + // ------------------------------------------------------------------ finding 15: escaped envelope keys + + [TestCaseSource(nameof(Serializers))] + public void EscapedKeys_Method(JsonRpcSerializer s) + { + Assert.AreEqual(Ok7, Run(s, "{\"m\\u0065thod\":\"ping\",\"id\":1}")); + Assert.AreEqual(Ok7, Run(s, "{\"\\u006D\\u0065\\u0074\\u0068\\u006F\\u0064\":\"ping\",\"id\":1}")); + } + + [TestCaseSource(nameof(Serializers))] + public void EscapedKeys_ParamsAndId(JsonRpcSerializer s) + { + const string expected = "{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":1}"; + Assert.AreEqual(expected, Run(s, "{\"method\":\"echo\",\"p\\u0061rams\":[\"x\"],\"\\u0069d\":1}")); + Assert.AreEqual(expected, Run(s, "{\"\\u006Aso\\u006Erpc\":\"2.0\",\"m\\u0065thod\":\"echo\",\"\\u0070\\u0061\\u0072\\u0061\\u006D\\u0073\":{\"\\u0073\":\"x\"},\"\\u0069\\u0064\":1}")); + } + + [TestCaseSource(nameof(Serializers))] + public void EscapedKeys_CaseInsensitiveAfterDecoding(JsonRpcSerializer s) + { + Assert.AreEqual(Ok7, Run(s, "{\"M\\u0045thod\":\"ping\",\"\\u0049D\":1}")); + } + + [TestCaseSource(nameof(Serializers))] + public void EscapedKeys_InBatch(JsonRpcSerializer s) + { + var r = Run(s, "[{\"m\\u0065thod\":\"ping\",\"\\u0069d\":1},{\"method\":\"echo\",\"p\\u0061rams\":[\"x\"],\"id\":2},{\"method\":\"ping\",\"id\":3}]"); + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1},{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":2},{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":3}]", r); + } + + [TestCaseSource(nameof(Serializers))] + public void EscapedKeys_Notification(JsonRpcSerializer s) + { + Assert.IsTrue(string.IsNullOrEmpty(Run(s, "{\"m\\u0065thod\":\"ping\"}"))); + } + + [TestCaseSource(nameof(Serializers))] + public void EscapedKeys_OnlyTheDecodedNameMatches(JsonRpcSerializer s) + { + // decodes to "mfthod": not the method member + var r = Run(s, "{\"m\\u0066thod\":\"ping\",\"id\":1}"); + StringAssert.Contains("\"code\":-32600", r); + StringAssert.Contains("Missing property 'method'", r); + // an escaped name that decodes to something else is ignored like any other extra member + Assert.AreEqual(Ok7, Run(s, "{\"method\":\"ping\",\"m\\u0065thodx\":\"nope\",\"id\":1}")); + } + + [TestCaseSource(nameof(LenientSerializers))] + public void EscapedKeys_MixedWithBareKeys(JsonRpcSerializer s) + { + Assert.AreEqual(Ok7, Run(s, "{\"m\\u0065thod\":'ping',id:1}")); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"x\",\"id\":\"q\"}", Run(s, "{method:'echo',\"p\\u0061rams\":['x'],\"\\u0069d\":'q'}")); + } + } +} diff --git a/AustinHarris.JsonRpcTestN/RequestIdTests.cs b/AustinHarris.JsonRpcTestN/RequestIdTests.cs new file mode 100644 index 0000000..941c5cf --- /dev/null +++ b/AustinHarris.JsonRpcTestN/RequestIdTests.cs @@ -0,0 +1,455 @@ +using System; +using System.Text; +using System.Threading; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Request id access from inside a method (issue #56): the raw slice, the kind and the owned snapshot, read + /// from the invocation frame on demand. Nothing is decoded or allocated unless a method asks; the numeric + /// request stays allocation-free whether or not it reads the id. Every scenario runs against the three serializers. + /// + [TestFixture] + public class RequestIdTests + { + private const string Session = "request-id"; + private static readonly string[] Serializers = { "jsmn", "newtonsoft", "stj" }; + private static JsonRpcSerializer _current; + + private class IdService + { + public static JsonRpcRequestId Captured; + public static string CapturedRaw; + public static JsonRpcIdKind CapturedKind; + + [JsonRpcMethod("rid.kind")] + public string Kind() => Handler.RpcRequestIdKind().ToString(); + + [JsonRpcMethod("rid.raw")] + public string Raw() => Encoding.UTF8.GetString(Handler.RpcRequestIdRaw().ToArray()); + + [JsonRpcMethod("rid.describe")] + public string Describe() => DescribeId(JsonRpcContext.CurrentRequestId()); + + [JsonRpcMethod("rid.int64")] + public long Int64() => Handler.RpcRequestId().TryGetInt64(out var v) ? v : -1; + + /// Reads everything that must be free for an integer id: raw, kind and the snapshot. + [JsonRpcMethod("rid.touch")] + public int Touch() => Handler.RpcRequestIdRaw().Length + (int)Handler.RpcRequestIdKind() * 100 + (Handler.RpcRequestId().IsAbsent ? 0 : 1000); + + /// Reads what must be free for any id: raw and kind (a string snapshot allocates its string). + [JsonRpcMethod("rid.rawLength")] + public int RawLength() => Handler.RpcRequestIdRaw().Length + (int)Handler.RpcRequestIdKind() * 100; + + [JsonRpcMethod("rid.capture")] + public int Capture() + { + Captured = Handler.RpcRequestId(); + CapturedRaw = Encoding.UTF8.GetString(Handler.RpcRequestIdRaw().ToArray()); + CapturedKind = Handler.RpcRequestIdKind(); + return 1; + } + + /// A parameter named id binds from params only; the envelope id is not injected. + [JsonRpcMethod("rid.echoParam")] + public string EchoParam(int id) => id + "/" + Handler.RpcRequestId(); + + /// Snapshot before, dispatch another request synchronously, compare after. + [JsonRpcMethod("rid.nested")] + public string Nested() + { + var before = Handler.RpcRequestId(); + var inner = JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"rid.describe\",\"id\":\"inner-id\"}", null, _current); + var after = Handler.RpcRequestId(); + return DescribeId(before) + " | " + inner + " | " + (before == after ? "same" : "changed:" + DescribeId(after)); + } + + /// The frame is per thread: another thread sees no request. + [JsonRpcMethod("rid.otherThread")] + public string OtherThread() + { + var snapshot = Handler.RpcRequestId(); + string seen = null; + var t = new Thread(() => seen = Handler.RpcRequestIdKind() + "/" + Handler.RpcRequestIdRaw().Length + "/" + DescribeId(snapshot)); + t.Start(); + t.Join(); + return seen; + } + + [JsonRpcMethod("rid.plain")] + public int Plain(int n) => n * 2; + + [JsonRpcMethod("rid.throws")] + public int Throws() => throw new InvalidOperationException("boom"); + } + + private static string DescribeId(JsonRpcRequestId id) + { + switch (id.Kind) + { + case JsonRpcIdKind.Integer: + return id.TryGetInt64(out var v) ? "int64:" + v : "bigint:" + id.GetIntegerText(); + case JsonRpcIdKind.String: + return "string:" + id.GetString(); + default: + return id.Kind.ToString(); + } + } + + [OneTimeSetUp] + public void Bind() + { + ServiceBinder.BindService(Session, new IdService()); + } + + [OneTimeTearDown] + public void Destroy() + { + Handler.DestroySession(Session); + } + + private static string Run(string json, JsonRpcSerializer serializer, object context = null) + { + return JsonRpcProcessor.ProcessSync(Session, json, context, serializer); + } + + private static string Result(string json, JsonRpcSerializer serializer) + { + var response = Run(json, serializer); + var parsed = JObject.Parse(response); + Assert.IsNull(parsed["error"], response); + return (string)parsed["result"]; + } + + // ------------------------------------------------------------------ the semantics table + + [TestCaseSource(nameof(Serializers))] + public void IntegerId(string name) + { + var s = SerializerCatalog.Create(name); + Assert.AreEqual("Integer", Result("{\"method\":\"rid.kind\",\"id\":42}", s)); + Assert.AreEqual("42", Result("{\"method\":\"rid.raw\",\"id\":42}", s)); + Assert.AreEqual("int64:42", Result("{\"method\":\"rid.describe\",\"id\":42}", s)); + Assert.AreEqual("int64:-7", Result("{\"method\":\"rid.describe\",\"id\":-7}", s)); + Assert.AreEqual("int64:0", Result("{\"method\":\"rid.describe\",\"id\":0}", s)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":42,\"id\":42}", Run("{\"method\":\"rid.int64\",\"id\":42}", s)); + } + + [TestCaseSource(nameof(Serializers))] + public void Int64Boundaries(string name) + { + var s = SerializerCatalog.Create(name); + Assert.AreEqual("int64:9223372036854775807", Result("{\"method\":\"rid.describe\",\"id\":9223372036854775807}", s)); + Assert.AreEqual("int64:-9223372036854775808", Result("{\"method\":\"rid.describe\",\"id\":-9223372036854775808}", s)); + } + + [TestCaseSource(nameof(Serializers))] + public void OversizedInteger_KeepsItsDigits(string name) + { + var s = SerializerCatalog.Create(name); + const string big = "123456789012345678901234567890"; + Assert.AreEqual("Integer", Result("{\"method\":\"rid.kind\",\"id\":" + big + "}", s)); + Assert.AreEqual(big, Result("{\"method\":\"rid.raw\",\"id\":" + big + "}", s)); + Assert.AreEqual("bigint:" + big, Result("{\"method\":\"rid.describe\",\"id\":" + big + "}", s)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":-1,\"id\":" + big + "}", Run("{\"method\":\"rid.int64\",\"id\":" + big + "}", s), "TryGetInt64 is false, the id is still echoed byte for byte"); + } + + [TestCaseSource(nameof(Serializers))] + public void StringId(string name) + { + var s = SerializerCatalog.Create(name); + Assert.AreEqual("String", Result("{\"method\":\"rid.kind\",\"id\":\"abc\"}", s)); + Assert.AreEqual("\"abc\"", Result("{\"method\":\"rid.raw\",\"id\":\"abc\"}", s), "raw keeps the quotes"); + Assert.AreEqual("string:abc", Result("{\"method\":\"rid.describe\",\"id\":\"abc\"}", s)); + Assert.AreEqual("string:", Result("{\"method\":\"rid.describe\",\"id\":\"\"}", s)); + Assert.AreEqual("string:123", Result("{\"method\":\"rid.describe\",\"id\":\"123\"}", s), "a string of digits is a string"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":-1,\"id\":\"123\"}", Run("{\"method\":\"rid.int64\",\"id\":\"123\"}", s)); + } + + [TestCaseSource(nameof(Serializers))] + public void EscapedStringId_IsDecoded_RawIsNot(string name) + { + var s = SerializerCatalog.Create(name); + const string request = "{\"method\":\"rid.describe\",\"id\":\"a\\\"b\\u00e9\\n\"}"; + Assert.AreEqual("string:a\"bé\n", Result(request, s)); + Assert.AreEqual("\"a\\\"b\\u00e9\\n\"", Result("{\"method\":\"rid.raw\",\"id\":\"a\\\"b\\u00e9\\n\"}", s), "raw is the request's own JSON"); + } + + [TestCaseSource(nameof(Serializers))] + public void NullId_IsItsOwnKind(string name) + { + var s = SerializerCatalog.Create(name); + IdService.Captured = JsonRpcRequestId.FromInt64(-1); + var response = Run("{\"method\":\"rid.capture\",\"id\":null}", s); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":null}", response); + Assert.AreEqual(JsonRpcIdKind.Null, IdService.Captured.Kind); + Assert.IsTrue(IdService.Captured.IsNull); + Assert.IsFalse(IdService.Captured.IsAbsent); + Assert.AreEqual("null", IdService.CapturedRaw); + Assert.AreEqual(JsonRpcIdKind.Null, IdService.CapturedKind); + Assert.AreEqual(JsonRpcRequestId.Null, IdService.Captured); + } + + [TestCaseSource(nameof(Serializers))] + public void Notification_HasAbsentId(string name) + { + var s = SerializerCatalog.Create(name); + IdService.Captured = JsonRpcRequestId.FromInt64(-1); + IdService.CapturedRaw = "?"; + Assert.AreEqual("", Run("{\"jsonrpc\":\"2.0\",\"method\":\"rid.capture\"}", s), "no response for a notification"); + Assert.AreEqual(JsonRpcIdKind.Absent, IdService.Captured.Kind); + Assert.IsTrue(IdService.Captured.IsAbsent); + Assert.AreEqual("", IdService.CapturedRaw); + Assert.AreEqual(JsonRpcIdKind.Absent, IdService.CapturedKind); + Assert.AreEqual(default(JsonRpcRequestId), IdService.Captured); + } + + [TestCaseSource(nameof(Serializers))] + public void FractionalId_IsRejectedBeforeTheMethodRuns(string name) + { + var s = SerializerCatalog.Create(name); + IdService.Captured = JsonRpcRequestId.FromInt64(-1); + var response = JObject.Parse(Run("{\"method\":\"rid.capture\",\"id\":1.5}", s)); + Assert.AreEqual(-32600, (int)response["error"]["code"]); + Assert.AreEqual(-1, IdService.Captured.ToObject(), "the method did not run"); + } + + [Test] + public void LenientSingleQuotedId_IsNormalisedToJson() + { + var s = SerializerCatalog.Create("newtonsoft"); + Assert.AreEqual("\"x\"", Result("{\"method\":\"rid.raw\",\"id\":'x'}", s), "raw is the normalised JSON string, not the single-quoted source"); + Assert.AreEqual("string:x", Result("{\"method\":\"rid.describe\",\"id\":'x'}", s)); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"String\",\"id\":\"x\"}", Run("{\"method\":\"rid.kind\",\"id\":'x'}", s)); + } + + [Test] + public void OutsideAnInvocation_NothingIsReported() + { + Assert.AreEqual(JsonRpcIdKind.Absent, Handler.RpcRequestIdKind()); + Assert.AreEqual(0, Handler.RpcRequestIdRaw().Length); + Assert.IsTrue(Handler.RpcRequestId().IsAbsent); + Assert.IsTrue(JsonRpcContext.CurrentRequestId().IsAbsent); + } + + [TestCaseSource(nameof(Serializers))] + public void ParameterNamedId_BindsFromParamsOnly(string name) + { + var s = SerializerCatalog.Create(name); + Assert.AreEqual("5/77", Result("{\"method\":\"rid.echoParam\",\"params\":{\"id\":5},\"id\":77}", s)); + Assert.AreEqual("5/77", Result("{\"method\":\"rid.echoParam\",\"params\":[5],\"id\":77}", s)); + var response = JObject.Parse(Run("{\"method\":\"rid.echoParam\",\"params\":{},\"id\":77}", s)); + Assert.AreEqual(-32602, (int)response["error"]["code"], "the envelope id is never injected into a parameter"); + } + + // ------------------------------------------------------------------ frames, hooks, threads + + [TestCaseSource(nameof(Serializers))] + public void NestedDispatch_RestoresTheOuterId(string name) + { + var s = SerializerCatalog.Create(name); + _current = s; + Assert.AreEqual("int64:9 | {\"jsonrpc\":\"2.0\",\"result\":\"string:inner-id\",\"id\":\"inner-id\"} | same", + Result("{\"method\":\"rid.nested\",\"id\":9}", s)); + Assert.IsTrue(Handler.RpcRequestId().IsAbsent, "nothing leaks out of the invocation"); + } + + [TestCaseSource(nameof(Serializers))] + public void NestedDispatch_RestoresTheOuterId_WithHooks(string name) + { + var s = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + try + { + handler.SetPreProcessHandler((request, context) => null); + handler.SetPostProcessHandler((request, response, context) => null); + NestedDispatch_RestoresTheOuterId(name); + } + finally + { + handler.SetPreProcessHandler(null); + handler.SetPostProcessHandler(null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void MethodException_RestoresTheFrame(string name) + { + var s = SerializerCatalog.Create(name); + var response = JObject.Parse(Run("{\"method\":\"rid.throws\",\"id\":3}", s)); + Assert.AreEqual(-32603, (int)response["error"]["code"]); + Assert.IsTrue(Handler.RpcRequestId().IsAbsent); + Assert.AreEqual(0, Handler.RpcRequestIdRaw().Length); + } + + [TestCaseSource(nameof(Serializers))] + public void Hooks_LeavingTheIdAlone_SeeTheSameId(string name) + { + var s = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + try + { + handler.SetPreProcessHandler((request, context) => null); + Assert.AreEqual("int64:42", Result("{\"method\":\"rid.describe\",\"id\":42}", s)); + Assert.AreEqual("string:abc", Result("{\"method\":\"rid.describe\",\"id\":\"abc\"}", s)); + Assert.AreEqual("\"abc\"", Result("{\"method\":\"rid.raw\",\"id\":\"abc\"}", s)); + } + finally + { + handler.SetPreProcessHandler(null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void Hooks_ReplacingTheId_TheMethodSeesTheReplacement(string name) + { + var s = SerializerCatalog.Create(name); + var handler = Handler.GetSessionHandler(Session); + try + { + handler.SetPreProcessHandler((request, context) => { request.Id = "changed"; return null; }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"string:changed\",\"id\":\"changed\"}", Run("{\"method\":\"rid.describe\",\"id\":1}", s)); + Assert.AreEqual("\"changed\"", Result("{\"method\":\"rid.raw\",\"id\":1}", s)); + + handler.SetPreProcessHandler((request, context) => { request.Id = 99L; return null; }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"int64:99\",\"id\":99}", Run("{\"method\":\"rid.describe\",\"id\":\"x\"}", s)); + + handler.SetPreProcessHandler((request, context) => { request.Id = null; return null; }); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"Null\",\"id\":null}", Run("{\"method\":\"rid.describe\",\"id\":5}", s)); + + // a replacement JSON-RPC does not allow is rejected before the method runs + handler.SetPreProcessHandler((request, context) => { request.Id = 1.5; return null; }); + IdService.Captured = JsonRpcRequestId.FromInt64(-1); + var response = JObject.Parse(Run("{\"method\":\"rid.capture\",\"id\":5}", s)); + Assert.AreEqual(-32600, (int)response["error"]["code"], response.ToString()); + Assert.AreEqual(-1, IdService.Captured.ToObject(), "the method did not run"); + } + finally + { + handler.SetPreProcessHandler(null); + } + } + + [TestCaseSource(nameof(Serializers))] + public void Snapshot_SurvivesLaterRequestsAndOtherThreads(string name) + { + var s = SerializerCatalog.Create(name); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":\"first\"}", Run("{\"method\":\"rid.capture\",\"id\":\"first\"}", s)); + var snapshot = IdService.Captured; + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":2}", Run("{\"method\":\"rid.capture\",\"id\":2}", s)); + Assert.AreEqual("string:first", DescribeId(snapshot), "the snapshot owns its value; the reader has moved on"); + Assert.AreEqual("int64:2", DescribeId(IdService.Captured)); + + Assert.AreEqual("Absent/0/int64:11", Result("{\"method\":\"rid.otherThread\",\"id\":11}", s), "the frame is per thread; the snapshot travels"); + } + + [TestCaseSource(nameof(Serializers))] + public void Batch_EachRequestSeesItsOwnId(string name) + { + var s = SerializerCatalog.Create(name); + Assert.AreEqual("[{\"jsonrpc\":\"2.0\",\"result\":\"int64:1\",\"id\":1},{\"jsonrpc\":\"2.0\",\"result\":\"string:two\",\"id\":\"two\"},{\"jsonrpc\":\"2.0\",\"result\":\"Null\",\"id\":null}]", + Run("[{\"method\":\"rid.describe\",\"id\":1},{\"method\":\"rid.describe\"},{\"method\":\"rid.describe\",\"id\":\"two\"},{\"method\":\"rid.describe\",\"id\":null}]", s)); + } + + [Test] + public void HandleJsonRequest_SeesTheId() + { + var handler = Handler.GetSessionHandler(Session); + var response = handler.Handle(new JsonRequest("rid.describe", null, "boxed")); + Assert.IsNull(response.Error); + Assert.AreEqual("string:boxed", response.Result); + Assert.AreEqual("boxed", response.Id); + response = handler.Handle(new JsonRequest("rid.describe", null, 12L)); + Assert.AreEqual("int64:12", response.Result); + } + + // ------------------------------------------------------------------ the struct itself + + [Test] + public void Struct_EqualityAndConversions() + { + Assert.AreEqual(JsonRpcRequestId.FromInt64(5), JsonRpcRequestId.FromRaw(Encoding.UTF8.GetBytes("5"))); + Assert.AreEqual(JsonRpcRequestId.FromString("5"), JsonRpcRequestId.FromRaw(Encoding.UTF8.GetBytes("\"5\""))); + Assert.AreNotEqual(JsonRpcRequestId.FromInt64(5), JsonRpcRequestId.FromString("5")); + Assert.AreEqual(JsonRpcRequestId.Null, JsonRpcRequestId.FromRaw(Encoding.UTF8.GetBytes("null"))); + Assert.AreEqual(JsonRpcRequestId.Absent, JsonRpcRequestId.FromRaw(ReadOnlySpan.Empty)); + Assert.AreEqual(JsonRpcIdKind.Invalid, JsonRpcRequestId.FromRaw(Encoding.UTF8.GetBytes("1.5")).Kind); + Assert.AreEqual(JsonRpcRequestId.FromInt64(5).GetHashCode(), JsonRpcRequestId.FromRaw(Encoding.UTF8.GetBytes("5")).GetHashCode()); + + var big = JsonRpcRequestId.FromRaw(Encoding.UTF8.GetBytes("99999999999999999999")); + Assert.IsTrue(big.IsInteger); + Assert.IsFalse(big.TryGetInt64(out _)); + Assert.AreEqual("99999999999999999999", big.GetIntegerText()); + Assert.AreEqual("99999999999999999999", big.ToObject()); + Assert.AreEqual(big, JsonRpcRequestId.FromRaw(Encoding.UTF8.GetBytes("99999999999999999999"))); + + Assert.AreEqual(5L, JsonRpcRequestId.FromInt64(5).ToObject()); + Assert.AreEqual("5", JsonRpcRequestId.FromInt64(5).GetIntegerText()); + Assert.IsNull(JsonRpcRequestId.FromInt64(5).GetString()); + Assert.AreEqual("x", JsonRpcRequestId.FromString("x").ToObject()); + Assert.IsNull(JsonRpcRequestId.FromString("x").GetIntegerText()); + Assert.IsNull(JsonRpcRequestId.Null.ToObject()); + Assert.AreEqual("5", JsonRpcRequestId.FromInt64(5).ToString()); + Assert.AreEqual("x", JsonRpcRequestId.FromString("x").ToString()); + Assert.AreEqual("null", JsonRpcRequestId.Null.ToString()); + Assert.AreEqual("", JsonRpcRequestId.Absent.ToString()); + + Assert.AreEqual(JsonRpcRequestId.FromInt64(7), JsonRpcRequestId.FromObject(7)); + Assert.AreEqual(JsonRpcRequestId.FromInt64(7), JsonRpcRequestId.FromObject(7L)); + Assert.AreEqual(JsonRpcRequestId.FromString("s"), JsonRpcRequestId.FromObject("s")); + Assert.AreEqual(JsonRpcRequestId.Null, JsonRpcRequestId.FromObject(null)); + Assert.AreEqual(JsonRpcIdKind.Invalid, JsonRpcRequestId.FromObject(1.5).Kind); + + var w = new AustinHarris.JsonRpc.Serialization.PooledByteBufferWriter(64); + JsonRpcRequestId.FromString("a\"b").WriteTo(w); + Assert.AreEqual("\"a\\\"b\"", w.ToString()); + w.Clear(); big.WriteTo(w); + Assert.AreEqual("99999999999999999999", w.ToString()); + w.Clear(); JsonRpcRequestId.FromInt64(-3).WriteTo(w); + Assert.AreEqual("-3", w.ToString()); + w.Clear(); JsonRpcRequestId.Absent.WriteTo(w); + Assert.AreEqual("null", w.ToString()); + w.Dispose(); + } + + // ------------------------------------------------------------------ zero cost + + /// + /// The numeric request on the built-in serializer allocates nothing today; reading the raw id, the kind or the + /// integer snapshot must keep it that way, and so must not reading it. A string snapshot allocates exactly its string. + /// + [Test] + public void BuiltInSerializer_ReadingTheId_AllocatesNothing() + { + var s = SerializerCatalog.Create("jsmn"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1202,\"id\":42}", Run("{\"jsonrpc\":\"2.0\",\"method\":\"rid.touch\",\"id\":42}", s), "2 raw bytes, Integer, present"); + Assert.AreEqual(0, MeasureAllocations("{\"jsonrpc\":\"2.0\",\"method\":\"rid.int64\",\"id\":42}", s), "integer snapshot"); + Assert.AreEqual(0, MeasureAllocations("{\"jsonrpc\":\"2.0\",\"method\":\"rid.touch\",\"id\":42}", s), "raw + kind + snapshot"); + Assert.AreEqual(0, MeasureAllocations("{\"jsonrpc\":\"2.0\",\"method\":\"rid.touch\",\"id\":-9223372036854775808}", s), "the widest integer"); + Assert.AreEqual(0, MeasureAllocations("{\"jsonrpc\":\"2.0\",\"method\":\"rid.touch\",\"id\":null}", s), "null id"); + Assert.AreEqual(0, MeasureAllocations("{\"jsonrpc\":\"2.0\",\"method\":\"rid.touch\"}", s), "notification"); + Assert.AreEqual(0, MeasureAllocations("{\"jsonrpc\":\"2.0\",\"method\":\"rid.rawLength\",\"id\":\"abc\"}", s), "a string id that is not decoded"); + Assert.AreEqual(0, MeasureAllocations("{\"jsonrpc\":\"2.0\",\"method\":\"rid.plain\",\"params\":[5],\"id\":\"s\"}", s), "a method that never reads the id: no cost"); + } + + private static long MeasureAllocations(string request, JsonRpcSerializer serializer) + { + var input = Encoding.UTF8.GetBytes(request); + using (var output = new AustinHarris.JsonRpc.Serialization.PooledByteBufferWriter(256)) + { + var memory = new ReadOnlyMemory(input); + for (int i = 0; i < 500; i++) { output.Clear(); JsonRpcProcessor.Process(Session, memory, output, null, serializer); } + const int reps = 2000; + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < reps; i++) { output.Clear(); JsonRpcProcessor.Process(Session, memory, output, null, serializer); } + // an undivided total: one allocation per request would show as reps times its size + return GC.GetAllocatedBytesForCurrentThread() - before; + } + } + } +} diff --git a/AustinHarris.JsonRpcTestN/SerializerCatalog.cs b/AustinHarris.JsonRpcTestN/SerializerCatalog.cs new file mode 100644 index 0000000..4c80aa1 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/SerializerCatalog.cs @@ -0,0 +1,21 @@ +using System; +using AustinHarris.JsonRpc.Jsmn; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpcTestN +{ + /// Maps a fixture name to a serializer instance. Add new serializers here to run the suite against them. + public static class SerializerCatalog + { + public static JsonRpcSerializer Create(string name) + { + switch (name) + { + case "jsmn": return JsmnSerializer.Instance; + case "newtonsoft": return new AustinHarris.JsonRpc.Newtonsoft.NewtonsoftJsonRpcSerializer(); + case "stj": return new AustinHarris.JsonRpc.SystemTextJson.SystemTextJsonRpcSerializer(); + default: throw new ArgumentException("Unknown serializer '" + name + "'.", nameof(name)); + } + } + } +} diff --git a/AustinHarris.JsonRpcTestN/SerializerHardeningTests.cs b/AustinHarris.JsonRpcTestN/SerializerHardeningTests.cs new file mode 100644 index 0000000..87e5900 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/SerializerHardeningTests.cs @@ -0,0 +1,553 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Jsmn; +using AustinHarris.JsonRpc.Newtonsoft; +using AustinHarris.JsonRpc.Serialization; +using AustinHarris.JsonRpc.SystemTextJson; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Regression tests for the serializer findings of the 2.0 review: write-only members (built-in writer), + /// non-finite floats (all three serializers, byte parity with Json.NET), mutable structs (built-in mapper) + /// and the System.Text.Json cached writer after a failed write. + /// + [TestFixture] + public class SerializerHardeningTests + { + private static JsonRpcSerializer[] All() => new JsonRpcSerializer[] + { + JsmnSerializer.Instance, + new NewtonsoftJsonRpcSerializer(), + new SystemTextJsonRpcSerializer() + }; + + private static T Read(JsonRpcSerializer s, string json) => s.Read(Encoding.UTF8.GetBytes(json)); + + // ------------------------------------------------------------------ finding 9: write-only members + + public class WriteOnlyFirst + { + public int Hidden { set { } } + public int Visible => 3; + } + + public class WriteOnlyMiddle + { + public int A => 1; + public int Hidden { set { } } + public int B => 2; + } + + public class WriteOnlyLast + { + public int A => 1; + public int B => 2; + public int Hidden { set { } } + } + + public class WriteOnlyOnly + { + public int Hidden { set { } } + } + + public class WriteOnlyAroundFields + { + public int Hidden1 { set { } } + public int F = 5; + public int Hidden2 { set { } } + public string G = "g"; + public int Hidden3 { set { } } + } + + [Test] + public void WriteOnlyMember_First_IsSkippedWithoutLeadingComma() + { + Assert.AreEqual("{\"Visible\":3}", JsmnSerializer.Instance.Serialize(new WriteOnlyFirst())); + } + + [Test] + public void WriteOnlyMember_Middle_IsSkippedWithoutDoubleComma() + { + Assert.AreEqual("{\"A\":1,\"B\":2}", JsmnSerializer.Instance.Serialize(new WriteOnlyMiddle())); + } + + [Test] + public void WriteOnlyMember_Last_IsSkippedWithoutTrailingComma() + { + Assert.AreEqual("{\"A\":1,\"B\":2}", JsmnSerializer.Instance.Serialize(new WriteOnlyLast())); + } + + [Test] + public void WriteOnlyMember_Only_WritesEmptyObject() + { + Assert.AreEqual("{}", JsmnSerializer.Instance.Serialize(new WriteOnlyOnly())); + } + + [Test] + public void WriteOnlyMember_BetweenFields_IsSkipped() + { + Assert.AreEqual("{\"F\":5,\"G\":\"g\"}", JsmnSerializer.Instance.Serialize(new WriteOnlyAroundFields())); + } + + [Test] + public void WriteOnlyMember_AllSerializersAgree() + { + foreach (var s in All()) + { + Assert.AreEqual("{\"Visible\":3}", s.Serialize(new WriteOnlyFirst()), s.Name); + Assert.AreEqual("{\"A\":1,\"B\":2}", s.Serialize(new WriteOnlyMiddle()), s.Name); + Assert.AreEqual("{\"A\":1,\"B\":2}", s.Serialize(new WriteOnlyLast()), s.Name); + Assert.AreEqual("{}", s.Serialize(new WriteOnlyOnly()), s.Name); + } + } + + [Test] + public void WriteOnlyMember_StillBindsOnRead() + { + // the setter is still honoured when reading (the plan keeps the member, only the writer skips it) + var s = JsmnSerializer.Instance; + Assert.AreEqual(3, Read(s, "{\"Hidden\":9,\"Visible\":1}").Visible); + } + + private sealed class WriteOnlyService + { + [JsonRpcMethod("hardening.shape")] + public WriteOnlyFirst Shape() => new WriteOnlyFirst(); + } + + [Test] + public void WriteOnlyMember_RpcResultIsValidJson() + { + const string session = "hardening-writeonly"; + ServiceBinder.BindService(session, new WriteOnlyService()); + try + { + foreach (var s in All()) + { + var response = JsonRpcProcessor.ProcessSync(session, "{\"jsonrpc\":\"2.0\",\"method\":\"hardening.shape\",\"id\":1}", null, s); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":{\"Visible\":3},\"id\":1}", response, s.Name); + } + } + finally + { + Handler.DestroySession(session); + } + } + + // ------------------------------------------------------------------ finding 10: non-finite floats + + public class Floats + { + public double D { get; set; } + public float F { get; set; } + public double? N { get; set; } + } + + [TestCase(double.NaN, "\"NaN\"")] + [TestCase(double.PositiveInfinity, "\"Infinity\"")] + [TestCase(double.NegativeInfinity, "\"-Infinity\"")] + public void NonFiniteDouble_WritesJsonNetString(double value, string expected) + { + // Json.NET's default FloatFormatHandling.String is the reference + Assert.AreEqual(expected, global::Newtonsoft.Json.JsonConvert.SerializeObject(value)); + foreach (var s in All()) + { + Assert.AreEqual(expected, s.Serialize(value), s.Name + " double"); + Assert.AreEqual(expected, s.Serialize((double?)value), s.Name + " double?"); + Assert.AreEqual(expected, s.Serialize((object)value, typeof(object)), s.Name + " boxed double"); + } + } + + [TestCase(float.NaN, "\"NaN\"")] + [TestCase(float.PositiveInfinity, "\"Infinity\"")] + [TestCase(float.NegativeInfinity, "\"-Infinity\"")] + public void NonFiniteSingle_WritesJsonNetString(float value, string expected) + { + Assert.AreEqual(expected, global::Newtonsoft.Json.JsonConvert.SerializeObject(value)); + foreach (var s in All()) + { + Assert.AreEqual(expected, s.Serialize(value), s.Name + " float"); + Assert.AreEqual(expected, s.Serialize((float?)value), s.Name + " float?"); + } + } + + [Test] + public void NonFinite_InsideObjectsAndArrays_AllSerializersProduceTheSameBytes() + { + var poco = new Floats { D = double.NaN, F = float.NegativeInfinity, N = double.PositiveInfinity }; + var list = new List { 1.5, double.NaN, double.PositiveInfinity, double.NegativeInfinity }; + const string expectedPoco = "{\"D\":\"NaN\",\"F\":\"-Infinity\",\"N\":\"Infinity\"}"; + const string expectedList = "[1.5,\"NaN\",\"Infinity\",\"-Infinity\"]"; + Assert.AreEqual(expectedPoco, global::Newtonsoft.Json.JsonConvert.SerializeObject(poco)); + Assert.AreEqual(expectedList, global::Newtonsoft.Json.JsonConvert.SerializeObject(list)); + foreach (var s in All()) + { + Assert.AreEqual(expectedPoco, s.Serialize(poco), s.Name); + Assert.AreEqual(expectedList, s.Serialize(list), s.Name); + } + } + + [Test] + public void NonFinite_StringsReadBackToTheValues() + { + foreach (var s in All()) + { + Assert.IsNaN(Read(s, "\"NaN\""), s.Name); + Assert.AreEqual(double.PositiveInfinity, Read(s, "\"Infinity\""), s.Name); + Assert.AreEqual(double.NegativeInfinity, Read(s, "\"-Infinity\""), s.Name); + Assert.IsNaN(Read(s, "\"NaN\""), s.Name); + Assert.AreEqual(float.PositiveInfinity, Read(s, "\"Infinity\""), s.Name); + Assert.AreEqual(float.NegativeInfinity, Read(s, "\"-Infinity\""), s.Name); + Assert.IsNaN(Read(s, "\"NaN\"").Value, s.Name); + Assert.AreEqual(double.NegativeInfinity, Read(s, "\"-Infinity\"").Value, s.Name); + + var poco = Read(s, "{\"D\":\"NaN\",\"F\":\"-Infinity\",\"N\":\"Infinity\"}"); + Assert.IsNaN(poco.D, s.Name); + Assert.AreEqual(float.NegativeInfinity, poco.F, s.Name); + Assert.AreEqual(double.PositiveInfinity, poco.N, s.Name); + + // and a value round-trips through the serializer + Assert.IsNaN(Read(s, s.Serialize(double.NaN)), s.Name); + Assert.AreEqual(double.NegativeInfinity, Read(s, s.Serialize(double.NegativeInfinity)), s.Name); + } + } + + private sealed class NonFiniteService + { + [JsonRpcMethod("hardening.nonfinite")] + public double Echo(double d) => d; + } + + [Test] + public void NonFinite_RpcRoundTripIsValidJsonOnAllSerializers() + { + const string session = "hardening-nonfinite"; + ServiceBinder.BindService(session, new NonFiniteService()); + try + { + foreach (var s in All()) + { + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"NaN\",\"id\":1}", + JsonRpcProcessor.ProcessSync(session, "{\"jsonrpc\":\"2.0\",\"method\":\"hardening.nonfinite\",\"params\":[\"NaN\"],\"id\":1}", null, s), s.Name); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":\"-Infinity\",\"id\":2}", + JsonRpcProcessor.ProcessSync(session, "{\"jsonrpc\":\"2.0\",\"method\":\"hardening.nonfinite\",\"params\":[\"-Infinity\"],\"id\":2}", null, s), s.Name); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":1.5,\"id\":3}", + JsonRpcProcessor.ProcessSync(session, "{\"jsonrpc\":\"2.0\",\"method\":\"hardening.nonfinite\",\"params\":[1.5],\"id\":3}", null, s), s.Name); + } + } + finally + { + Handler.DestroySession(session); + } + } + + [Test] + public void FiniteNumbersStillWriteWithDecimalPlace() + { + foreach (var s in All()) + { + Assert.AreEqual("3.0", s.Serialize(3.0), s.Name); + Assert.AreEqual("-2.5", s.Serialize(-2.5), s.Name); + Assert.AreEqual("987.0", s.Serialize(987f), s.Name); + } + } + + // ------------------------------------------------------------------ finding 18: mutable structs + + public struct Pair + { + public int X { get; set; } + public int Y { get; set; } + } + + public struct FieldPair + { + public int X; + public string Name; + } + + public class PairHolder + { + public Pair P { get; set; } + public Pair? Q { get; set; } + public FieldPair F; + } + + public struct Outer + { + public Pair Inner { get; set; } + public List Items { get; set; } + } + + [Test] + public void MutableStruct_Properties_Bind() + { + foreach (var s in All()) + { + var p = Read(s, "{\"X\":7,\"Y\":8}"); + Assert.AreEqual(7, p.X, s.Name); + Assert.AreEqual(8, p.Y, s.Name); + Assert.AreEqual(7, Read(s, "{\"x\":7}").X, s.Name + " case-insensitive"); + Assert.AreEqual(0, Read(s, "{}").X, s.Name + " default"); + } + } + + [Test] + public void MutableStruct_Fields_Bind() + { + foreach (var s in All()) + { + var f = Read(s, "{\"X\":3,\"Name\":\"n\"}"); + Assert.AreEqual(3, f.X, s.Name); + Assert.AreEqual("n", f.Name, s.Name); + } + } + + [Test] + public void MutableStruct_Nullable_Binds() + { + foreach (var s in All()) + { + var p = Read(s, "{\"X\":7}"); + Assert.IsTrue(p.HasValue, s.Name); + Assert.AreEqual(7, p.Value.X, s.Name); + Assert.IsFalse(Read(s, "null").HasValue, s.Name); + } + } + + [Test] + public void MutableStruct_InListAndArray_Binds() + { + foreach (var s in All()) + { + var list = Read>(s, "[{\"X\":1},{\"X\":2,\"Y\":3}]"); + Assert.AreEqual(2, list.Count, s.Name); + Assert.AreEqual(1, list[0].X, s.Name); + Assert.AreEqual(2, list[1].X, s.Name); + Assert.AreEqual(3, list[1].Y, s.Name); + var array = Read(s, "[{\"Y\":9}]"); + Assert.AreEqual(9, array[0].Y, s.Name); + } + } + + [Test] + public void MutableStruct_AsPocoProperty_Binds() + { + foreach (var s in All()) + { + var h = Read(s, "{\"P\":{\"X\":3},\"Q\":{\"X\":4,\"Y\":5},\"F\":{\"X\":6,\"Name\":\"f\"}}"); + Assert.AreEqual(3, h.P.X, s.Name); + Assert.AreEqual(4, h.Q.Value.X, s.Name); + Assert.AreEqual(5, h.Q.Value.Y, s.Name); + Assert.AreEqual(6, h.F.X, s.Name); + Assert.AreEqual("f", h.F.Name, s.Name); + Assert.IsNull(Read(s, "{\"P\":{\"X\":3},\"Q\":null}").Q, s.Name); + } + } + + [Test] + public void MutableStruct_NestedInStruct_Binds() + { + foreach (var s in All()) + { + var o = Read(s, "{\"Inner\":{\"X\":1,\"Y\":2},\"Items\":[{\"X\":3}]}"); + Assert.AreEqual(1, o.Inner.X, s.Name); + Assert.AreEqual(2, o.Inner.Y, s.Name); + Assert.AreEqual(3, o.Items[0].X, s.Name); + } + } + + [Test] + public void MutableStruct_WritesLikeAClass() + { + foreach (var s in All()) + { + Assert.AreEqual("{\"X\":7,\"Y\":8}", s.Serialize(new Pair { X = 7, Y = 8 }), s.Name); + Assert.AreEqual("{\"X\":7,\"Y\":0}", s.Serialize((Pair?)new Pair { X = 7 }), s.Name); + Assert.AreEqual("null", s.Serialize((Pair?)null), s.Name); + Assert.AreEqual("[{\"X\":1,\"Y\":0}]", s.Serialize(new List { new Pair { X = 1 } }), s.Name); + } + } + + private sealed class StructService + { + [JsonRpcMethod("hardening.pair")] + public int Sum(Pair p) => p.X + p.Y; + + [JsonRpcMethod("hardening.pairs")] + public int SumAll(List items) + { + int total = 0; + foreach (var p in items) total += p.X + p.Y; + return total; + } + } + + [Test] + public void MutableStruct_AsRpcParameter_BindsOnAllSerializers() + { + const string session = "hardening-struct"; + ServiceBinder.BindService(session, new StructService()); + try + { + foreach (var s in All()) + { + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":15,\"id\":1}", + JsonRpcProcessor.ProcessSync(session, "{\"jsonrpc\":\"2.0\",\"method\":\"hardening.pair\",\"params\":[{\"X\":7,\"Y\":8}],\"id\":1}", null, s), s.Name); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":15,\"id\":2}", + JsonRpcProcessor.ProcessSync(session, "{\"jsonrpc\":\"2.0\",\"method\":\"hardening.pair\",\"params\":{\"p\":{\"X\":7,\"Y\":8}},\"id\":2}", null, s), s.Name); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":10,\"id\":3}", + JsonRpcProcessor.ProcessSync(session, "{\"jsonrpc\":\"2.0\",\"method\":\"hardening.pairs\",\"params\":[[{\"X\":1,\"Y\":2},{\"X\":3,\"Y\":4}]],\"id\":3}", null, s), s.Name); + } + } + finally + { + Handler.DestroySession(session); + } + } + + public class NoDefaultCtor + { + public NoDefaultCtor(int x) { X = x; } + public int X { get; } + } + + [Test] + public void ClassWithoutParameterlessConstructor_StillRejectedByBuiltIn() + { + // a NotSupportedException, not a JsonRpcBindException: the type is the server's limitation, not the client's value + Assert.Throws(() => Read(JsmnSerializer.Instance, "{\"X\":1}")); + } + + // ------------------------------------------------------------------ finding 3: System.Text.Json writer after a throw + + public class Explodes + { + public int Good => 1; + public int Bad => throw new InvalidOperationException("boom"); + } + + public class Wrapper + { + public int Before { get; set; } = 1; + public Nested Value { get; set; } = new Nested(); + public int After { get; set; } = 2; + } + + public class Nested { } + + [Test] + public void StjFailedWrite_ThenReuseWithSameOptions() + { + var a = new SystemTextJsonRpcSerializer(); + Assert.Catch(() => a.Serialize(new Explodes())); + Assert.AreEqual("7", a.Serialize(7)); + Assert.AreEqual("[1,\"a\"]", a.Serialize(new object[] { 1, "a" })); + Assert.AreEqual("{\"Good\":1}", a.Serialize(new { Good = 1 })); + } + + [Test] + public void StjFailedWrite_ThenReuseWithDifferentOptions() + { + // the review's repro: A fails part-way through a value, then B (other options) evicts A's cached writer + var a = new SystemTextJsonRpcSerializer(); + var b = new SystemTextJsonRpcSerializer(new JsonSerializerOptions()); + Assert.Catch(() => a.Serialize(new Explodes())); + Assert.AreEqual("7", b.Serialize(7)); + Assert.AreEqual("7", a.Serialize(7)); + // and the other way round + Assert.Catch(() => b.Serialize(new Explodes())); + Assert.AreEqual("\"x\"", a.Serialize("x")); + Assert.AreEqual("\"x\"", b.Serialize("x")); + } + + [Test] + public void StjFailedWrite_LeavesNothingInTheCallersBuffer() + { + var a = new SystemTextJsonRpcSerializer(); + using (var w = new PooledByteBufferWriter(64)) + { + Assert.Catch(() => a.Write(w, new Explodes())); + Assert.AreEqual(0, w.WrittenCount, "nothing may be flushed into the output after the failure"); + a.Write(w, 7); + Assert.AreEqual("7", w.ToString()); + } + } + + [Test] + public void StjFailedWrite_OnTheOutputItself_ThenReuse() + { + var a = new SystemTextJsonRpcSerializer(); + Assert.Catch(() => a.Write(new ThrowingBufferWriter(), new int[256])); + Assert.AreEqual("[1,2]", a.Serialize(new[] { 1, 2 })); + var b = new SystemTextJsonRpcSerializer(new JsonSerializerOptions()); + Assert.AreEqual("[1,2]", b.Serialize(new[] { 1, 2 })); + } + + [Test] + public void StjFailedWrite_Nested_CaughtInsideConverter() + { + var outer = new SystemTextJsonRpcSerializer(); + var options = SystemTextJsonRpcSerializer.CreateDefaultOptions(); + options.Converters.Add(new NestedConverter(outer, swallow: true)); + var reentrant = new SystemTextJsonRpcSerializer(options); + + // the nested write fails on a throwaway writer while the outer (cached) writer is in use + Assert.AreEqual("{\"Before\":1,\"Value\":\"7\",\"After\":2}", reentrant.Serialize(new Wrapper())); + Assert.AreEqual("7", outer.Serialize(7)); + Assert.AreEqual("7", reentrant.Serialize(7)); + } + + [Test] + public void StjFailedWrite_Nested_PropagatingToTheOuterWrite() + { + var outer = new SystemTextJsonRpcSerializer(); + var options = SystemTextJsonRpcSerializer.CreateDefaultOptions(); + options.Converters.Add(new NestedConverter(outer, swallow: false)); + var reentrant = new SystemTextJsonRpcSerializer(options); + + using (var w = new PooledByteBufferWriter(64)) + { + Assert.Catch(() => reentrant.Write(w, new Wrapper())); + Assert.AreEqual(0, w.WrittenCount); + } + Assert.AreEqual("7", outer.Serialize(7)); + Assert.AreEqual("7", reentrant.Serialize(7)); + Assert.AreEqual("7", new SystemTextJsonRpcSerializer(new JsonSerializerOptions()).Serialize(7)); + } + + private sealed class NestedConverter : JsonConverter + { + private readonly SystemTextJsonRpcSerializer _serializer; + private readonly bool _swallow; + public NestedConverter(SystemTextJsonRpcSerializer serializer, bool swallow) { _serializer = serializer; _swallow = swallow; } + public override Nested Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, Nested value, JsonSerializerOptions options) + { + if (_swallow) + { + try { _serializer.Serialize(new Explodes()); } catch (InvalidOperationException) { } + writer.WriteStringValue(_serializer.Serialize(7)); + } + else + { + _serializer.Serialize(new Explodes()); + } + } + } + + private sealed class ThrowingBufferWriter : System.Buffers.IBufferWriter + { + private readonly byte[] _buffer = new byte[16]; + public void Advance(int count) => throw new InvalidOperationException("output is closed"); + public Memory GetMemory(int sizeHint = 0) => _buffer; + public Span GetSpan(int sizeHint = 0) => _buffer; + } + } +} diff --git a/AustinHarris.JsonRpcTestN/SystemTextJsonTests.cs b/AustinHarris.JsonRpcTestN/SystemTextJsonTests.cs new file mode 100644 index 0000000..c4c8584 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/SystemTextJsonTests.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; +using AustinHarris.JsonRpc.SystemTextJson; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Behaviour specific to the System.Text.Json serializer: option handling, the wire converters on their own, + /// and the JsonElement object model. The shared wire-format suite lives in (fixture "stj"). + /// + [TestFixture] + public class SystemTextJsonTests + { + public enum Colour { Red, Green } + + public class Shape + { + public string ShapeName { get; set; } + public Colour Tint { get; set; } + public double Area { get; set; } + } + + private class StjService + { + [JsonRpcMethod("shape")] + public Shape MakeShape(string name) => new Shape { ShapeName = name, Tint = Colour.Green, Area = 4 }; + + [JsonRpcMethod("echo")] + public string Echo(string s) => s; + + [JsonRpcMethod("sum")] + public int Sum(int a, int b) => a + b; + } + + private const string SessionId = "stj-tests"; + + [OneTimeSetUp] + public void SelectSerializer() + { + Config.SetSerializer(new SystemTextJsonRpcSerializer()); + ServiceBinder.BindService(SessionId, new StjService()); + } + + [OneTimeTearDown] + public void RestoreSerializer() + { + Handler.DestroySession(SessionId); + Config.SetSerializer(null); + } + + // ------------------------------------------------------------------ options + + [Test] + public void DefaultOptionsAreSharedAndReadOnly() + { + var a = new SystemTextJsonRpcSerializer(); + var b = new SystemTextJsonRpcSerializer(); + Assert.IsNull(a.Options); + Assert.AreSame(a.EffectiveOptions, b.EffectiveOptions); + Assert.AreSame(SystemTextJsonRpcSerializer.DefaultOptions, a.EffectiveOptions); + Assert.IsTrue(a.EffectiveOptions.IsReadOnly); + Assert.IsTrue(a.EffectiveOptions.IncludeFields); + Assert.IsTrue(a.EffectiveOptions.PropertyNameCaseInsensitive); + Assert.AreEqual(JsonIgnoreCondition.Never, a.EffectiveOptions.DefaultIgnoreCondition); + Assert.IsTrue(JsonRpcConverters.ContainsAll(a.EffectiveOptions)); + } + + [Test] + public void UserOptionsAreHonoured_EnumAsStringAndCamelCase() + { + var options = SystemTextJsonRpcSerializer.CreateDefaultOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + var serializer = new SystemTextJsonRpcSerializer(options); + + // options built from CreateDefaultOptions already carry the converters: used as-is, not copied + Assert.AreSame(options, serializer.Options); + Assert.AreSame(options, serializer.EffectiveOptions); + + var response = JsonRpcProcessor.ProcessSync(SessionId, "{\"jsonrpc\":\"2.0\",\"method\":\"shape\",\"params\":[\"box\"],\"id\":7}", null, serializer); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":{\"shapeName\":\"box\",\"tint\":\"Green\",\"area\":4.0},\"id\":7}", response); + + // the default serializer still produces the library conventions for the same call + var plain = JsonRpcProcessor.ProcessSync(SessionId, "{\"jsonrpc\":\"2.0\",\"method\":\"shape\",\"params\":[\"box\"],\"id\":7}", null, null); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":{\"ShapeName\":\"box\",\"Tint\":1,\"Area\":4.0},\"id\":7}", plain); + } + + [Test] + public void UserOptionsWithoutConvertersAreCopiedNotMutated() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + options.Converters.Add(new JsonStringEnumConverter()); + // use the options elsewhere first so they are read-only, as they would be in a real app + JsonSerializer.Serialize(new Shape(), options); + Assert.IsTrue(options.IsReadOnly); + int before = options.Converters.Count; + + var serializer = new SystemTextJsonRpcSerializer(options); + + Assert.AreSame(options, serializer.Options); + Assert.AreNotSame(options, serializer.EffectiveOptions); + Assert.AreEqual(before, options.Converters.Count, "the caller's options must not be mutated"); + Assert.IsTrue(JsonRpcConverters.ContainsAll(serializer.EffectiveOptions)); + Assert.AreSame(JsonNamingPolicy.CamelCase, serializer.EffectiveOptions.PropertyNamingPolicy); + // the user's converter is still first, so it keeps precedence + Assert.IsInstanceOf(serializer.EffectiveOptions.Converters[0]); + + var response = JsonRpcProcessor.ProcessSync(SessionId, "{\"jsonrpc\":\"2.0\",\"method\":\"shape\",\"params\":[\"box\"],\"id\":1}", null, serializer); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":{\"shapeName\":\"box\",\"tint\":\"Green\",\"area\":4.0},\"id\":1}", response); + } + + [Test] + public void SessionSerializerIsUsed() + { + var options = SystemTextJsonRpcSerializer.CreateDefaultOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + var handler = Handler.GetSessionHandler(SessionId); + handler.Serializer = new SystemTextJsonRpcSerializer(options); + try + { + var response = JsonRpcProcessor.ProcessSync(SessionId, "{\"jsonrpc\":\"2.0\",\"method\":\"shape\",\"params\":[\"box\"],\"id\":1}", null, null); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":{\"shape_name\":\"box\",\"tint\":1,\"area\":4.0},\"id\":1}", response); + } + finally + { + handler.Serializer = null; + } + } + + // ------------------------------------------------------------------ converters on their own + + private static readonly JsonSerializerOptions Wire = SystemTextJsonRpcSerializer.DefaultOptions; + + [TestCase(3.0, "3.0")] + [TestCase(71.0, "71.0")] + [TestCase(0.0, "0.0")] + [TestCase(-2.0, "-2.0")] + [TestCase(3.14159, "3.14159")] + [TestCase(0.123, "0.123")] + [TestCase(1e21, "1E+21")] + public void DoubleWritesWithDecimalPlace(double value, string expected) + { + Assert.AreEqual(expected, JsonSerializer.Serialize(value, Wire)); + Assert.AreEqual(expected, JsonSerializer.Serialize((double?)value, Wire)); + } + + [TestCase(1.2345f, "1.2345")] + [TestCase(987f, "987.0")] + [TestCase(0f, "0.0")] + public void SingleWritesWithDecimalPlace(float value, string expected) + { + Assert.AreEqual(expected, JsonSerializer.Serialize(value, Wire)); + Assert.AreEqual(expected, JsonSerializer.Serialize((float?)value, Wire)); + } + + [Test] + public void DecimalWritesWithDecimalPlace() + { + Assert.AreEqual("71.0", JsonSerializer.Serialize(71m, Wire)); + Assert.AreEqual("0.0", JsonSerializer.Serialize(0.0m, Wire)); + Assert.AreEqual("1.25", JsonSerializer.Serialize(1.25m, Wire)); + Assert.AreEqual("671.0", JsonSerializer.Serialize((decimal?)671m, Wire)); + Assert.AreEqual("null", JsonSerializer.Serialize((decimal?)null, Wire)); + } + + [Test] + public void NumbersMatchTheBuiltInSerializer() + { + var jsmn = AustinHarris.JsonRpc.Jsmn.JsmnSerializer.Instance; + var stj = new SystemTextJsonRpcSerializer(); + foreach (var d in new[] { 0.0, 1.0, -1.0, 0.1, 1.5, 123456789.0, 1e-7, 1e21, 0.30000000000000004, double.MaxValue, double.Epsilon }) + { + Assert.AreEqual(jsmn.Serialize(d), stj.Serialize(d), "double " + d.ToString("R")); + } + foreach (var f in new[] { 0f, 1f, 1.2345f, 3.14159f, 987f, 1e-7f, float.MaxValue }) + { + Assert.AreEqual(jsmn.Serialize(f), stj.Serialize(f), "float " + f.ToString("R")); + } + foreach (var m in new[] { 0m, 0.0m, 1m, 71m, 1.25m, 987m, decimal.MaxValue, -0.5m }) + { + Assert.AreEqual(jsmn.Serialize(m), stj.Serialize(m), "decimal " + m); + } + } + + [Test] + public void NumericReadsCoerce() + { + Assert.AreEqual(1, JsonSerializer.Deserialize("true", Wire)); + Assert.AreEqual(0, JsonSerializer.Deserialize("false", Wire)); + Assert.AreEqual(12, JsonSerializer.Deserialize("\"12\"", Wire)); + Assert.AreEqual(2, JsonSerializer.Deserialize("2.5", Wire), "round half to even"); + Assert.AreEqual(4, JsonSerializer.Deserialize("3.5", Wire), "round half to even"); + Assert.AreEqual(71.0, JsonSerializer.Deserialize("71", Wire)); + Assert.AreEqual(1.5, JsonSerializer.Deserialize("\"1.5\"", Wire)); + Assert.AreEqual(1.0, JsonSerializer.Deserialize("true", Wire)); + Assert.AreEqual(71m, JsonSerializer.Deserialize("71", Wire)); + Assert.AreEqual(1.2345f, JsonSerializer.Deserialize("1.2345", Wire)); + Assert.AreEqual(255, JsonSerializer.Deserialize("255", Wire)); + Assert.AreEqual(ulong.MaxValue, JsonSerializer.Deserialize(ulong.MaxValue.ToString(), Wire)); + Assert.IsNull(JsonSerializer.Deserialize("null", Wire)); + Assert.AreEqual(5, JsonSerializer.Deserialize("5", Wire)); + Assert.Throws(() => JsonSerializer.Deserialize("\"mytext\"", Wire)); + Assert.Throws(() => JsonSerializer.Deserialize("\"mytext\"", Wire)); + Assert.Throws(() => JsonSerializer.Deserialize("null", Wire)); + Assert.Throws(() => JsonSerializer.Deserialize("256", Wire)); + } + + [Test] + public void BooleanReadsCoerce() + { + Assert.IsTrue(JsonSerializer.Deserialize("true", Wire)); + Assert.IsFalse(JsonSerializer.Deserialize("false", Wire)); + Assert.IsTrue(JsonSerializer.Deserialize("123", Wire)); + Assert.IsFalse(JsonSerializer.Deserialize("0", Wire)); + Assert.IsTrue(JsonSerializer.Deserialize("\"true\"", Wire)); + Assert.IsTrue(JsonSerializer.Deserialize("\"1\"", Wire)); + Assert.IsNull(JsonSerializer.Deserialize("null", Wire)); + Assert.AreEqual("true", JsonSerializer.Serialize(true, Wire)); + Assert.Throws(() => JsonSerializer.Deserialize("\"maybe\"", Wire)); + } + + [Test] + public void CharConverter() + { + Assert.AreEqual("\"b\"", JsonSerializer.Serialize('b', Wire)); + Assert.AreEqual("\"\\\"\"", JsonSerializer.Serialize('"', Wire)); + Assert.AreEqual("\"b\"", JsonSerializer.Serialize((char?)'b', Wire)); + Assert.AreEqual('b', JsonSerializer.Deserialize("98", Wire)); + Assert.AreEqual('b', JsonSerializer.Deserialize("\"b\"", Wire)); + Assert.AreEqual('b', JsonSerializer.Deserialize("98", Wire)); + Assert.Throws(() => JsonSerializer.Deserialize("\"bc\"", Wire)); + } + + [Test] + public void DateTimeConverter() + { + var text = "2014-06-30T14:50:38.5208399+09:00"; + var parsed = JsonSerializer.Deserialize("\"" + text + "\"", Wire); + Assert.AreEqual(DateTime.Parse(text), parsed); + Assert.AreEqual(DateTimeKind.Local, parsed.Kind); + Assert.AreEqual(parsed, JsonSerializer.Deserialize("\"" + text + "\"", Wire)); + Assert.IsNull(JsonSerializer.Deserialize("null", Wire)); + + // Json.NET's text: no fraction when it is zero, trailing zeros trimmed otherwise + var whole = new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc); + Assert.AreEqual("\"2020-01-02T03:04:05Z\"", JsonSerializer.Serialize(whole, Wire)); + Assert.AreEqual("\"2020-01-02T03:04:05\"", JsonSerializer.Serialize(DateTime.SpecifyKind(whole, DateTimeKind.Unspecified), Wire)); + Assert.AreEqual("\"2020-01-02T03:04:05.5Z\"", JsonSerializer.Serialize(whole.AddTicks(5_000_000), Wire)); + Assert.AreEqual("\"2020-01-02T03:04:05.5208399Z\"", JsonSerializer.Serialize(whole.AddTicks(5_208_399), Wire)); + Assert.AreEqual(Newtonsoft.Json.JsonConvert.SerializeObject(whole.AddTicks(5_208_399)), JsonSerializer.Serialize(whole.AddTicks(5_208_399), Wire)); + + // the '+' in an offset survives whatever encoder the options use + var local = new DateTime(2014, 6, 30, 14, 50, 38, DateTimeKind.Local).AddTicks(5208399); + var strictEncoder = new JsonSerializerOptions(); + JsonRpcConverters.AddMissing(strictEncoder); + Assert.AreEqual(JsonSerializer.Serialize(local, Wire), JsonSerializer.Serialize(local, strictEncoder)); + StringAssert.DoesNotContain("\\u002B", JsonSerializer.Serialize(local, strictEncoder)); + + Assert.Throws(() => JsonSerializer.Deserialize("\"not a date\"", Wire)); + Assert.Throws(() => JsonSerializer.Deserialize("12", Wire)); + } + + [Test] + public void DateTimesMatchTheBuiltInSerializer() + { + var jsmn = AustinHarris.JsonRpc.Jsmn.JsmnSerializer.Instance; + var stj = new SystemTextJsonRpcSerializer(); + var now = DateTime.Now; + foreach (var dt in new[] + { + now, now.ToUniversalTime(), DateTime.SpecifyKind(now, DateTimeKind.Unspecified), + new DateTime(2014, 6, 30, 14, 50, 38, DateTimeKind.Local).AddTicks(5208399), + new DateTime(1, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(9999, 12, 31, 23, 59, 59, DateTimeKind.Unspecified) + }) + { + Assert.AreEqual(jsmn.Serialize(dt), stj.Serialize(dt), dt.Kind.ToString()); + Assert.AreEqual(jsmn.Serialize((DateTime?)dt), stj.Serialize((DateTime?)dt), dt.Kind.ToString()); + } + foreach (var dto in new[] { DateTimeOffset.Now, DateTimeOffset.UtcNow, new DateTimeOffset(2014, 6, 30, 14, 50, 38, TimeSpan.FromHours(9)).AddTicks(5208399), new DateTimeOffset(2014, 6, 30, 14, 50, 38, TimeSpan.Zero) }) + { + Assert.AreEqual(jsmn.Serialize(dto), stj.Serialize(dto), dto.ToString()); + } + } + + // ------------------------------------------------------------------ object model + + [Test] + public void ReadAsObjectReturnsJsonElement() + { + var serializer = new SystemTextJsonRpcSerializer(); + var bytes = Encoding.UTF8.GetBytes("{\"str\":\"x\",\"n\":[1,2.5,null]}"); + + var value = serializer.Read(bytes, typeof(object)); + Assert.IsInstanceOf(value); + var element = (JsonElement)value; + Assert.AreEqual(JsonValueKind.Object, element.ValueKind); + Assert.AreEqual("x", element.GetProperty("str").GetString()); + Assert.AreEqual(3, element.GetProperty("n").GetArrayLength()); + + Assert.IsInstanceOf(serializer.Read(bytes)); + Assert.IsInstanceOf(serializer.Read(Encoding.UTF8.GetBytes("12"))); + Assert.IsNull(serializer.Read(Encoding.UTF8.GetBytes("null"), typeof(object))); + + // and it writes back out unchanged + Assert.AreEqual("{\"str\":\"x\",\"n\":[1,2.5,null]}", serializer.Serialize(value, typeof(object))); + Assert.AreEqual("{\"str\":\"x\",\"n\":[1,2.5,null]}", serializer.Serialize(element)); + } + + [Test] + public void ReadWriteRoundTripsValues() + { + var serializer = new SystemTextJsonRpcSerializer(); + Assert.AreEqual("abc", serializer.Read(Encoding.UTF8.GetBytes("\"abc\""))); + Assert.AreEqual(12, serializer.Read(Encoding.UTF8.GetBytes("12"))); + Assert.AreEqual(1.5, serializer.Read(Encoding.UTF8.GetBytes("1.5"))); + CollectionAssert.AreEqual(new[] { 1, 2 }, serializer.Read(Encoding.UTF8.GetBytes("[1,2]"))); + Assert.AreEqual("x", serializer.Read(Encoding.UTF8.GetBytes("{\"STR\":\"x\"}")).str, "public field, case-insensitive"); + Assert.AreEqual("[1,2]", serializer.Serialize(new List { 1, 2 })); + Assert.AreEqual("null", serializer.Serialize((string)null)); + Assert.AreEqual("null", serializer.Serialize(null, typeof(Shape))); + Assert.AreEqual("{\"ShapeName\":null,\"Tint\":0,\"Area\":0.0}", serializer.Serialize(new Shape())); + Assert.AreEqual("{\"ClassName\":\"System.InvalidOperationException\",\"Message\":\"boom\",\"Source\":null,\"StackTraceString\":null,\"HResult\":-2146233079,\"InnerException\":null}", + serializer.Serialize(ExceptionInfo.From(new InvalidOperationException("boom")))); + Assert.Throws(() => serializer.Read(Encoding.UTF8.GetBytes("\"x\""))); + Assert.Throws(() => serializer.Read(Encoding.UTF8.GetBytes("12 13")), "exactly one value"); + } + + [Test] + public void WriteDoesNotLeaveTrailingBytesAndSupportsNestedWriters() + { + var serializer = new SystemTextJsonRpcSerializer(); + using (var w = new PooledByteBufferWriter(16)) + { + serializer.Write(w, 1); + serializer.Write(w, "a"); + serializer.Write(w, new[] { 1.0, 2.5 }); + Assert.AreEqual("1\"a\"[1.0,2.5]", w.ToString()); + } + + // a converter that re-enters the serializer on the same thread must not corrupt the cached writer + var options = SystemTextJsonRpcSerializer.CreateDefaultOptions(); + options.Converters.Add(new ReentrantConverter(serializer)); + var reentrant = new SystemTextJsonRpcSerializer(options); + Assert.AreEqual("[{\"Inner\":\"{\\\"ShapeName\\\":\\\"s\\\",\\\"Tint\\\":0,\\\"Area\\\":1.0}\"},2]", reentrant.Serialize(new object[] { new Reentrant { Inner = new Shape { ShapeName = "s", Area = 1 } }, 2 })); + } + + public class Reentrant + { + public Shape Inner { get; set; } + } + + private sealed class ReentrantConverter : JsonConverter + { + private readonly SystemTextJsonRpcSerializer _serializer; + public ReentrantConverter(SystemTextJsonRpcSerializer serializer) { _serializer = serializer; } + public override Shape Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, Shape value, JsonSerializerOptions options) + { + // serialize the nested value with the outer serializer while the outer write is in progress + writer.WriteStringValue(_serializer.Serialize(value)); + } + } + + [Test] + public void PreProcessHandlerSeesJsonElementParams() + { + object seen = null; + var handler = Handler.GetSessionHandler(SessionId); + handler.SetPreProcessHandler((rpc, ctx) => { seen = rpc.Params; return null; }); + try + { + var response = JsonRpcProcessor.ProcessSync(SessionId, "{\"jsonrpc\":\"2.0\",\"method\":\"sum\",\"params\":{\"a\":1,\"b\":2},\"id\":3}", null, null); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":3}", response); + Assert.IsInstanceOf(seen); + Assert.AreEqual(2, ((JsonElement)seen).GetProperty("b").GetInt32()); + } + finally + { + handler.SetPreProcessHandler(null); + } + } + + [Test] + public void HandlerHandleRoundTripsJsonRequest() + { + var handler = Handler.GetSessionHandler(SessionId); + var serializer = new SystemTextJsonRpcSerializer(); + + var parameters = serializer.Read(Encoding.UTF8.GetBytes("[\"hello\"]"), typeof(object)); + var response = handler.Handle(new JsonRequest("echo", parameters, 5L)); + Assert.IsNull(response.Error); + Assert.AreEqual("hello", response.Result); + Assert.AreEqual(5L, response.Id); + + var named = serializer.Read(Encoding.UTF8.GetBytes("{\"b\":2,\"a\":40}"), typeof(object)); + response = handler.Handle(new JsonRequest("sum", named, "x")); + Assert.IsNull(response.Error); + Assert.AreEqual(42, response.Result); + Assert.AreEqual("x", response.Id); + + response = handler.Handle(new JsonRequest("sum", parameters, 1L)); + Assert.IsNotNull(response.Error); + Assert.AreEqual(-32602, response.Error.code); + } + } +} diff --git a/AustinHarris.JsonRpcTestN/Test.cs b/AustinHarris.JsonRpcTestN/Test.cs index ed46543..001fa3c 100644 --- a/AustinHarris.JsonRpcTestN/Test.cs +++ b/AustinHarris.JsonRpcTestN/Test.cs @@ -24,12 +24,39 @@ public Poco(int offset) public int Add(int input) { return input + _offset; } } - [TestFixture()] + /// + /// The whole suite runs once per serializer. Every fixture argument must produce byte-identical + /// responses for the wire conventions the tests assert (compact output, member order, ".0" on whole + /// floating values, ISO dates, char as a one-character string, nulls included). + /// + [TestFixture("jsmn")] + [TestFixture("newtonsoft")] + [TestFixture("stj")] public class Test { + private readonly string _serializerName; + + public Test(string serializerName) + { + _serializerName = serializerName; + } + + [OneTimeSetUp] + public void SelectSerializer() + { + Config.SetSerializer(SerializerCatalog.Create(_serializerName)); + } + + [OneTimeTearDown] + public void RestoreSerializer() + { + Config.SetSerializer(null); + } + [Test()] public void TestCase() { + Assert.AreEqual(_serializerName, Config.Serializer.Name); } static object[] services; @@ -42,7 +69,7 @@ static Test() [Test()] public void TestCanCreateMultipleServicesOfSameTypeInTheirOwnSessions() { - Func request = (int param) => String.Format("{{method:'add',params:[{0}],id:1}}", param); + Func request = (int param) => String.Format("{{\"method\":\"add\",\"params\":[{0}],\"id\":1}}", param); Func expectedResult = (int param) => String.Format("{{\"jsonrpc\":\"2.0\",\"result\":{0},\"id\":1}}", param); for (int i = 0; i < 100; i++) @@ -70,9 +97,9 @@ public void TestCanCreateAndRemoveSession() }.ToDictionary(x => x.Item1, x => x.Item2); h.RegisterFuction("workie", metadata, new System.Collections.Generic.Dictionary(),new Func(x => "workie ... " + x)); - string request = @"{method:'workie',params:{'sooper':'good'},id:1}"; + string request = @"{""method"":""workie"",""params"":{""sooper"":""good""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"workie ... good\",\"id\":1}"; - string expectedResultAfterDestroy = "{\"jsonrpc\":\"2.0\",\"error\":{\"message\":\"Method not found\",\"code\":-32601,\"data\":\"The method does not exist / is not available.\"},\"id\":1}"; + string expectedResultAfterDestroy = "{\"jsonrpc\":\"2.0\",\"error\":{\"message\":\"Method not found\",\"code\":-32601,\"data\":{\"method\":\"workie\"}},\"id\":1}"; var result = JsonRpcProcessor.Process("this one", request); result.Wait(); @@ -92,7 +119,7 @@ public void TestCanCreateAndRemoveSession() [Test()] public void TestInProcessClient() { - string request = @"{method:'NullableFloatToNullableFloat',params:[0.0],id:1}"; + string request = @"{""method"":""NullableFloatToNullableFloat"",""params"":[0.0],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":0.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -104,7 +131,7 @@ public void TestInProcessClient() [Test()] public void NullableDateTimeToNullableDateTime() { - string request = @"{method:'NullableDateTimeToNullableDateTime',params:['2014-06-30T14:50:38.5208399+09:00'],id:1}"; + string request = @"{""method"":""NullableDateTimeToNullableDateTime"",""params"":[""2014-06-30T14:50:38.5208399+09:00""],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"2014-06-30T14:50:38.5208399+09:00\",\"id\":1}"; var expectedDate = DateTime.Parse("2014-06-30T14:50:38.5208399+09:00"); var result = JsonRpcProcessor.Process(request); @@ -113,9 +140,9 @@ public void NullableDateTimeToNullableDateTime() Assert.AreEqual(expectedDate, acutalDate); } - [TestCase(@"{method:'NullableFloatToNullableFloat',params:[1.2345],id:1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1.2345,\"id\":1}")] - [TestCase(@"{method:'NullableFloatToNullableFloat',params:[3.14159],id:1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":3.14159,\"id\":1}")] - [TestCase(@"{method:'NullableFloatToNullableFloat',params:[null],id:1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":null,\"id\":1}")] + [TestCase(@"{""method"":""NullableFloatToNullableFloat"",""params"":[1.2345],""id"":1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1.2345,\"id\":1}")] + [TestCase(@"{""method"":""NullableFloatToNullableFloat"",""params"":[3.14159],""id"":1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":3.14159,\"id\":1}")] + [TestCase(@"{""method"":""NullableFloatToNullableFloat"",""params"":[null],""id"":1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":null,\"id\":1}")] public string NullableFloatToNullableFloat(string request) { var result = JsonRpcProcessor.Process(request); @@ -127,7 +154,7 @@ public string NullableFloatToNullableFloat(string request) [Test()] public void DecimalToNullableDecimal() { - string request = @"{method:'DecimalToNullableDecimal',params:[0.0],id:1}"; + string request = @"{""method"":""DecimalToNullableDecimal"",""params"":[0.0],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":0.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -138,7 +165,7 @@ public void DecimalToNullableDecimal() [Test()] public void StringToListOfString() { - string request = @"{method:'StringToListOfString',params:['some string'],id:1}"; + string request = @"{""method"":""StringToListOfString"",""params"":[""some string""],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":[\"one\",\"two\",\"three\",\"some string\"],\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -149,7 +176,7 @@ public void StringToListOfString() [Test()] public void CustomStringToListOfString() { - string request = @"{method:'CustomStringToListOfString',params:[{str:'some string'}],id:1}"; + string request = @"{""method"":""CustomStringToListOfString"",""params"":[{""str"":""some string""}],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":[\"one\",\"two\",\"three\",\"some string\"],\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -160,7 +187,7 @@ public void CustomStringToListOfString() [Test()] public void StringToThrowingException() { - string request = @"{method:'StringToThrowingException',params:['some string'],id:1}"; + string request = @"{""method"":""StringToThrowingException"",""params"":[""some string""],""id"":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); StringAssert.Contains("-32603", result.Result); @@ -169,7 +196,7 @@ public void StringToThrowingException() [Test()] public void StringToRefException() { - string request = @"{method:'StringToRefException',params:['some string'],id:1}"; + string request = @"{""method"":""StringToRefException"",""params"":[""some string""],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"error\":{\"message\":\"refException worked\",\"code\":-1,\"data\":null},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -179,7 +206,7 @@ public void StringToRefException() [Test()] public void StringToThrowJsonRpcException() { - string request = @"{method:'StringToThrowJsonRpcException',params:['some string'],id:1}"; + string request = @"{""method"":""StringToThrowJsonRpcException"",""params"":[""some string""],""id"":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); StringAssert.Contains("-2700", result.Result); @@ -188,7 +215,7 @@ public void StringToThrowJsonRpcException() [Test()] public void ReturnsDateTime() { - string request = @"{method:'ReturnsDateTime',params:[],id:1}"; + string request = @"{""method"":""ReturnsDateTime"",""params"":[],""id"":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); Assert.IsFalse(result.Result.Contains("error")); @@ -197,7 +224,7 @@ public void ReturnsDateTime() [Test()] public void ReturnsCustomRecursiveClass() { - string request = @"{method:'ReturnsCustomRecursiveClass',params:[],id:1}"; + string request = @"{""method"":""ReturnsCustomRecursiveClass"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":{\"Nested1\":{\"Nested1\":null,\"Value1\":5},\"Value1\":10},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -209,7 +236,7 @@ public void ReturnsCustomRecursiveClass() [Test()] public void FloatToFloat() { - string request = @"{method:'FloatToFloat',params:[0.123],id:1}"; + string request = @"{""method"":""FloatToFloat"",""params"":[0.123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":0.123,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -221,7 +248,7 @@ public void FloatToFloat() [Test()] public void IntToInt() { - string request = @"{method:'IntToInt',params:[789],id:1}"; + string request = @"{""method"":""IntToInt"",""params"":[789],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":789,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -232,7 +259,7 @@ public void IntToInt() [Test()] public void OptionalParamInt16() { - string request = @"{method:'TestOptionalParamInt16',params:[789],id:1}"; + string request = @"{""method"":""TestOptionalParamInt16"",""params"":[789],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":789,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -243,7 +270,7 @@ public void OptionalParamInt16() [Test()] public void OptionalParamInt16NoParam() { - string request = @"{method:'TestOptionalParamInt16',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamInt16"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":789,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -254,7 +281,7 @@ public void OptionalParamInt16NoParam() [Test()] public void Int16ToInt16() { - string request = @"{method:'Int16ToInt16',params:[789],id:1}"; + string request = @"{""method"":""Int16ToInt16"",""params"":[789],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":789,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -265,7 +292,7 @@ public void Int16ToInt16() [Test()] public void Int32ToInt32() { - string request = @"{method:'Int32ToInt32',params:[789],id:1}"; + string request = @"{""method"":""Int32ToInt32"",""params"":[789],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":789,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -276,7 +303,7 @@ public void Int32ToInt32() [Test()] public void Int64ToInt64() { - string request = @"{method:'Int64ToInt64',params:[78915984515564],id:1}"; + string request = @"{""method"":""Int64ToInt64"",""params"":[78915984515564],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":78915984515564,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -288,7 +315,7 @@ public void Int64ToInt64() [Test()] public void TestOptionalParamByteMissing() { - string request = @"{method:'TestOptionalParambyte',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParambyte"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -298,7 +325,7 @@ public void TestOptionalParamByteMissing() [Test()] public void TestOptionalParamSbyteMissing() { - string request = @"{method:'TestOptionalParamsbyte',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamsbyte"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -308,7 +335,7 @@ public void TestOptionalParamSbyteMissing() [Test()] public void TestOptionalParamShortMissing() { - string request = @"{method:'TestOptionalParamshort',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamshort"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -318,7 +345,7 @@ public void TestOptionalParamShortMissing() [Test()] public void TestOptionalParamintMissing() { - string request = @"{method:'TestOptionalParamint',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamint"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -328,7 +355,7 @@ public void TestOptionalParamintMissing() [Test()] public void TestOptionalParamLongMissing() { - string request = @"{method:'TestOptionalParamlong',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamlong"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -338,7 +365,7 @@ public void TestOptionalParamLongMissing() [Test()] public void TestOptionalParamUshortMissing() { - string request = @"{method:'TestOptionalParamushort',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamushort"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -348,7 +375,7 @@ public void TestOptionalParamUshortMissing() [Test()] public void TestOptionalParamUintMissing() { - string request = @"{method:'TestOptionalParamuint',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamuint"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -358,7 +385,7 @@ public void TestOptionalParamUintMissing() [Test()] public void TestOptionalParamUlongMissing() { - string request = @"{method:'TestOptionalParamulong',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamulong"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -368,7 +395,7 @@ public void TestOptionalParamUlongMissing() [Test()] public void TestOptionalParamFloatMissing() { - string request = @"{method:'TestOptionalParamfloat',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamfloat"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -378,7 +405,7 @@ public void TestOptionalParamFloatMissing() [Test()] public void TestOptionalParamDoubleMissing() { - string request = @"{method:'TestOptionalParamdouble',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamdouble"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -388,7 +415,7 @@ public void TestOptionalParamDoubleMissing() [Test()] public void TestOptionalParamBoolMissing() { - string request = @"{method:'TestOptionalParambool',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParambool"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":true,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -398,7 +425,7 @@ public void TestOptionalParamBoolMissing() [Test()] public void TestOptionalParamCharMissing() { - string request = @"{method:'TestOptionalParamchar',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamchar"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"a\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -408,7 +435,7 @@ public void TestOptionalParamCharMissing() [Test()] public void TestOptionalParamDecimalMissing() { - string request = @"{method:'TestOptionalParamdecimal',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParamdecimal"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -419,7 +446,7 @@ public void TestOptionalParamDecimalMissing() [Test()] public void TestOptionalParamBytePresent() { - string request = @"{method:'TestOptionalParambyte',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParambyte"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -429,7 +456,7 @@ public void TestOptionalParamBytePresent() [Test()] public void TestOptionalParamSbytePresent() { - string request = @"{method:'TestOptionalParamsbyte',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamsbyte"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -439,7 +466,7 @@ public void TestOptionalParamSbytePresent() [Test()] public void TestOptionalParamShortPresent() { - string request = @"{method:'TestOptionalParamshort',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamshort"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -449,7 +476,7 @@ public void TestOptionalParamShortPresent() [Test()] public void TestOptionalParamintPresent() { - string request = @"{method:'TestOptionalParamint',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamint"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -459,7 +486,7 @@ public void TestOptionalParamintPresent() [Test()] public void TestOptionalParamLongPresent() { - string request = @"{method:'TestOptionalParamlong',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamlong"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -469,7 +496,7 @@ public void TestOptionalParamLongPresent() [Test()] public void TestOptionalParamUshortPresent() { - string request = @"{method:'TestOptionalParamushort',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamushort"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -479,7 +506,7 @@ public void TestOptionalParamUshortPresent() [Test()] public void TestOptionalParamUintPresent() { - string request = @"{method:'TestOptionalParamuint',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamuint"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -489,7 +516,7 @@ public void TestOptionalParamUintPresent() [Test()] public void TestOptionalParamUlongPresent() { - string request = @"{method:'TestOptionalParamulong',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamulong"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -499,7 +526,7 @@ public void TestOptionalParamUlongPresent() [Test()] public void TestOptionalParamFloatPresent() { - string request = @"{method:'TestOptionalParamfloat',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamfloat"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -509,7 +536,7 @@ public void TestOptionalParamFloatPresent() [Test()] public void TestOptionalParamDoublePresent() { - string request = @"{method:'TestOptionalParamdouble',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamdouble"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -519,7 +546,7 @@ public void TestOptionalParamDoublePresent() [Test()] public void TestOptionalParamBoolPresent() { - string request = @"{method:'TestOptionalParambool',params:[false],id:1}"; + string request = @"{""method"":""TestOptionalParambool"",""params"":[false],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":false,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -529,7 +556,7 @@ public void TestOptionalParamBoolPresent() [Test()] public void TestOptionalParamCharPresent() { - string request = @"{method:'TestOptionalParamchar',params:[" + (int)'b' + "],id:1}"; + string request = @"{""method"":""TestOptionalParamchar"",""params"":[" + (int)'b' + "],\"id\":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"b\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -539,7 +566,7 @@ public void TestOptionalParamCharPresent() [Test()] public void TestOptionalParamDecimalPresent() { - string request = @"{method:'TestOptionalParamdecimal',params:[71],id:1}"; + string request = @"{""method"":""TestOptionalParamdecimal"",""params"":[71],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -550,7 +577,7 @@ public void TestOptionalParamDecimalPresent() [Test()] public void TestOptionalParamBytePresentObjectSyntax() { - string request = @"{method:'TestOptionalParambyte',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParambyte"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -560,7 +587,7 @@ public void TestOptionalParamBytePresentObjectSyntax() [Test()] public void TestOptionalParamSbytePresentObjectSyntax() { - string request = @"{method:'TestOptionalParamsbyte',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamsbyte"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -570,7 +597,7 @@ public void TestOptionalParamSbytePresentObjectSyntax() [Test()] public void TestOptionalParamShortPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamshort',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamshort"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -580,7 +607,7 @@ public void TestOptionalParamShortPresentObjectSyntax() [Test()] public void TestOptionalParamintPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamint',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamint"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -590,7 +617,7 @@ public void TestOptionalParamintPresentObjectSyntax() [Test()] public void TestOptionalParamLongPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamlong',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamlong"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -600,7 +627,7 @@ public void TestOptionalParamLongPresentObjectSyntax() [Test()] public void TestOptionalParamUshortPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamushort',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamushort"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -610,7 +637,7 @@ public void TestOptionalParamUshortPresentObjectSyntax() [Test()] public void TestOptionalParamUintPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamuint',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamuint"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -620,7 +647,7 @@ public void TestOptionalParamUintPresentObjectSyntax() [Test()] public void TestOptionalParamUlongPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamulong',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamulong"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -630,7 +657,7 @@ public void TestOptionalParamUlongPresentObjectSyntax() [Test()] public void TestOptionalParamFloatPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamfloat',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamfloat"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -640,7 +667,7 @@ public void TestOptionalParamFloatPresentObjectSyntax() [Test()] public void TestOptionalParamDoublePresentObjectSyntax() { - string request = @"{method:'TestOptionalParamdouble',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamdouble"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -650,7 +677,7 @@ public void TestOptionalParamDoublePresentObjectSyntax() [Test()] public void TestOptionalParamBoolPresentObjectSyntax() { - string request = @"{method:'TestOptionalParambool',params:{'input':false},id:1}"; + string request = @"{""method"":""TestOptionalParambool"",""params"":{""input"":false},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":false,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -660,7 +687,7 @@ public void TestOptionalParamBoolPresentObjectSyntax() [Test()] public void TestOptionalParamCharPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamchar',params:{'input':" + (int)'c' + "},id:1}"; + string request = @"{""method"":""TestOptionalParamchar"",""params"":{""input"":" + (int)'c' + "},\"id\":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"c\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -670,7 +697,7 @@ public void TestOptionalParamCharPresentObjectSyntax() [Test()] public void TestOptionalParamDecimalPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamdecimal',params:{'input':71},id:1}"; + string request = @"{""method"":""TestOptionalParamdecimal"",""params"":{""input"":71},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":71.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -681,7 +708,7 @@ public void TestOptionalParamDecimalPresentObjectSyntax() [Test()] public void TestOptionalParamByteMissingObjectSyntax() { - string request = @"{method:'TestOptionalParambyte',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParambyte"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -691,7 +718,7 @@ public void TestOptionalParamByteMissingObjectSyntax() [Test()] public void TestOptionalParamSbyteMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamsbyte',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamsbyte"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -701,7 +728,7 @@ public void TestOptionalParamSbyteMissingObjectSyntax() [Test()] public void TestOptionalParamShortMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamshort',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamshort"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -711,7 +738,7 @@ public void TestOptionalParamShortMissingObjectSyntax() [Test()] public void TestOptionalParamintMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamint',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamint"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -721,7 +748,7 @@ public void TestOptionalParamintMissingObjectSyntax() [Test()] public void TestOptionalParamLongMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamlong',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamlong"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -731,7 +758,7 @@ public void TestOptionalParamLongMissingObjectSyntax() [Test()] public void TestOptionalParamUshortMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamushort',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamushort"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -741,7 +768,7 @@ public void TestOptionalParamUshortMissingObjectSyntax() [Test()] public void TestOptionalParamUintMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamuint',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamuint"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -751,7 +778,7 @@ public void TestOptionalParamUintMissingObjectSyntax() [Test()] public void TestOptionalParamUlongMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamulong',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamulong"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -761,7 +788,7 @@ public void TestOptionalParamUlongMissingObjectSyntax() [Test()] public void TestOptionalParamFloatMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamfloat',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamfloat"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -771,7 +798,7 @@ public void TestOptionalParamFloatMissingObjectSyntax() [Test()] public void TestOptionalParamDoubleMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamdouble',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamdouble"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -781,7 +808,7 @@ public void TestOptionalParamDoubleMissingObjectSyntax() [Test()] public void TestOptionalParamBoolMissingObjectSyntax() { - string request = @"{method:'TestOptionalParambool',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParambool"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":true,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -791,7 +818,7 @@ public void TestOptionalParamBoolMissingObjectSyntax() [Test()] public void TestOptionalParamCharMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamchar',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamchar"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"a\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -801,7 +828,7 @@ public void TestOptionalParamCharMissingObjectSyntax() [Test()] public void TestOptionalParamDecimalMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamdecimal',params:{},id:1}"; + string request = @"{""method"":""TestOptionalParamdecimal"",""params"":{},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":1.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -812,7 +839,7 @@ public void TestOptionalParamDecimalMissingObjectSyntax() [Test()] public void TestOptionalParamByte_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParambyte_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParambyte_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":98,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -822,7 +849,7 @@ public void TestOptionalParamByte_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamSbyte_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamsbyte_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamsbyte_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":126,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -832,7 +859,7 @@ public void TestOptionalParamSbyte_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamShort_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamshort_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamshort_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -842,7 +869,7 @@ public void TestOptionalParamShort_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamint_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamint_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamint_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -852,7 +879,7 @@ public void TestOptionalParamint_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamLong_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamlong_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamlong_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -862,7 +889,7 @@ public void TestOptionalParamLong_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamUshort_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamushort_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamushort_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -872,7 +899,7 @@ public void TestOptionalParamUshort_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamUint_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamuint_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamuint_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -882,7 +909,7 @@ public void TestOptionalParamUint_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamUlong_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamulong_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamulong_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -892,7 +919,7 @@ public void TestOptionalParamUlong_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamFloat_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamfloat_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamfloat_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -902,7 +929,7 @@ public void TestOptionalParamFloat_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamDouble_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamdouble_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamdouble_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -912,7 +939,7 @@ public void TestOptionalParamDouble_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamBool_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParambool_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParambool_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":true,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -922,7 +949,7 @@ public void TestOptionalParamBool_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamChar_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamchar_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamchar_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"d\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -932,7 +959,7 @@ public void TestOptionalParamChar_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamDecimal_2ndMissingObjectSyntax() { - string request = @"{method:'TestOptionalParamdecimal_2x',params:{input1:123},id:1}"; + string request = @"{""method"":""TestOptionalParamdecimal_2x"",""params"":{""input1"":123},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -943,7 +970,7 @@ public void TestOptionalParamDecimal_2ndMissingObjectSyntax() [Test()] public void TestOptionalParamByte_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParambyte_2x',params:{input1:123, input2: 67},id:1}"; + string request = @"{""method"":""TestOptionalParambyte_2x"",""params"":{""input1"":123, ""input2"": 67},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":67,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -953,7 +980,7 @@ public void TestOptionalParamByte_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamByte_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParambyte_2x',params:[123, 67],id:1}"; + string request = @"{""method"":""TestOptionalParambyte_2x"",""params"":[123, 67],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":67,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -963,7 +990,7 @@ public void TestOptionalParamByte_2ndPresentArraySyntax() [Test()] public void TestOptionalParamByte_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParambyte_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParambyte_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":98,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -973,7 +1000,7 @@ public void TestOptionalParamByte_2ndMissingArraySyntax() [Test()] public void TestOptionalParamSbyte_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamsbyte_2x',params:{input1:123, input2: 97},id:1}"; + string request = @"{""method"":""TestOptionalParamsbyte_2x"",""params"":{""input1"":123, ""input2"": 97},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":97,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -983,7 +1010,7 @@ public void TestOptionalParamSbyte_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamSbyte_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamsbyte_2x',params:[123, 98],id:1}"; + string request = @"{""method"":""TestOptionalParamsbyte_2x"",""params"":[123, 98],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":98,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -993,7 +1020,7 @@ public void TestOptionalParamSbyte_2ndPresentArraySyntax() [Test()] public void TestOptionalParamSbyte_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamsbyte_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamsbyte_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":126,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1003,7 +1030,7 @@ public void TestOptionalParamSbyte_2ndMissingArraySyntax() [Test()] public void TestOptionalParamShort_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamshort_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamshort_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1013,7 +1040,7 @@ public void TestOptionalParamShort_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamShort_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamshort_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamshort_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1023,7 +1050,7 @@ public void TestOptionalParamShort_2ndPresentArraySyntax() [Test()] public void TestOptionalParamShort_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamshort_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamshort_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1033,7 +1060,7 @@ public void TestOptionalParamShort_2ndMissingArraySyntax() [Test()] public void TestOptionalParamint_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamint_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamint_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1043,7 +1070,7 @@ public void TestOptionalParamint_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamint_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamint_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamint_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1053,7 +1080,7 @@ public void TestOptionalParamint_2ndPresentArraySyntax() [Test()] public void TestOptionalParamint_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamint_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamint_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1063,7 +1090,7 @@ public void TestOptionalParamint_2ndMissingArraySyntax() [Test()] public void TestOptionalParamLong_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamlong_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamlong_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1073,7 +1100,7 @@ public void TestOptionalParamLong_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamLong_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamlong_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamlong_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1083,7 +1110,7 @@ public void TestOptionalParamLong_2ndPresentArraySyntax() [Test()] public void TestOptionalParamLong_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamlong_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamlong_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1093,7 +1120,7 @@ public void TestOptionalParamLong_2ndMissingArraySyntax() [Test()] public void TestOptionalParamUshort_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamushort_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamushort_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1103,7 +1130,7 @@ public void TestOptionalParamUshort_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamUshort_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamushort_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamushort_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1113,7 +1140,7 @@ public void TestOptionalParamUshort_2ndPresentArraySyntax() [Test()] public void TestOptionalParamUshort_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamushort_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamushort_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1123,7 +1150,7 @@ public void TestOptionalParamUshort_2ndMissingArraySyntax() [Test()] public void TestOptionalParamUint_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamuint_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamuint_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1133,7 +1160,7 @@ public void TestOptionalParamUint_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamUint_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamuint_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamuint_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1143,7 +1170,7 @@ public void TestOptionalParamUint_2ndPresentArraySyntax() [Test()] public void TestOptionalParamUint_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamuint_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamuint_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1153,7 +1180,7 @@ public void TestOptionalParamUint_2ndMissingArraySyntax() [Test()] public void TestOptionalParamUlong_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamulong_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamulong_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1163,7 +1190,7 @@ public void TestOptionalParamUlong_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamUlong_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamulong_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamulong_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1173,7 +1200,7 @@ public void TestOptionalParamUlong_2ndPresentArraySyntax() [Test()] public void TestOptionalParamUlong_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamulong_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamulong_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1183,7 +1210,7 @@ public void TestOptionalParamUlong_2ndMissingArraySyntax() [Test()] public void TestOptionalParamFloat_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamfloat_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamfloat_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1193,7 +1220,7 @@ public void TestOptionalParamFloat_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamFloat_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamfloat_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamfloat_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1203,7 +1230,7 @@ public void TestOptionalParamFloat_2ndPresentArraySyntax() [Test()] public void TestOptionalParamFloat_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamfloat_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamfloat_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1213,7 +1240,7 @@ public void TestOptionalParamFloat_2ndMissingArraySyntax() [Test()] public void TestOptionalParamDouble_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamdouble_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamdouble_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1223,7 +1250,7 @@ public void TestOptionalParamDouble_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamDouble_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamdouble_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamdouble_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1233,7 +1260,7 @@ public void TestOptionalParamDouble_2ndPresentArraySyntax() [Test()] public void TestOptionalParamDouble_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamdouble_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamdouble_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1243,7 +1270,7 @@ public void TestOptionalParamDouble_2ndMissingArraySyntax() [Test()] public void TestOptionalParamBool_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParambool_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParambool_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":true,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1253,7 +1280,7 @@ public void TestOptionalParamBool_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamBool_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParambool_2x',params:[true, false],id:1}"; + string request = @"{""method"":""TestOptionalParambool_2x"",""params"":[true, false],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":false,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1263,7 +1290,7 @@ public void TestOptionalParamBool_2ndPresentArraySyntax() [Test()] public void TestOptionalParamBool_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParambool_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParambool_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":true,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1273,7 +1300,7 @@ public void TestOptionalParamBool_2ndMissingArraySyntax() [Test()] public void TestOptionalParamChar_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamchar_2x',params:{'input1':" + (int)'c' + ", 'input2':" + (int)'d' + "},id:1}"; + string request = @"{""method"":""TestOptionalParamchar_2x"",""params"":{""input1"":" + (int)'c' + ", \"input2\":" + (int)'d' + "},\"id\":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"d\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1283,7 +1310,7 @@ public void TestOptionalParamChar_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamChar_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamchar_2x',params:[" + (int)'c' + ", " + (int)'d' + "],id:1}"; + string request = @"{""method"":""TestOptionalParamchar_2x"",""params"":[" + (int)'c' + ", " + (int)'d' + "],\"id\":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"d\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1293,7 +1320,7 @@ public void TestOptionalParamChar_2ndPresentArraySyntax() [Test()] public void TestOptionalParamChar_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamchar_2x',params:[" + (int)'c' + "],id:1}"; + string request = @"{""method"":""TestOptionalParamchar_2x"",""params"":[" + (int)'c' + "],\"id\":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"d\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1303,7 +1330,7 @@ public void TestOptionalParamChar_2ndMissingArraySyntax() [Test()] public void TestOptionalParamDecimal_2ndPresentObjectSyntax() { - string request = @"{method:'TestOptionalParamdecimal_2x',params:{input1:123, input2: 671},id:1}"; + string request = @"{""method"":""TestOptionalParamdecimal_2x"",""params"":{""input1"":123, ""input2"": 671},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1313,7 +1340,7 @@ public void TestOptionalParamDecimal_2ndPresentObjectSyntax() [Test()] public void TestOptionalParamDecimal_2ndPresentArraySyntax() { - string request = @"{method:'TestOptionalParamdecimal_2x',params:[123, 671],id:1}"; + string request = @"{""method"":""TestOptionalParamdecimal_2x"",""params"":[123, 671],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":671.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1323,7 +1350,7 @@ public void TestOptionalParamDecimal_2ndPresentArraySyntax() [Test()] public void TestOptionalParamDecimal_2ndMissingArraySyntax() { - string request = @"{method:'TestOptionalParamdecimal_2x',params:[123],id:1}"; + string request = @"{""method"":""TestOptionalParamdecimal_2x"",""params"":[123],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":987.0,\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1334,7 +1361,7 @@ public void TestOptionalParamDecimal_2ndMissingArraySyntax() [Test()] public void TestOptionalParametersStrings_BothMissing() { - string request = @"{method:'TestOptionalParameters_Strings',params:[],id:1}"; + string request = @"{""method"":""TestOptionalParameters_Strings"",""params"":[],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":[null,null],\"id\":1}"; var result = JsonRpcProcessor.Process(request); @@ -1346,7 +1373,7 @@ public void TestOptionalParametersStrings_BothMissing() [Test()] public void TestOptionalParametersStrings_SecondMissing() { - string request = @"{method:'TestOptionalParameters_Strings',params:['first'],id:1}"; + string request = @"{""method"":""TestOptionalParameters_Strings"",""params"":[""first""],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":[\"first\",null],\"id\":1}"; var result = JsonRpcProcessor.Process(request); @@ -1358,7 +1385,7 @@ public void TestOptionalParametersStrings_SecondMissing() [Test()] public void TestOptionalParametersStrings_BothExists() { - string request = @"{method:'TestOptionalParameters_Strings',params:['first','second'],id:1}"; + string request = @"{""method"":""TestOptionalParameters_Strings"",""params"":[""first"",""second""],""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":[\"first\",\"second\"],\"id\":1}"; var result = JsonRpcProcessor.Process(request); @@ -1380,11 +1407,11 @@ public void TestOptionalParametersBoolsAndStrings() Assert.AreEqual(expectedResult, result.Result); } - [TestCase("{method:\"TestDifferentOptionalParameters\",params:{location:\"loc1\", uid:\"abc123\", wavelengths: [0.0], traces: [0.0]},id:1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] - [TestCase("{method:\"TestDifferentOptionalParameters\",params:{uid:\"abc123\", wavelengths: [0.0], traces: [0.0]},id:1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] - [TestCase("{method:\"TestDifferentOptionalParameters\",params:{location:\"loc1\", uid:\"abc123\", traces: [0.0]},id:1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] - [TestCase("{method:\"TestDifferentOptionalParameters\",params:{location:\"loc1\", uid:\"abc123\", wavelengths: [0.0]},id:1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] - [TestCase("{method:\"TestDifferentOptionalParameters\",params:{uid:\"abc123\", wavelengths: [0.0]},id:1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] + [TestCase("{\"method\":\"TestDifferentOptionalParameters\",\"params\":{\"location\":\"loc1\", \"uid\":\"abc123\", \"wavelengths\": [0.0], \"traces\": [0.0]},\"id\":1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] + [TestCase("{\"method\":\"TestDifferentOptionalParameters\",\"params\":{\"uid\":\"abc123\", \"wavelengths\": [0.0], \"traces\": [0.0]},\"id\":1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] + [TestCase("{\"method\":\"TestDifferentOptionalParameters\",\"params\":{\"location\":\"loc1\", \"uid\":\"abc123\", \"traces\": [0.0]},\"id\":1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] + [TestCase("{\"method\":\"TestDifferentOptionalParameters\",\"params\":{\"location\":\"loc1\", \"uid\":\"abc123\", \"wavelengths\": [0.0]},\"id\":1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] + [TestCase("{\"method\":\"TestDifferentOptionalParameters\",\"params\":{\"uid\":\"abc123\", \"wavelengths\": [0.0]},\"id\":1}", ExpectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"this is the requested measurement\",\"id\":1}")] public string TestDifferentOptionalParametersNamedWorking(string request) { var result = JsonRpcProcessor.Process(request); @@ -1466,7 +1493,9 @@ public void TestSingleResultBatch() var result = JsonRpcProcessor.Process(@"[{""jsonrpc"":""2.0"",""method"":""ReturnsDateTime"",""params"":{},""id"":1}]"); result.Wait(); - Assert.IsFalse(result.Result.EndsWith("]")); + // JSON-RPC 2.0: a batch that produces responses answers with an array, even a one-element one. + Assert.IsTrue(result.Result.StartsWith("[") && result.Result.EndsWith("]"), result.Result); + Assert.IsFalse(result.Result.Contains("},{"), "exactly one response expected: " + result.Result); } class PreProcessHandlerLocal @@ -1492,7 +1521,7 @@ public void TestPreProcessor() try { PreProcessHandlerLocal handler = new PreProcessHandlerLocal(); Config.SetPreProcessHandler(new PreProcessHandler(handler.PreProcess)); - string request = @"{method:'TestPreProcessor',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPreProcessor"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"Success!\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1513,7 +1542,7 @@ public void TestPreProcessorThrowsJsonRPCException() { PreProcessHandlerLocal handler = new PreProcessHandlerLocal(); Config.SetPreProcessHandler(new PreProcessHandler(handler.PreProcess)); - string request = @"{method:'TestPreProcessorThrowsJsonRPCException',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPreProcessorThrowsJsonRPCException"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-27000,\"message\":\"Just some testing\",\"data\":null},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1535,7 +1564,7 @@ public void TestPreProcessorThrowsException() { PreProcessHandlerLocal handler = new PreProcessHandlerLocal(); Config.SetPreProcessHandler(new PreProcessHandler(handler.PreProcess)); - string request = @"{method:'TestPreProcessorThrowsException',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPreProcessorThrowsException"",""params"":{""inputValue"":""some string""},""id"":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); StringAssert.Contains("-32603", result.Result); @@ -1556,7 +1585,7 @@ public void TestPreProcessorSetsException() { PreProcessHandlerLocal handler = new PreProcessHandlerLocal(); Config.SetPreProcessHandler(new PreProcessHandler(handler.PreProcess)); - string request = @"{method:'TestPreProcessorSetsException',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPreProcessorSetsException"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-27000,\"message\":\"This exception was thrown using: JsonRpcContext.SetException()\",\"data\":null},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1585,9 +1614,9 @@ public void TestPreProcessOnSession() }.ToDictionary(x => x.Item1, x => x.Item2); h.RegisterFuction("workie", metadata, new System.Collections.Generic.Dictionary(),new Func(x => "workie ... " + x)); - string request = @"{method:'workie',params:{'sooper':'good'},id:1}"; + string request = @"{""method"":""workie"",""params"":{""sooper"":""good""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"workie ... good\",\"id\":1}"; - string expectedResultAfterDestroy = "{\"jsonrpc\":\"2.0\",\"error\":{\"message\":\"Method not found\",\"code\":-32601,\"data\":\"The method does not exist / is not available.\"},\"id\":1}"; + string expectedResultAfterDestroy = "{\"jsonrpc\":\"2.0\",\"error\":{\"message\":\"Method not found\",\"code\":-32601,\"data\":{\"method\":\"workie\"}},\"id\":1}"; var result = JsonRpcProcessor.Process(sessionId, request); result.Wait(); @@ -1641,7 +1670,7 @@ public void TestPostProcessor() { PostProcessHandlerLocal handler = new PostProcessHandlerLocal(false); Config.SetPostProcessHandler(new PostProcessHandler(handler.PostProcess)); - string request = @"{method:'TestPostProcessor',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPostProcessor"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"Success!\",\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1665,7 +1694,7 @@ public void TestPostProcessorThrowsJsonRPCException() { PostProcessHandlerLocal handler = new PostProcessHandlerLocal(false); Config.SetPostProcessHandler(new PostProcessHandler(handler.PostProcess)); - string request = @"{method:'TestPostProcessorThrowsJsonRPCException',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPostProcessorThrowsJsonRPCException"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-27000,\"message\":\"Just some testing\",\"data\":null},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1692,7 +1721,7 @@ public void TestPostProcessorThrowsException() { PostProcessHandlerLocal handler = new PostProcessHandlerLocal(false); Config.SetPostProcessHandler(new PostProcessHandler(handler.PostProcess)); - string request = @"{method:'TestPostProcessorThrowsException',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPostProcessorThrowsException"",""params"":{""inputValue"":""some string""},""id"":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); StringAssert.Contains("-32603", result.Result); @@ -1717,7 +1746,7 @@ public void TestPostProcessorSetsException() { PostProcessHandlerLocal handler = new PostProcessHandlerLocal(false); Config.SetPostProcessHandler(new PostProcessHandler(handler.PostProcess)); - string request = @"{method:'TestPostProcessorSetsException',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPostProcessorSetsException"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-27001,\"message\":\"This exception was thrown using: JsonRpcContext.SetException()\",\"data\":null},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1740,7 +1769,7 @@ public void TestPostProcessorChangesReturn() { PostProcessHandlerLocal handler = new PostProcessHandlerLocal(true); Config.SetPostProcessHandler(new PostProcessHandler(handler.PostProcess)); - string request = @"{method:'TestPostProcessor',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPostProcessor"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-123,\"message\":\"Test error\",\"data\":null},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1765,7 +1794,7 @@ public void TestPostProcessorThrowsJsonRPCExceptionChangesReturn() { PostProcessHandlerLocal handler = new PostProcessHandlerLocal(true); Config.SetPostProcessHandler(new PostProcessHandler(handler.PostProcess)); - string request = @"{method:'TestPostProcessorThrowsJsonRPCException',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPostProcessorThrowsJsonRPCException"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-123,\"message\":\"Test error\",\"data\":null},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1792,7 +1821,7 @@ public void TestPostProcessorThrowsExceptionChangesReturn() { PostProcessHandlerLocal handler = new PostProcessHandlerLocal(true); Config.SetPostProcessHandler(new PostProcessHandler(handler.PostProcess)); - string request = @"{method:'TestPostProcessorThrowsException',params:{inputValue:'some string'},id:1}"; + string request = @"{""method"":""TestPostProcessorThrowsException"",""params"":{""inputValue"":""some string""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"error\":{\"message\":\"Test error\",\"code\":-123,\"data\":null},\"id\":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); @@ -1825,9 +1854,9 @@ public void TestPostProcessOnSession() }.ToDictionary(x => x.Item1, x => x.Item2); h.RegisterFuction("workie", metadata, new System.Collections.Generic.Dictionary(), new Func(x => "workie ... " + x)); - string request = @"{method:'workie',params:{'sooper':'good'},id:1}"; + string request = @"{""method"":""workie"",""params"":{""sooper"":""good""},""id"":1}"; string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":\"workie ... good\",\"id\":1}"; - string expectedResultAfterDestroy = "{\"jsonrpc\":\"2.0\",\"error\":{\"message\":\"Method not found\",\"code\":-32601,\"data\":\"The method does not exist / is not available.\"},\"id\":1}"; + string expectedResultAfterDestroy = "{\"jsonrpc\":\"2.0\",\"error\":{\"message\":\"Method not found\",\"code\":-32601,\"data\":{\"method\":\"workie\"}},\"id\":1}"; var result = JsonRpcProcessor.Process(sessionId, request); result.Wait(); @@ -1848,7 +1877,7 @@ public void TestPostProcessOnSession() [Test()] public void TestExtraParameters() { - string request = @"{method:'ReturnsDateTime',params:{extra:'mytext'},id:1}"; + string request = @"{""method"":""ReturnsDateTime"",""params"":{""extra"":""mytext""},""id"":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); Assert.IsTrue(result.Result.Contains("error")); @@ -1858,7 +1887,7 @@ public void TestExtraParameters() [Test()] public void TestExtraPositionalParameters() { - string request = @"{method:'ReturnsDateTime',params:[1,2,'mytext'],id:1}"; + string request = @"{""method"":""ReturnsDateTime"",""params"":[1,2,""mytext""],""id"":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); Assert.IsTrue(result.Result.Contains("error")); @@ -1868,7 +1897,7 @@ public void TestExtraPositionalParameters() [Test()] public void TestCustomParameterName() { - Func request = (string paramName) => String.Format("{{method:'TestCustomParameterName',params:{{ {0}:'some string'}},id:1}}", paramName); + Func request = (string paramName) => String.Format("{{\"method\":\"TestCustomParameterName\",\"params\":{{ \"{0}\":\"some string\"}},\"id\":1}}", paramName); string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":true,\"id\":1}"; // Check custom param name specified in attribute works var result = JsonRpcProcessor.Process(request("myCustomParameter")); @@ -1883,7 +1912,7 @@ public void TestCustomParameterName() [Test()] public void TestCustomParameterWithNoSpecificName() { - Func request = (string paramName) => String.Format("{{method:'TestCustomParameterWithNoSpecificName',params:{{ {0}:'some string'}},id:1}}", paramName); + Func request = (string paramName) => String.Format("{{\"method\":\"TestCustomParameterWithNoSpecificName\",\"params\":{{ \"{0}\":\"some string\"}},\"id\":1}}", paramName); string expectedResult = "{\"jsonrpc\":\"2.0\",\"result\":true,\"id\":1}"; // Check method can be used with its parameter name var result = JsonRpcProcessor.Process(request("arg")); @@ -1904,17 +1933,18 @@ public void TestNestedReturnType() [Test()] public void TestWrongParamType() { - string request = @"{method:'TestOptionalParamdouble',params:{input:'mytext'},id:1}"; + string request = @"{""method"":""TestOptionalParamdouble"",""params"":{""input"":""mytext""},""id"":1}"; var result = JsonRpcProcessor.Process(request); result.Wait(); Assert.IsTrue(result.Result.Contains("error")); - Assert.IsTrue(result.Result.Contains("\"code\":-32603")); + Assert.IsTrue(result.Result.Contains("\"code\":-32602"), result.Result); + Assert.IsTrue(result.Result.Contains("\"parameter\":\"input\""), result.Result); } [Test()] public void TestWrongIdType() { - string request = @"{method:'TestOptionalParamdouble',params:{input:5},id:{what:4,that:3}}"; + string request = @"{""method"":""TestOptionalParamdouble"",""params"":{""input"":5},""id"":{""what"":4,""that"":3}}"; var result = JsonRpcProcessor.Process(request); result.Wait(); Assert.IsTrue(result.Result.Contains("error")); diff --git a/AustinHarris.JsonRpcTestN/VersionPolicyTests.cs b/AustinHarris.JsonRpcTestN/VersionPolicyTests.cs new file mode 100644 index 0000000..70bd21c --- /dev/null +++ b/AustinHarris.JsonRpcTestN/VersionPolicyTests.cs @@ -0,0 +1,138 @@ +using System; +using AustinHarris.JsonRpc; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// Config.VersionPolicy / Config.SetVersionPolicy: how the "jsonrpc" member of a request is checked. + /// Lenient (default): absent is fine, present must be "2.0". Ignore: never looked at. Strict: must be "2.0". + /// + [TestFixture] + [NonParallelizable] + public class VersionPolicyTests + { + private const string Session = "version-policy"; + private static readonly string[] Serializers = { "jsmn", "newtonsoft", "stj" }; + + private class PingService + { + [JsonRpcMethod("ping")] + public int Ping() => 7; + } + + [OneTimeSetUp] + public void Bind() => ServiceBinder.BindService(Session, new PingService()); + + [OneTimeTearDown] + public void Destroy() => Handler.DestroySession(Session); + + [TearDown] + public void ResetPolicies() + { + Config.VersionPolicy = JsonRpcVersionPolicy.Lenient; + Config.SetVersionPolicy(Session, null); + } + + private static string Run(string name, string json) => JsonRpcProcessor.ProcessSync(Session, json, null, SerializerCatalog.Create(name)); + + private const string Ok = "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}"; + + private static void AssertInvalidRequest(string response, string detail, object id = null) + { + var o = JObject.Parse(response); + Assert.AreEqual(-32600, (int)o["error"]["code"], response); + StringAssert.Contains(detail, response); + if (id == null) Assert.AreEqual(JTokenType.Null, o["id"].Type, response); + else Assert.AreEqual(id, o["id"].ToObject(id.GetType()), response); + } + + [Test] + public void DefaultIsLenient() + { + Assert.AreEqual(JsonRpcVersionPolicy.Lenient, Config.VersionPolicy); + Assert.IsNull(Handler.GetSessionHandler(Session).VersionPolicy); + } + + [TestCaseSource(nameof(Serializers))] + public void Lenient_AcceptsMissingAndExact(string s) + { + Assert.AreEqual(Ok, Run(s, "{\"method\":\"ping\",\"id\":1}")); + Assert.AreEqual(Ok, Run(s, "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}")); + Assert.AreEqual(Ok, Run(s, "{\"JSONRPC\":\"2.0\",\"method\":\"ping\",\"id\":1}"), "member names match case-insensitively like the others"); + } + + [TestCaseSource(nameof(Serializers))] + public void Lenient_RejectsOtherVersions(string s) + { + AssertInvalidRequest(Run(s, "{\"jsonrpc\":\"1.0\",\"method\":\"ping\",\"id\":1}"), "must be \\\"2.0\\\"", 1L); + AssertInvalidRequest(Run(s, "{\"jsonrpc\":2.0,\"method\":\"ping\",\"id\":\"a\"}"), "must be \\\"2.0\\\"", "a"); + AssertInvalidRequest(Run(s, "{\"jsonrpc\":null,\"method\":\"ping\",\"id\":1}"), "must be \\\"2.0\\\"", 1L); + AssertInvalidRequest(Run(s, "{\"jsonrpc\":\"2.00\",\"method\":\"ping\",\"id\":1}"), "must be \\\"2.0\\\"", 1L); + // an invalid request object is answered even without an id (it is not a valid notification) + AssertInvalidRequest(Run(s, "{\"jsonrpc\":\"1.0\",\"method\":\"ping\"}"), "must be \\\"2.0\\\""); + } + + [TestCaseSource(nameof(Serializers))] + public void Lenient_ChecksVersionBeforeMethod(string s) + { + AssertInvalidRequest(Run(s, "{\"jsonrpc\":\"1.0\",\"id\":1}"), "must be \\\"2.0\\\"", 1L); + AssertInvalidRequest(Run(s, "{\"id\":1}"), "Missing property 'method'", 1L); + } + + [TestCaseSource(nameof(Serializers))] + public void Ignore_AcceptsAnything(string s) + { + Config.VersionPolicy = JsonRpcVersionPolicy.Ignore; + Assert.AreEqual(Ok, Run(s, "{\"method\":\"ping\",\"id\":1}")); + Assert.AreEqual(Ok, Run(s, "{\"jsonrpc\":\"1.0\",\"method\":\"ping\",\"id\":1}")); + Assert.AreEqual(Ok, Run(s, "{\"jsonrpc\":2,\"method\":\"ping\",\"id\":1}")); + Assert.AreEqual(Ok, Run(s, "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}")); + } + + [TestCaseSource(nameof(Serializers))] + public void Strict_RequiresTheMember(string s) + { + Config.VersionPolicy = JsonRpcVersionPolicy.Strict; + Assert.AreEqual(Ok, Run(s, "{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1}")); + AssertInvalidRequest(Run(s, "{\"method\":\"ping\",\"id\":1}"), "Missing property 'jsonrpc'", 1L); + AssertInvalidRequest(Run(s, "{\"jsonrpc\":\"1.0\",\"method\":\"ping\",\"id\":1}"), "must be \\\"2.0\\\"", 1L); + } + + [TestCaseSource(nameof(Serializers))] + public void SessionOverridesGlobal(string s) + { + const string missing = "{\"method\":\"ping\",\"id\":1}"; + Config.VersionPolicy = JsonRpcVersionPolicy.Strict; + Config.SetVersionPolicy(Session, JsonRpcVersionPolicy.Lenient); + Assert.AreEqual(Ok, Run(s, missing), "the session's Lenient wins over the global Strict"); + + Config.VersionPolicy = JsonRpcVersionPolicy.Lenient; + Config.SetVersionPolicy(Session, JsonRpcVersionPolicy.Strict); + AssertInvalidRequest(Run(s, missing), "Missing property 'jsonrpc'", 1L); + + Config.SetVersionPolicy(Session, null); + Assert.AreEqual(Ok, Run(s, missing), "null makes the session follow the global policy again"); + } + + [TestCaseSource(nameof(Serializers))] + public void Batch_JudgesEachRequest(string s) + { + var r = JArray.Parse(Run(s, "[{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":1},{\"jsonrpc\":\"1.0\",\"method\":\"ping\",\"id\":2},{\"method\":\"ping\",\"id\":3}]")); + Assert.AreEqual(3, r.Count); + Assert.AreEqual(7, (int)r[0]["result"]); + Assert.AreEqual(-32600, (int)r[1]["error"]["code"]); + Assert.AreEqual(2, (int)r[1]["id"]); + Assert.AreEqual(7, (int)r[2]["result"]); + } + + [TestCaseSource(nameof(Serializers))] + public void EscapedMemberNameIsStillTheVersion(string s) + { + AssertInvalidRequest(Run(s, "{\"json\\u0072pc\":\"1.0\",\"method\":\"ping\",\"id\":1}"), "must be \\\"2.0\\\"", 1L); + // an escaped value is decoded before the comparison + Assert.AreEqual(Ok, Run(s, "{\"jsonrpc\":\"2\\u002e0\",\"method\":\"ping\",\"id\":1}")); + } + } +} diff --git a/Json-Rpc/Attributes.cs b/Json-Rpc/Attributes.cs index 1592511..5ebe04f 100644 --- a/Json-Rpc/Attributes.cs +++ b/Json-Rpc/Attributes.cs @@ -19,12 +19,28 @@ public JsonRpcMethodAttribute(string jsonMethodName = "") this.jsonMethodName = jsonMethodName; } + /// Whether the invocation context flows across awaits. Defaults to Flow. + public RpcContextFlow ContextFlow { get; set; } = RpcContextFlow.None; + public string JsonMethodName { get { return jsonMethodName; } } } + /// Controls ambient context propagation for asynchronous methods. The default is . + public enum RpcContextFlow + { + /// Only the initial synchronous part of the method has ambient context; capture snapshots before awaiting. Allocation-free when the operation completes inline. + None, + /// Context, request id and authored exceptions flow across sequential awaits, at a per-invocation allocation. + Flow + } + + /// Injects the processor cancellation token instead of binding a JSON parameter. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)] + public sealed class JsonRpcCancellationAttribute : Attribute { } + /// /// Used to assign JsonRpc parameter name to method argument. /// diff --git a/Json-Rpc/AustinHarris.JsonRpc.csproj b/Json-Rpc/AustinHarris.JsonRpc.csproj index f6a680d..8c92c49 100644 --- a/Json-Rpc/AustinHarris.JsonRpc.csproj +++ b/Json-Rpc/AustinHarris.JsonRpc.csproj @@ -1,27 +1,63 @@ - + Austin Harris + Austin Harris Json-Rpc.Net Core - Core functionality for JsonRpc.Net - 1.2.3 + JSON-RPC.Net is a high performance JSON-RPC 2.0 server for .NET Standard 2.0+ and modern .NET. Bytes in, bytes out, no dependencies; plug in Json.NET or System.Text.Json with the companion packages. Host it in ASP.NET Core / Kestrel, a console app, sockets, pipes - anything that can hand you UTF-8 or a string. + 2.0.0 $(VersionSuffix) Austin Harris https://github.com/Astn/JSON-RPC.NET - https://raw.githubusercontent.com/Astn/JSON-RPC.NET/master/LICENSE + https://github.com/Astn/JSON-RPC.NET + git + MIT + README.md + json-rpc;jsonrpc;json;rpc;server;netstandard;kestrel;pipelines;system.text.json;json.net;fast - Improves support for optional parameters - @HoMS1987 https://github.com/HoMS1987 - Fixes protocol validation of the ID property - @pedrolcl https://github.com/pedrolcl - DotNet Core support - @astn https://github.com/astn + 2.0.0 + - ServiceBinder.BindInterface registers interface trees atomically, with contract naming, filtering, defaults and ownership-aware disposal. + - ProcessAsync awaits Task and ValueTask methods with typed result writing, sequential batches and cooperative cancellation. + - Ambient context does not flow across awaits by default (RpcContextFlow.None); RpcContextFlow.Flow opts a method in. JsonRpcCancellation injects the processor token. + - Kestrel EnableAsyncMethods enables asynchronous HTTP and ordered raw-connection processing. + - The core no longer depends on Json.NET. Serializers are pluggable (AustinHarris.JsonRpc.Serialization.JsonRpcSerializer); + the built-in jsmn serializer is the default, Json.NET and System.Text.Json ship as companion packages. + - Byte-first pipeline: ReadOnlySequence/ReadOnlyMemory in, IBufferWriter out (System.IO.Pipelines / Kestrel friendly); + string overloads remain. Parameters bind straight from the request bytes through compiled invokers. + - Breaking: JsonSerializerSettings overloads moved to the Json.NET package (pass a serializer instead); + JsonRequest/JsonResponse/JsonRpcException no longer carry Json.NET attributes; SMD type descriptors are plain dictionaries. + - Batch fixes: a trailing notification no longer leaves a dangling comma; an all-notification batch returns nothing. + - 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. + - A parameter value the serializer cannot convert is -32602 with structured data naming the parameter (it was -32603); + -32601 names the requested method in its data; async void methods are rejected at registration. + - ServiceBinder.BindMethod registers any delegate as a method without attributes or a service class. + + 1.3.0 + - Targets netstandard2.0, netstandard2.1, net8.0 and net10.0 (drops EOL netcoreapp3.1; netstandard2.0 still covers it) + - Newtonsoft.Json 13.0.4 (fixes GHSA-5crp-9r3c-p9vr in 12.0.3) + - Lock-free session handler registry via NonBlocking.ConcurrentDictionary + - Packaging moved fully to the SDK-style csproj: MIT license expression, README in package, repository metadata + - Closes out the .NET Standard work from PR #90 / issue #89 - @astn https://github.com/astn - netstandard2.0;netstandard2.1;netcoreapp3.1 + netstandard2.0;netstandard2.1;net8.0;net10.0 + latest true - + + + - + + + + + + + + + - \ No newline at end of file + diff --git a/Json-Rpc/AustinHarris.JsonRpc.nuspec b/Json-Rpc/AustinHarris.JsonRpc.nuspec deleted file mode 100644 index ad36f62..0000000 --- a/Json-Rpc/AustinHarris.JsonRpc.nuspec +++ /dev/null @@ -1,31 +0,0 @@ - - - - AustinHarris.JsonRpc - $version$ - JSON-RPC.NET - Austin Harris - - https://raw.githubusercontent.com/Astn/JSON-RPC.NET/master/LICENSE - https://github.com/Astn/JSON-RPC.NET - http://download-codeplex.sec.s-msft.com/Download?ProjectName=jsonrpc2&DownloadId=487107 - false - The fastest .Net JSON RPC Server - JSON-RPC.Net is a high performance Json-Rpc 2.0 server, leveraging the popular JSON.NET library. Easily create a JSON RPC server for your Angular javascript apps, also supports sockets and pipes, oh my! - Optional JsonSerializer Settings - @hovi. ProcessSync is now public - @astn - en-US - fast json rpc server socket javascript json-rpc.net json-rpc jsonrpc json.net web services webapi service angular server angularjs - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Json-Rpc/Basic.cs b/Json-Rpc/Basic.cs deleted file mode 100644 index 101819c..0000000 --- a/Json-Rpc/Basic.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace AustinHarris.JsonRpc -{ - - //public class MetadataService: JsonRpcService - //{ - // #region Private Methods - // /// - // /// Returns all available methods. - // /// - // /// The query result. - // /// The result. - // /// - // [JsonRpcMethod("?")] - // private SMD Handle_AvaiableMethods() - // { - // return Handler.Current.MetaData; - // } - - // #endregion - //} -} diff --git a/Json-Rpc/Client/InProcessJsonRpcClient.cs b/Json-Rpc/Client/InProcessJsonRpcClient.cs index ba51d6c..5a7b637 100644 --- a/Json-Rpc/Client/InProcessJsonRpcClient.cs +++ b/Json-Rpc/Client/InProcessJsonRpcClient.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; using System.Threading.Tasks; namespace AustinHarris.JsonRpc.Client diff --git a/Json-Rpc/Config.cs b/Json-Rpc/Config.cs index 9e6692e..5797b57 100644 --- a/Json-Rpc/Config.cs +++ b/Json-Rpc/Config.cs @@ -1,7 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System; +using AustinHarris.JsonRpc.Serialization; namespace AustinHarris.JsonRpc { @@ -29,6 +27,64 @@ namespace AustinHarris.JsonRpc /// public static class Config { + private static volatile JsonRpcSerializer _serializer; + + /// + /// The serializer used when neither the call nor the session specifies one. Defaults to the built-in + /// dependency-free serializer (). Install the Json.NET or System.Text.Json + /// package and assign its serializer here to switch the whole process. + /// + public static JsonRpcSerializer Serializer + { + get { return _serializer ?? Jsmn.JsmnSerializer.Instance; } + set { _serializer = value; } + } + + private static volatile bool _includeExceptionDetails; + + /// + /// Whether an ordinary exception thrown by a method (reported as a -32603 error) carries its diagnostics + /// (Source, StackTraceString, HResult and the InnerException chain) to the client in error.data. + /// False (the default) sends the exception type name and message only. Exceptions authored by the + /// application ( and its data) are not affected. + /// + public static bool IncludeExceptionDetails + { + get { return _includeExceptionDetails; } + set { _includeExceptionDetails = value; } + } + + private static volatile JsonRpcVersionPolicy _versionPolicy = JsonRpcVersionPolicy.Lenient; + + /// + /// How the jsonrpc member of incoming requests is checked. + /// (the default) accepts a missing member and requires "2.0" when present; see the enum for the + /// other choices. A session can override it with . + /// + public static JsonRpcVersionPolicy VersionPolicy + { + get { return _versionPolicy; } + set { _versionPolicy = value; } + } + + /// Sets the version policy for one session; null makes the session follow . + public static void SetVersionPolicy(string sessionId, JsonRpcVersionPolicy? policy) + { + Handler.GetSessionHandler(sessionId).VersionPolicy = policy; + } + + /// Sets the process-wide default serializer (null restores the built-in one). + public static void SetSerializer(JsonRpcSerializer serializer) + { + _serializer = serializer; + } + + /// Sets the serializer for one session; null makes the session follow the global default. + public static void SetSerializer(string sessionId, JsonRpcSerializer serializer) + { + Handler.GetSessionHandler(sessionId).Serializer = serializer; + } + /// /// Sets the the PreProcessing Handler on the default session. /// @@ -71,7 +127,7 @@ public static void SetErrorHandler(Func /// For exceptions thrown after the routed method has been called. /// Allows you to specify an error handler that will be invoked prior to returning the JsonResponse to the client. - /// You are able to modify the error that is returned inside the provided handler. + /// You are able to modify the error that is returned inside the provided handler. /// /// /// @@ -94,7 +150,7 @@ public static void SetParseErrorHandler(Func /// For exceptions thrown during parsing and prior to a routed method being called. /// Allows you to specify an error handler that will be invoked prior to returning the JsonResponse to the client. - /// You are able to modify the error that is returned inside the provided handler. + /// You are able to modify the error that is returned inside the provided handler. /// /// /// diff --git a/Json-Rpc/Handler.Async.cs b/Json-Rpc/Handler.Async.cs new file mode 100644 index 0000000..983278a --- /dev/null +++ b/Json-Rpc/Handler.Async.cs @@ -0,0 +1,396 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc.Invocation; +using AustinHarris.JsonRpc.Jsmn; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc +{ + public sealed partial class Handler + { + [ThreadStatic] private static InvocationState __unflowedState; + + private static class AsyncAmbient + { + internal static readonly AsyncLocal Current = new AsyncLocal(Changed); + + private static void Changed(AsyncLocalValueChangedArgs change) + { + if (change.PreviousValue == null && change.CurrentValue != null) __unflowedState = __state; + __state = change.CurrentValue ?? __unflowedState; + if (change.CurrentValue == null) __unflowedState = null; + } + } + + // Only the async dispatcher uses this scope. The thread frame is restored before returning + // an incomplete operation; a flowing frame is owned by that operation until terminal cleanup. + private readonly struct AsyncScope : IDisposable + { + internal readonly InvocationState Frame; + internal readonly InvocationState FlowFrame; + private readonly InvocationState _parent; + private readonly InvocationState _thread; + private readonly object _context; + private readonly JsonRpcException _exception; + private readonly JsonRpcRequestReader _reader; + + internal AsyncScope(object context, JsonRpcRequestReader reader, bool flow) + { + _parent = AsyncAmbient.Current.Value; + _thread = __state; + if (flow) + { + Frame = FlowFrame = new InvocationState { Context = context, Reader = reader }; + _context = null; _exception = null; _reader = null; + AsyncAmbient.Current.Value = Frame; + } + else + { + // Suppress an enclosing flowing invocation before the method captures its context. + if (_parent != null) AsyncAmbient.Current.Value = null; + Frame = State; + FlowFrame = null; + _context = Frame.Context; _exception = Frame.Exception; _reader = Frame.Reader; + Frame.Context = context; Frame.Exception = null; Frame.Reader = reader; + } + } + + public void Dispose() + { + if (FlowFrame == null) + { + Frame.Context = _context; Frame.Exception = _exception; Frame.Reader = _reader; + } + if (AsyncAmbient.Current.Value != _parent) AsyncAmbient.Current.Value = _parent; + // A None scope may have created the reusable thread frame; keep it when there was no parent. + if (_thread != null || FlowFrame != null) __state = _thread; + } + } + + private static void ClearAsyncFrame(InvocationState frame) + { + if (frame == null) return; + frame.Context = null; + frame.Reader = null; + frame.Exception = null; + } + + internal ValueTask HandleRequestAsync(JsonRpcRequestReader reader, int index, JsonRpcSerializer serializer, + PooledByteBufferWriter output, object context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + int envelopeStart = output.WrittenCount; + if (!reader.Select(index)) + { + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "Request must be an object.")), default); + return new ValueTask(true); + } + + var idKind = reader.IdKind; + if (idKind == JsonRpcIdKind.Invalid) + { + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "Id property must be either null or string or integer.")), default); + return new ValueTask(true); + } + var policy = VersionPolicy ?? Config.VersionPolicy; + if (policy != JsonRpcVersionPolicy.Ignore) + { + var version = reader.VersionKind; + if (version == JsonRpcVersionKind.Other) + { + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "The 'jsonrpc' member must be \"2.0\".")), reader.IdRaw); + return new ValueTask(true); + } + if (version == JsonRpcVersionKind.Absent && policy == JsonRpcVersionPolicy.Strict) + { + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "Missing property 'jsonrpc'")), reader.IdRaw); + return new ValueTask(true); + } + } + if (!reader.HasMethod) + { + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "Missing property 'method'")), reader.IdRaw); + return new ValueTask(true); + } + if (reader.ParamsKind == JsonRpcParamsKind.Invalid) + { + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "The 'params' member must be an array or an object.")), reader.IdRaw); + return new ValueTask(true); + } + + // From here on the request object is valid: without an id it is a notification and gets no response. + bool notification = idKind == JsonRpcIdKind.Absent; + + + if (externalPreProcessingHandler != null || externalPostProcessingHandler != null) + return HandleHookRequestAsync(reader, serializer, output, context, cancellationToken, notification, envelopeStart); + + var service = Resolve(reader); + if (service == null) + { + if (notification && externalErrorHandler == null) return new ValueTask(false); + var notFound = ProcessException(reader, MethodNotFound(reader.Method)); + if (notification) return new ValueTask(false); + WriteErrorEnvelope(output, serializer, notFound, reader.IdRaw); + return new ValueTask(true); + } + var method = service.Method; + var map = BindMap(reader, method, out var bindError); + if (bindError != null) + { + bindError = ProcessException(reader, bindError); + if (notification) return new ValueTask(false); + WriteErrorEnvelope(output, serializer, bindError, reader.IdRaw); + return new ValueTask(true); + } + + var scope = new AsyncScope(context, reader, method.IsAsync && method.ContextFlow == RpcContextFlow.Flow); + bool transferred = false; + try + { + cancellationToken.ThrowIfCancellationRequested(); + output.Write(ResultPrefix); + Task operation; + if (method.IsAsync || method.HasCancellation) + operation = reader is JsmnRequestReader jsmn && jsmn.IsBuiltIn + ? method.InvokeJsmnAsync(jsmn, map, output, cancellationToken) + : method.InvokeAsync(reader, map, serializer, output, cancellationToken); + else + { + if (reader is JsmnRequestReader syncJsmn && syncJsmn.IsBuiltIn) method.InvokeJsmn(syncJsmn, map, output); + else method.Invoke(reader, map, serializer, output); + operation = Task.CompletedTask; + } + if (!operation.IsCompleted) + { + var pending = AwaitStreaming(operation, reader, method, map, serializer, output, scope.FlowFrame, + scope.Frame.Exception, notification, envelopeStart); + transferred = true; + return pending; + } + operation.GetAwaiter().GetResult(); + return new ValueTask(FinishStreaming(reader, serializer, output, scope.Frame.Exception, notification, envelopeStart)); + } + catch (Exception ex) + { + return new ValueTask(StreamingFailure(reader, method, map, serializer, output, ex, notification, envelopeStart)); + } + finally + { + scope.Dispose(); + if (!transferred) { ClearAsyncFrame(scope.FlowFrame); ReturnMap(method, map); } + } + } + + private async ValueTask AwaitStreaming(Task operation, JsonRpcRequestReader reader, RpcMethod method, int[] map, + JsonRpcSerializer serializer, PooledByteBufferWriter output, InvocationState frame, JsonRpcException initialError, + bool notification, int envelopeStart) + { + try + { + await operation.ConfigureAwait(false); + return FinishStreaming(reader, serializer, output, frame != null ? frame.Exception : initialError, notification, envelopeStart); + } + catch (Exception ex) + { + return StreamingFailure(reader, method, map, serializer, output, ex, notification, envelopeStart); + } + finally + { + ClearAsyncFrame(frame); + ReturnMap(method, map); + } + } + + private bool FinishStreaming(JsonRpcRequestReader reader, JsonRpcSerializer serializer, PooledByteBufferWriter output, + JsonRpcException error, bool notification, int envelopeStart) + { + if (error != null) + { + output.Rewind(envelopeStart); + error = ProcessException(reader, error); + if (notification) return false; + WriteErrorEnvelope(output, serializer, error, reader.IdRaw); + } + else if (notification) + { + output.Rewind(envelopeStart); + return false; + } + else + { + output.Write(IdInfix); + WriteIdRaw(output, reader.IdRaw); + output.Write((byte)'}'); + } + return true; + } + + private bool StreamingFailure(JsonRpcRequestReader reader, RpcMethod method, int[] map, JsonRpcSerializer serializer, + PooledByteBufferWriter output, Exception ex, bool notification, int envelopeStart) + { + output.Rewind(envelopeStart); + ex = UnwrapAsyncException(ex); + var error = BindingFailure(reader, method, map, ex); + error = error != null ? ProcessException(reader, error) : MapAsyncException(reader, ex); + if (notification) return false; + WriteErrorEnvelope(output, serializer, error, reader.IdRaw); + return true; + } + + private static Exception UnwrapAsyncException(Exception ex) + { + while (true) + { + if (ex is TargetInvocationException tie && tie.InnerException != null) ex = tie.InnerException; + else if (ex is AggregateException aggregate && aggregate.InnerExceptions.Count == 1) ex = aggregate.InnerExceptions[0]; + else return ex; + } + } + + private JsonRpcException MapAsyncException(JsonRpcRequestReader reader, Exception ex) + { + ex = UnwrapAsyncException(ex); + return ex is AggregateException + ? ProcessException(reader, new JsonRpcException(-32603, "Internal Error", ex)) : MapException(reader, ex); + } + + private JsonRpcException MapAsyncException(JsonRequest request, Exception ex) + { + ex = UnwrapAsyncException(ex); + return ex is AggregateException + ? ProcessException(request, new JsonRpcException(-32603, "Internal Error", ex)) : MapException(request, ex); + } + + private async ValueTask HandleHookRequestAsync(JsonRpcRequestReader reader, JsonRpcSerializer serializer, + PooledByteBufferWriter output, object context, CancellationToken token, bool notification, int envelopeStart) + { + object originalId = reader.IdValue; + var response = await HandleBoxedAsync(reader, serializer, context, token).ConfigureAwait(false); + if (notification) return false; + if (Equals(response.Id, originalId)) WriteResponse(output, serializer, response, reader.IdRaw, envelopeStart); + else + { + using (var idBuffer = new PooledByteBufferWriter(64)) + { + WriteIdValue(idBuffer, serializer, response.Id); + WriteResponse(output, serializer, response, idBuffer.WrittenSpan, envelopeStart); + } + } + return true; + } + + private async ValueTask HandleBoxedAsync(JsonRpcRequestReader reader, JsonRpcSerializer serializer, object context, CancellationToken token) + { + // This async boundary restores its execution context when it returns to its caller, including + // when it suspends. The frame itself remains owned until all hooks have finished. + var scope = new AsyncScope(context, reader, true); + try + { + string method = reader.Method; + object id = reader.IdValue; + object parameters; + try { parameters = reader.ParamsValue; } + catch (Exception ex) + { + var failed = new JsonRequest(method, null, id); + return PostProcess(failed, new JsonResponse { Error = MapAsyncException(failed, ex), Id = id }, context); + } + var request = new JsonRequest(method, parameters, id); + JsonRpcException preError; + try { preError = PreProcess(request, context); } + catch (Exception ex) { preError = ProcessException(request, new JsonRpcException(-32603, "Internal Error", ex)); } + if (preError != null) return PostProcess(request, new JsonResponse { Error = preError, Id = request.Id }, context); + JsonResponse response; + if (request.Method == method && ReferenceEquals(request.Params, parameters) && Equals(request.Id, id)) + response = await InvokeBoxedAsync(reader, request, context, token, scope.Frame).ConfigureAwait(false); + else response = await InvokeModifiedAsync(request, serializer, context, token, scope.Frame).ConfigureAwait(false); + return PostProcess(request, response, context); + } + catch (Exception ex) + { + return new JsonResponse { Error = new JsonRpcException(-32603, "Internal Error", ex), Id = SafeIdValue(reader) }; + } + finally { scope.Dispose(); ClearAsyncFrame(scope.FlowFrame); } + } + + private async ValueTask InvokeModifiedAsync(JsonRequest request, JsonRpcSerializer serializer, object context, CancellationToken token, InvocationState hookFrame) + { + JsonRpcRequestReader reader = null; + using (var buffer = new PooledByteBufferWriter(512)) + { + try + { + serializer.Write(buffer, request, typeof(JsonRequest)); + reader = serializer.CreateReader(); + string message = null; + if (!reader.TryParse(buffer.WrittenMemory, out var error) || !reader.Select(0)) message = error ?? "Request must be an object."; + else if (!reader.HasMethod) message = "Missing property 'method'"; + else if (reader.IdKind == JsonRpcIdKind.Invalid) message = "Id property must be either null or string or integer."; + else if (reader.ParamsKind == JsonRpcParamsKind.Invalid) message = "The 'params' member must be an array or an object."; + if (message != null) return new JsonResponse { Error = ProcessException(request, new JsonRpcException(-32600, "Invalid Request", message)), Id = request.Id }; + return await InvokeBoxedAsync(reader, request, context, token, hookFrame).ConfigureAwait(false); + } + catch (Exception ex) { return new JsonResponse { Error = MapAsyncException(request, ex), Id = request.Id }; } + finally { reader?.Release(); } + } + } + + private ValueTask InvokeBoxedAsync(JsonRpcRequestReader reader, JsonRequest request, object context, CancellationToken token, InvocationState hookFrame) + { + var service = Resolve(reader); + if (service == null) return new ValueTask(new JsonResponse { Error = ProcessException(request, MethodNotFound(reader.Method)), Id = request.Id }); + var method = service.Method; + var map = BindMap(reader, method, out var error); + if (error != null) return new ValueTask(new JsonResponse { Error = ProcessException(request, error), Id = request.Id }); + var scope = new AsyncScope(context, reader, method.IsAsync && method.ContextFlow == RpcContextFlow.Flow); + scope.Frame.Exception = hookFrame.Exception; + hookFrame.Exception = null; + bool transferred = false; + try + { + token.ThrowIfCancellationRequested(); + var operation = method.IsAsync || method.HasCancellation ? method.InvokeBoxedAsync(reader, map, token) : new ValueTask(method.InvokeBoxed(reader, map)); + if (!operation.IsCompleted) + { + var pending = AwaitBoxedInvocation(operation, reader, request, method, map, scope.FlowFrame, scope.Frame.Exception); + transferred = true; + return pending; + } + var result = operation.GetAwaiter().GetResult(); + return new ValueTask(BoxedResponse(request, result, scope.Frame.Exception)); + } + catch (Exception ex) { return new ValueTask(BoxedFailure(reader, request, method, map, ex)); } + finally + { + scope.Dispose(); + if (!transferred) { ClearAsyncFrame(scope.FlowFrame); ReturnMap(method, map); } + } + } + + private async ValueTask AwaitBoxedInvocation(ValueTask operation, JsonRpcRequestReader reader, JsonRequest request, + RpcMethod method, int[] map, InvocationState frame, JsonRpcException initialError) + { + try + { + var result = await operation.ConfigureAwait(false); + return BoxedResponse(request, result, frame != null ? frame.Exception : initialError); + } + catch (Exception ex) { return BoxedFailure(reader, request, method, map, ex); } + finally { ClearAsyncFrame(frame); ReturnMap(method, map); } + } + + private JsonResponse BoxedResponse(JsonRequest request, object result, JsonRpcException error) => error != null + ? new JsonResponse { Error = ProcessException(request, error), Id = request.Id } + : new JsonResponse { Result = result, Id = request.Id }; + + private JsonResponse BoxedFailure(JsonRpcRequestReader reader, JsonRequest request, RpcMethod method, int[] map, Exception ex) + { + ex = UnwrapAsyncException(ex); + var error = BindingFailure(reader, method, map, ex); + return new JsonResponse { Error = error != null ? ProcessException(request, error) : MapAsyncException(request, ex), Id = request.Id }; + } + } +} diff --git a/Json-Rpc/Handler.cs b/Json-Rpc/Handler.cs index 095bacb..d2aa608 100644 --- a/Json-Rpc/Handler.cs +++ b/Json-Rpc/Handler.cs @@ -1,37 +1,38 @@ -namespace AustinHarris.JsonRpc +namespace AustinHarris.JsonRpc { using System; - using System.Collections; + using System.Buffers; using System.Collections.Generic; - using System.Linq; using System.Reflection; - using Newtonsoft.Json; - using System.Threading.Tasks; - using System.Collections.Concurrent; - using Newtonsoft.Json.Linq; using System.Threading; + using AustinHarris.JsonRpc.Invocation; + using AustinHarris.JsonRpc.Jsmn; + using AustinHarris.JsonRpc.Serialization; + using NonBlocking; - public class Handler + public sealed partial class Handler { #region Members - private const string Name_of_JSONRPCEXCEPTION = "JsonRpcException&"; private static int _sessionHandlerMasterVersion = 1; [ThreadStatic] private static Dictionary _sessionHandlersLocal; [ThreadStatic] private static int _sessionHandlerLocalVersion = 0; - private static ConcurrentDictionary _sessionHandlersMaster; - - private static volatile string _defaultSessionId; + // The last hit on this thread: a transport hands the same session-id string instance to every request, so + // one reference comparison replaces hashing a GUID-length string. Dropped with the local snapshot. + [ThreadStatic] + private static string _lastSessionId; + [ThreadStatic] + private static Handler _lastSessionHandler; + private static readonly ConcurrentDictionary _sessionHandlersMaster = new ConcurrentDictionary(); + + private static readonly string _defaultSessionId = Guid.NewGuid().ToString(); #endregion #region Constructors static Handler() { - //current = new Handler(Guid.NewGuid().ToString()); - _defaultSessionId = Guid.NewGuid().ToString(); - _sessionHandlersMaster = new ConcurrentDictionary(); _sessionHandlersMaster[_defaultSessionId] = new Handler(_defaultSessionId); } @@ -48,33 +49,38 @@ private Handler(string sessionId) /// /// Returns the SessionID of the default session /// - /// public static string DefaultSessionId() { return _defaultSessionId; } /// /// Gets a specific session /// /// The sessionId of the handler you want to retrieve. - /// public static Handler GetSessionHandler(string sessionId) { if (_sessionHandlerMasterVersion != _sessionHandlerLocalVersion) { _sessionHandlersLocal = new Dictionary(_sessionHandlersMaster); _sessionHandlerLocalVersion = _sessionHandlerMasterVersion; + _lastSessionId = null; + _lastSessionHandler = null; + } + else if (ReferenceEquals(sessionId, _lastSessionId)) + { + return _lastSessionHandler; } - if (_sessionHandlersLocal.ContainsKey(sessionId)) + if (_sessionHandlersLocal.TryGetValue(sessionId, out var local)) { - return _sessionHandlersLocal[sessionId]; + _lastSessionId = sessionId; + _lastSessionHandler = local; + return local; } Interlocked.Increment(ref _sessionHandlerMasterVersion); - return _sessionHandlersMaster.GetOrAdd(sessionId, new Handler(sessionId)); + return _sessionHandlersMaster.GetOrAdd(sessionId, id => new Handler(id)); } /// /// gets the default session /// - /// The default Session Handler public static Handler GetSessionHandler() { return GetSessionHandler(_defaultSessionId); @@ -83,13 +89,12 @@ public static Handler GetSessionHandler() /// /// Removes and clears the Handler with the specific sessionID from the registry of Handlers /// - /// public static void DestroySession(string sessionId) { Handler h; _sessionHandlersMaster.TryRemove(sessionId, out h); Interlocked.Increment(ref _sessionHandlerMasterVersion); - h.MetaData.Services.Clear(); + h?.MetaData.Clear(); } /// /// Removes and clears the current Handler from the registry of Handlers @@ -109,42 +114,119 @@ public void Destroy() /// public string SessionId { get; private set; } + /// + /// The serializer for this session. Null (the default) means . + /// A serializer passed to a JsonRpcProcessor call overrides both. + /// + public JsonRpcSerializer Serializer { get; set; } + + /// + /// The jsonrpc member policy for this session. Null (the default) means . + /// + public JsonRpcVersionPolicy? VersionPolicy { get; set; } + /// /// Provides access to a context specific to each JsonRpc method invocation. /// Warning: Must be called from within the execution context of the jsonRpc Method to return the context /// - /// public static object RpcContext() { - return __currentRpcContext; + return __state?.Context; + } + + /// + /// The raw JSON of the id of the request being served (12, "abc", null), sliced from the + /// request bytes: nothing is decoded or allocated. Empty for a notification and outside an invocation. + /// The span is a borrow of a pooled buffer: use it before returning from the method and never store it; + /// returns a snapshot that can be kept. Like this reads + /// the per-thread frame, so it is empty on any other thread the method starts work on. + /// + public static ReadOnlySpan RpcRequestIdRaw() + { + var s = __state; + if (s == null) return default; + var reader = s.Reader; + if (reader == null) return default; + return reader.IdRaw; + } + + /// The kind of the id of the request being served; for a notification and outside an invocation. + public static JsonRpcIdKind RpcRequestIdKind() + { + var s = __state; + if (s == null) return JsonRpcIdKind.Absent; + var reader = s.Reader; + if (reader == null) return JsonRpcIdKind.Absent; + return reader.IdKind; + } + + /// + /// An owned snapshot of the id of the request being served (see ). An integer + /// id costs nothing beyond the parse; a string id allocates its decoded string. Absent for a notification + /// and outside an invocation. Must be called on the thread that runs the method; capture it before handing + /// work to another thread. + /// + public static JsonRpcRequestId RpcRequestId() + { + var s = __state; + if (s == null) return default; + var reader = s.Reader; + if (reader == null) return default; + return JsonRpcRequestId.FromRaw(reader.IdRaw, reader.IdKind); + } + + /// + /// The per-thread invocation frame: the context of the method currently executing, the exception it + /// set through , and the reader positioned on the request it serves (the + /// source of the request id, read on demand). Every dispatch saves the frame on entry and restores it on + /// exit (finally), so a method that synchronously processes another request keeps its own context, error + /// state and id. + /// + private sealed class InvocationState + { + public object Context; + public JsonRpcException Exception; + public JsonRpcRequestReader Reader; } [ThreadStatic] - static JsonRpcException __currentRpcException; + private static InvocationState __state; + + private static InvocationState State + { + get + { + var s = __state; + if (s == null) __state = s = new InvocationState(); + return s; + } + } + /// /// Allows you to set the exception used in in the JsonRpc response. /// Warning: Must be called from the same thread as the jsonRpc method. /// - /// public static void RpcSetException(JsonRpcException exception) { - __currentRpcException = exception; + State.Exception = exception; } public static JsonRpcException RpcGetAndRemoveRpcException() { - var ex = __currentRpcException; - __currentRpcException = null ; + var s = __state; + if (s == null) return null; + var ex = s.Exception; + s.Exception = null; return ex; } - private AustinHarris.JsonRpc.PreProcessHandler externalPreProcessingHandler; - private AustinHarris.JsonRpc.PostProcessHandler externalPostProcessingHandler; + private PreProcessHandler externalPreProcessingHandler; + private PostProcessHandler externalPostProcessingHandler; private Func externalErrorHandler; private Func parseErrorHandler; #endregion /// - /// This metadata contains all the types and mappings of all the methods in this handler. Warning: Modifying this directly could cause your handler to no longer function. + /// This metadata contains all the types and mappings of all the methods in this handler. Warning: Modifying this directly could cause your handler to no longer function. /// public SMD MetaData { get; set; } @@ -153,8 +235,6 @@ public static JsonRpcException RpcGetAndRemoveRpcException() /// /// Allows you to register all the functions on a Pojo Type that have been attributed as [JsonRpcMethod] to the specified sessionId /// - /// The session to register against - /// The instance containing JsonRpcMethods to register public static void RegisterInstance(string sessionID, object instance) { ServiceBinder.BindService(sessionID, instance); @@ -165,329 +245,700 @@ public static void RegisterInstance(string sessionID, object instance) /// Requires you to specify all types and defaults /// /// The method name that will map to the registered function - /// The parameter names and types that will be positionally bound to the function + /// The parameter names and types that will be positionally bound to the function; the last entry is the return type /// Optional default values for parameters /// A reference to the Function public void RegisterFuction(string methodName, Dictionary parameterNameTypeMapping, Dictionary parameterNameDefaultValueMapping, Delegate implementation) { - MetaData.AddService(methodName, parameterNameTypeMapping, parameterNameDefaultValueMapping, implementation); + MetaData.AddService(methodName, parameterNameTypeMapping, parameterNameDefaultValueMapping ?? new Dictionary(), implementation); } public void UnRegisterFunction(string methodName) { - MetaData.Services.Remove(methodName); + MetaData.RemoveService(methodName); } - public void SetPreProcessHandler(AustinHarris.JsonRpc.PreProcessHandler handler) + public void SetPreProcessHandler(PreProcessHandler handler) { externalPreProcessingHandler = handler; } - public void SetPostProcessHandler(AustinHarris.JsonRpc.PostProcessHandler handler) + public void SetPostProcessHandler(PostProcessHandler handler) { externalPostProcessingHandler = handler; } /// - /// Invokes a method to handle a JsonRpc request. + /// Invokes a method to handle an already-materialised JsonRpc request (the compatibility path; the + /// processor binds parameters directly from bytes instead). /// /// JsonRpc Request to be processed /// Optional context that will be available from within the jsonRpcMethod. - /// public JsonResponse Handle(JsonRequest Rpc, Object RpcContext = null) { - AddRpcContext(RpcContext); - - var preProcessingException = PreProcess(Rpc, RpcContext); - if (preProcessingException != null) + var serializer = Serializer ?? Config.Serializer; + using (var buffer = new PooledByteBufferWriter(512)) { - JsonResponse response = new JsonResponse() + serializer.Write(buffer, Rpc, typeof(JsonRequest)); + var reader = serializer.CreateReader(); + try { - Error = preProcessingException, - Id = Rpc.Id - }; - //callback is called - if it is empty then nothing will be done - //return response always- if callback is empty or not - return PostProcess(Rpc, response, RpcContext); - } - - SMDService metadata = null; - Delegate handle = null; - if (this.MetaData.Services.TryGetValue(Rpc.Method, out metadata)) - { - handle = metadata.dele; - } else if (metadata == null) - { - JsonResponse response = new JsonResponse() + if (!reader.TryParse(buffer.WrittenMemory, out var error) || !reader.Select(0)) + { + return new JsonResponse { Error = new JsonRpcException(-32600, "Invalid Request", error), Id = Rpc.Id }; + } + if (!reader.HasMethod) + { + return new JsonResponse { Error = new JsonRpcException(-32600, "Invalid Request", "Missing property 'method'"), Id = Rpc.Id }; + } + var response = HandleBoxed(reader, serializer, RpcContext); + response.Id = Rpc.Id; + return response; + } + finally { - Result = null, - Error = new JsonRpcException(-32601, "Method not found", "The method does not exist / is not available."), - Id = Rpc.Id - }; - return PostProcess(Rpc, response, RpcContext); + reader.Release(); + } } + } + #endregion - object[] parameters = null; - bool expectsRefException = false; - var metaDataParamCount = metadata.parameters.Count(x => x != null); + #region Request pipeline - - var loopCt = 0; - var getCount = Rpc.Params as ICollection; - if (getCount != null) + private static readonly byte[] ResultPrefix = System.Text.Encoding.ASCII.GetBytes("{\"jsonrpc\":\"2.0\",\"result\":"); + private static readonly byte[] ErrorPrefix = System.Text.Encoding.ASCII.GetBytes("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":"); + private static readonly byte[] MessageInfix = System.Text.Encoding.ASCII.GetBytes(",\"message\":"); + private static readonly byte[] DataInfix = System.Text.Encoding.ASCII.GetBytes(",\"data\":"); + private static readonly byte[] IdInfix = System.Text.Encoding.ASCII.GetBytes(",\"id\":"); + private static readonly byte[] ErrorIdInfix = System.Text.Encoding.ASCII.GetBytes("},\"id\":"); + + /// + /// Handles request of the parsed document, writing the response (if any) to + /// . Returns false when nothing was written: a notification (a request without an + /// id) never gets a wire response, whatever its outcome; the error handlers still run for it server-side. + /// + internal bool HandleRequest(JsonRpcRequestReader reader, int index, JsonRpcSerializer serializer, PooledByteBufferWriter output, object context) + { + int envelopeStart = output.WrittenCount; + + if (!reader.Select(index)) { - loopCt = getCount.Count; + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "Request must be an object.")), default); + return true; } - var paramCount = loopCt; - if (paramCount == metaDataParamCount - 1 && metadata.parameters[metaDataParamCount - 1].ObjectType.Name.Equals(Name_of_JSONRPCEXCEPTION)) + var idKind = reader.IdKind; + if (idKind == JsonRpcIdKind.Invalid) { - paramCount++; - expectsRefException = true; + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "Id property must be either null or string or integer.")), default); + return true; } - parameters = new object[paramCount]; - - if (Rpc.Params is Newtonsoft.Json.Linq.JArray) + var idRaw = reader.IdRaw; + var policy = VersionPolicy ?? Config.VersionPolicy; + if (policy != JsonRpcVersionPolicy.Ignore) { - var jarr = ((Newtonsoft.Json.Linq.JArray)Rpc.Params); - for (int i = 0; i < loopCt && i < metadata.parameters.Length; i++) + var version = reader.VersionKind; + if (version == JsonRpcVersionKind.Other) { - parameters[i] = CleanUpParameter(jarr[i], metadata.parameters[i]); + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "The 'jsonrpc' member must be \"2.0\".")), idRaw); + return true; + } + if (version == JsonRpcVersionKind.Absent && policy == JsonRpcVersionPolicy.Strict) + { + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "Missing property 'jsonrpc'")), idRaw); + return true; } } - else if (Rpc.Params is Newtonsoft.Json.Linq.JObject) + if (!reader.HasMethod) { - var asDict = Rpc.Params as IDictionary; - for (int i = 0; i < loopCt && i < metadata.parameters.Length; i++) + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "Missing property 'method'")), idRaw); + return true; + } + if (reader.ParamsKind == JsonRpcParamsKind.Invalid) + { + WriteErrorEnvelope(output, serializer, ProcessParseException(reader, new JsonRpcException(-32600, "Invalid Request", "The 'params' member must be an array or an object.")), idRaw); + return true; + } + + // From here on the request object is valid: without an id it is a notification and gets no response. + bool notification = idKind == JsonRpcIdKind.Absent; + + if (externalPreProcessingHandler != null || externalPostProcessingHandler != null) + { + object originalId = reader.IdValue; + var response = HandleBoxed(reader, serializer, context); + if (notification) return false; + if (Equals(response.Id, originalId)) { - if (asDict.ContainsKey(metadata.parameters[i].Name) == true) - { - parameters[i] = CleanUpParameter(asDict[metadata.parameters[i].Name], metadata.parameters[i]); - continue; - } - else + WriteResponse(output, serializer, response, idRaw, envelopeStart); + } + else + { + // a handler replaced the id: echo the new one + using (var idBuffer = new PooledByteBufferWriter(64)) { - var foundDefault = metadata.defaultValues - .FirstOrDefault(defaul => defaul.Name == metadata.parameters[i].Name); - if (foundDefault != null) - { - parameters[i] = foundDefault.Value; - continue; - } - - JsonResponse response = new JsonResponse() - { - Error = ProcessException(Rpc, - new JsonRpcException(-32602, - "Invalid params", - string.Format("Named parameter '{0}' was not present.", - metadata.parameters[i].Name) - )), - Id = Rpc.Id - }; - return PostProcess(Rpc, response, RpcContext); + WriteIdValue(idBuffer, serializer, response.Id); + WriteResponse(output, serializer, response, idBuffer.WrittenSpan, envelopeStart); } } + return true; } - // Optional Parameter support - // check if we still miss parameters compared to metadata which may include optional parameters. - // if the rpc-call didn't supply a value for an optional parameter, we should be assinging the default value of it. - if (parameters.Length < metaDataParamCount && metadata.defaultValues.Length > 0) // rpc call didn't set values for all optional parameters, so we need to assign the default values for them. + var service = Resolve(reader); + if (service == null) { - var suppliedParamsCount = parameters.Length; // the index we should start storing default values of optional parameters. - var missingParamsCount = metaDataParamCount - parameters.Length; // the amount of optional parameters without a value set by rpc-call. - Array.Resize(ref parameters, parameters.Length + missingParamsCount); // resize the array to include all optional parameters. + if (notification && externalErrorHandler == null) return false; + var notFound = ProcessException(reader, MethodNotFound(reader.Method)); + if (notification) return false; + WriteErrorEnvelope(output, serializer, notFound, idRaw); + return true; + } + var method = service.Method; + var map = BindMap(reader, method, out var bindError); + if (bindError != null) + { + bindError = ProcessException(reader, bindError); + if (notification) return false; + WriteErrorEnvelope(output, serializer, bindError, idRaw); + return true; + } - for (int paramIndex = parameters.Length - 1, defaultIndex = metadata.defaultValues.Length - 1; // fill missing parameters from the back - paramIndex >= suppliedParamsCount && defaultIndex >= 0; // to don't overwrite supplied ones. - paramIndex--, defaultIndex--) + var state = State; + var outerContext = state.Context; + var outerException = state.Exception; + var outerReader = state.Reader; + state.Context = context; + state.Exception = null; + state.Reader = reader; + try + { + output.Write(ResultPrefix); + try { - parameters[paramIndex] = metadata.defaultValues[defaultIndex].Value; + // The built-in serializer's requests take the invoker compiled against the concrete reader and + // writer: typed reads straight from the tokens and formatted writes into the pooled buffer, with + // no virtual, delegate or interface call in between. Everything else goes through the serializer. + if (reader is JsmnRequestReader jsmn && jsmn.IsBuiltIn) method.InvokeJsmn(jsmn, map, output); + else method.Invoke(reader, map, serializer, output); } - - if (missingParamsCount > metadata.defaultValues.Length) + catch (Exception ex) { - JsonResponse response = new JsonResponse - { - Error = ProcessException(Rpc, - new JsonRpcException(-32602, - "Invalid params", - string.Format( - "Number of default parameters {0} not sufficient to fill all missing parameters {1}", - metadata.defaultValues.Length, missingParamsCount) - )), - Id = Rpc.Id - }; - return PostProcess(Rpc, response, RpcContext); + output.Rewind(envelopeStart); + var error = BindingFailure(reader, method, map, ex); + error = error != null ? ProcessException(reader, error) : MapException(reader, ex); + if (notification) return false; + WriteErrorEnvelope(output, serializer, error, idRaw); + return true; + } + var contextException = state.Exception; + if (contextException != null) + { + output.Rewind(envelopeStart); + contextException = ProcessException(reader, contextException); + if (notification) return false; + WriteErrorEnvelope(output, serializer, contextException, idRaw); + return true; } + if (notification) + { + output.Rewind(envelopeStart); + return false; + } + output.Write(IdInfix); + WriteIdRaw(output, idRaw); + output.Write((byte)'}'); + return true; } + finally + { + state.Context = outerContext; + state.Exception = outerException; + state.Reader = outerReader; + ReturnMap(method, map); + } + } - if (parameters.Length != metaDataParamCount) + /// + /// The boxed path: materialises a JsonRequest/JsonResponse so pre/post handlers can see them. Everything + /// (materialisation, the hooks, binding and invocation) runs inside one error boundary: this never throws. + /// + internal JsonResponse HandleBoxed(JsonRpcRequestReader reader, JsonRpcSerializer serializer, object context) + { + var state = State; + var outerContext = state.Context; + var outerException = state.Exception; + var outerReader = state.Reader; + state.Context = context; + state.Exception = null; + state.Reader = reader; + try { - JsonResponse response = new JsonResponse() - { - Error = ProcessException(Rpc, - new JsonRpcException(-32602, - "Invalid params", - string.Format("Expecting {0} parameters, and received {1}", - metadata.parameters.Length, - parameters.Length) - )), - Id = Rpc.Id - }; - return PostProcess(Rpc, response, RpcContext); + return HandleBoxedCore(reader, serializer, context, state); } + catch (Exception ex) + { + // last resort: a failure the boundaries below could not attribute still becomes a JSON-RPC error + return new JsonResponse { Error = new JsonRpcException(-32603, "Internal Error", ex), Id = SafeIdValue(reader) }; + } + finally + { + state.Context = outerContext; + state.Exception = outerException; + state.Reader = outerReader; + } + } + private JsonResponse HandleBoxedCore(JsonRpcRequestReader reader, JsonRpcSerializer serializer, object context, InvocationState state) + { + string method = reader.Method; + object id = reader.IdValue; + object parameters; try { - var results = handle.DynamicInvoke(parameters); - - var last = parameters.LastOrDefault(); - var contextException = RpcGetAndRemoveRpcException(); - JsonResponse response = null; - if (contextException != null) + parameters = reader.ParamsValue; + } + catch (Exception ex) + { + // the serializer could not materialise params in its object model: report it without repeating the conversion + var failed = new JsonRequest(method, null, id); + return PostProcess(failed, new JsonResponse { Error = MapException(failed, ex), Id = id }, context); + } + + var request = new JsonRequest(method, parameters, id); + JsonRpcException preProcessingException; + try + { + preProcessingException = PreProcess(request, context); + } + catch (Exception ex) + { + preProcessingException = ProcessException(request, new JsonRpcException(-32603, "Internal Error", ex)); + } + if (preProcessingException != null) + { + return PostProcess(request, new JsonResponse { Error = preProcessingException, Id = request.Id }, context); + } + + // Dispatch what the pre-process handler returned. When it left the request alone this is the same + // reader the fast path uses; when it replaced Method, Params or Id, the request is round-tripped + // through the serializer and dispatched from the result (as 1.x dispatched from the request object). + if (request.Method == method && ReferenceEquals(request.Params, parameters) && Equals(request.Id, id)) + { + return PostProcess(request, InvokeBoxed(reader, request, state), context); + } + return PostProcess(request, InvokeModified(request, serializer, state), context); + } + + private JsonResponse InvokeModified(JsonRequest request, JsonRpcSerializer serializer, InvocationState state) + { + JsonRpcRequestReader reader = null; + try + { + using (var buffer = new PooledByteBufferWriter(512)) { - response = new JsonResponse() { Error = ProcessException(Rpc, contextException), Id = Rpc.Id }; + serializer.Write(buffer, request, typeof(JsonRequest)); + reader = serializer.CreateReader(); + if (!reader.TryParse(buffer.WrittenMemory, out var error) || !reader.Select(0)) + { + return new JsonResponse { Error = ProcessException(request, new JsonRpcException(-32600, "Invalid Request", error)), Id = request.Id }; + } + if (!reader.HasMethod) + { + return new JsonResponse { Error = ProcessException(request, new JsonRpcException(-32600, "Invalid Request", "Missing property 'method'")), Id = request.Id }; + } + if (reader.IdKind == JsonRpcIdKind.Invalid) + { + // the hook replaced the id with something JSON-RPC does not allow (a fraction, a bool, an object...) + return new JsonResponse { Error = ProcessException(request, new JsonRpcException(-32600, "Invalid Request", "Id property must be either null or string or integer.")), Id = request.Id }; + } + if (reader.ParamsKind == JsonRpcParamsKind.Invalid) + { + return new JsonResponse { Error = ProcessException(request, new JsonRpcException(-32600, "Invalid Request", "The 'params' member must be an array or an object.")), Id = request.Id }; + } + return InvokeBoxed(reader, request, state); } - else if (expectsRefException && last != null && last is JsonRpcException) + } + catch (Exception ex) + { + // the modified request could not be serialized or parsed + return new JsonResponse { Error = MapException(request, ex), Id = request.Id }; + } + finally + { + reader?.Release(); + } + } + + /// Resolves, binds and invokes from , returning the boxed response for . + private JsonResponse InvokeBoxed(JsonRpcRequestReader reader, JsonRequest request, InvocationState state) + { + var service = Resolve(reader); + if (service == null) + { + return new JsonResponse { Error = ProcessException(request, MethodNotFound(reader.Method)), Id = request.Id }; + } + + var method = service.Method; + var map = BindMap(reader, method, out var bindError); + if (bindError != null) + { + return new JsonResponse { Error = ProcessException(request, bindError), Id = request.Id }; + } + // the reader in hand is the effective request: the original one, or the re-parsed request a hook modified + var outerReader = state.Reader; + state.Reader = reader; + try + { + var result = method.InvokeBoxed(reader, map); + var contextException = state.Exception; + return contextException != null + ? new JsonResponse { Error = ProcessException(request, contextException), Id = request.Id } + : new JsonResponse { Result = result, Id = request.Id }; + } + catch (Exception ex) + { + var error = BindingFailure(reader, method, map, ex); + return new JsonResponse { Error = error != null ? ProcessException(request, error) : MapException(request, ex), Id = request.Id }; + } + finally + { + state.Exception = null; + state.Reader = outerReader; + ReturnMap(method, map); + } + } + + private static JsonRpcException MethodNotFound(string method) + { + return new JsonRpcException(-32601, "Method not found", new MethodNotFoundInfo(method)); + } + + private SMDService Resolve(JsonRpcRequestReader reader) + { + return MetaData.Find(reader.MethodUtf8) ?? MetaData.Find(reader.Method); + } + + /// + /// Computes reader-index → parameter mapping. Positional params fill from the front and defaults from + /// the back; named params match by exact name, every supplied name must match a parameter (an unknown + /// or repeated name is -32602) and defaults fill only the names that are absent. Returns the method's + /// identity map when nothing needs mapping. + /// + private static int[] BindMap(JsonRpcRequestReader reader, RpcMethod method, out JsonRpcException error) + { + error = null; + var parameters = method.Parameters; + int expected = parameters.Length; + int given = reader.ParamCount; + + if (reader.ParamsKind != JsonRpcParamsKind.Object) + { + if (given == expected) return method.IdentityMap; + if (given > expected) { - response = new JsonResponse() { Error = ProcessException(Rpc, last as JsonRpcException), Id = Rpc.Id }; + error = new JsonRpcException(-32602, "Invalid params", string.Format("Expecting {0} parameters, and received {1}", expected, given)); + return null; } - else + int missing = expected - given; + if (missing > method.DefaultCount) { - response = new JsonResponse() { Result = results }; + error = new JsonRpcException(-32602, "Invalid params", string.Format("Number of default parameters {0} not sufficient to fill all missing parameters {1}", method.DefaultCount, missing)); + return null; } - return PostProcess(Rpc, response, RpcContext); + var map = ArrayPool.Shared.Rent(expected); + for (int i = 0; i < expected; i++) map[i] = i < given ? i : -1; + return map; } - catch (Exception ex) + else { - JsonResponse response; - if (ex is TargetParameterCountException) + if (given == expected && method.HasUniqueNames) { - response = new JsonResponse() { Error = ProcessException(Rpc, new JsonRpcException(-32602, "Invalid params", ex)) }; - return PostProcess(Rpc, response, RpcContext); + // names supplied in declaration order: the common case for generated clients, and the identity map + int p = 0; + while (p < expected && reader.ParamNameUtf8(p).SequenceEqual(parameters[p].NameUtf8)) p++; + if (p == expected) return method.IdentityMap; } - - // We really dont care about the TargetInvocationException, just pass on the inner exception - if (ex is JsonRpcException) + var map = ArrayPool.Shared.Rent(Math.Max(1, expected)); + int matched = 0; + for (int p = 0; p < expected; p++) { - response = new JsonResponse() { Error = ProcessException(Rpc, ex as JsonRpcException) }; - return PostProcess(Rpc, response, RpcContext); + var name = parameters[p].NameUtf8; + int found = -1; + for (int j = 0; j < given; j++) + { + if (reader.ParamNameUtf8(j).SequenceEqual(name)) { found = j; break; } + } + if (found >= 0) matched++; + map[p] = found; } - if (ex.InnerException != null && ex.InnerException is JsonRpcException) + if (matched != given) { - response = new JsonResponse() { Error = ProcessException(Rpc, ex.InnerException as JsonRpcException) }; - return PostProcess(Rpc, response, RpcContext); + // a supplied member matched no parameter, or a name was supplied more than once + error = UnmatchedNamedParameter(reader, parameters, map, given); + ArrayPool.Shared.Return(map); + return null; } - else if (ex.InnerException != null) + for (int p = 0; p < expected; p++) { - response = new JsonResponse() { Error = ProcessException(Rpc, new JsonRpcException(-32603, "Internal Error", ex.InnerException)) }; - return PostProcess(Rpc, response, RpcContext); + if (map[p] < 0 && !parameters[p].HasDefault) + { + ArrayPool.Shared.Return(map); + error = new JsonRpcException(-32602, "Invalid params", string.Format("Named parameter '{0}' was not present.", parameters[p].Name)); + return null; + } } - - response = new JsonResponse() { Error = ProcessException(Rpc, new JsonRpcException(-32603, "Internal Error", ex)) }; - return PostProcess(Rpc, response, RpcContext); + return map; } - finally + } + + private static JsonRpcException UnmatchedNamedParameter(JsonRpcRequestReader reader, RpcParameter[] parameters, int[] map, int given) + { + for (int j = 0; j < given; j++) { - RemoveRpcContext(); + bool used = false; + for (int p = 0; p < parameters.Length; p++) + { + if (map[p] == j) { used = true; break; } + } + if (used) continue; + var name = reader.ParamNameUtf8(j); + string text = Utf8Json.ToStringUtf8(name); + for (int p = 0; p < parameters.Length; p++) + { + if (name.SequenceEqual(parameters[p].NameUtf8)) + { + return new JsonRpcException(-32602, "Invalid params", string.Format("Named parameter '{0}' was supplied more than once.", text)); + } + } + return new JsonRpcException(-32602, "Invalid params", string.Format("Unknown named parameter '{0}'.", text)); } + return new JsonRpcException(-32602, "Invalid params", string.Format("Expecting {0} parameters, and received {1}", parameters.Length, given)); } - #endregion - [ThreadStatic] - static object __currentRpcContext; - private void AddRpcContext(object RpcContext) + private static void ReturnMap(RpcMethod method, int[] map) { - __currentRpcContext = RpcContext; + if (map != null && !ReferenceEquals(map, method.IdentityMap)) ArrayPool.Shared.Return(map); } - private void RemoveRpcContext() + + // ---- response writing ---- + + internal static void WriteResponse(PooledByteBufferWriter output, JsonRpcSerializer serializer, JsonResponse response, ReadOnlySpan idRaw, int envelopeStart) { - __currentRpcContext = null; + if (response.Error != null) + { + WriteErrorEnvelope(output, serializer, response.Error, idRaw); + return; + } + output.Write(ResultPrefix); + try + { + if (response.Result == null) Utf8Json.WriteNull(output); + else serializer.Write(output, response.Result, response.Result.GetType()); + } + catch (Exception ex) + { + output.Rewind(envelopeStart); + WriteErrorEnvelope(output, serializer, new JsonRpcException(-32603, "Internal Error", ex), idRaw); + return; + } + output.Write(IdInfix); + WriteIdRaw(output, idRaw); + output.Write((byte)'}'); } - private JsonRpcException ProcessException(JsonRequest req, JsonRpcException ex) + internal static void WriteErrorEnvelope(PooledByteBufferWriter output, JsonRpcSerializer serializer, JsonRpcException error, ReadOnlySpan idRaw) { - if (externalErrorHandler != null) - return externalErrorHandler(req, ex); - return ex; + int start = output.WrittenCount; + output.Write(ErrorPrefix); + Utf8Json.WriteInt64(output, error.code); + output.Write(MessageInfix); + Utf8Json.WriteString(output, error.message); + output.Write(DataInfix); + try + { + WriteErrorData(output, serializer, error.data); + } + catch (Exception) + { + // the data object could not be serialized; fall back to its text + output.Rewind(start); + output.Write(ErrorPrefix); + Utf8Json.WriteInt64(output, error.code); + output.Write(MessageInfix); + Utf8Json.WriteString(output, error.message); + output.Write(DataInfix); + Utf8Json.WriteString(output, Convert.ToString(error.data)); + } + output.Write(ErrorIdInfix); + WriteIdRaw(output, idRaw); + output.Write((byte)'}'); } - internal JsonRpcException ProcessParseException(string req, JsonRpcException ex) + + private static void WriteErrorData(IBufferWriter output, JsonRpcSerializer serializer, object data) { - if (parseErrorHandler != null) - return parseErrorHandler(req, ex); - return ex; + switch (data) + { + case null: + Utf8Json.WriteNull(output); + break; + case string s: + Utf8Json.WriteString(output, s); + break; + case MethodNotFoundInfo notFound: + notFound.WriteTo(output); + break; + case ParameterErrorInfo parameterError: + parameterError.WriteTo(output); + break; + case JsonRpcException nested: + { + bool details = Config.IncludeExceptionDetails; + serializer.Write(output, new ExceptionInfo { ClassName = nested.GetType().FullName, Message = nested.message, HResult = nested.code, StackTraceString = details ? nested.StackTrace : null, Source = details ? nested.Source : null }, typeof(ExceptionInfo)); + } + break; + case Exception ex: + serializer.Write(output, ExceptionInfo.ForResponse(ex), typeof(ExceptionInfo)); + break; + default: + serializer.Write(output, data, data.GetType()); + break; + } } - internal void SetErrorHandler(Func handler) + + private static void WriteIdRaw(PooledByteBufferWriter output, ReadOnlySpan idRaw) { - externalErrorHandler = handler; + if (idRaw.Length == 0) Utf8Json.WriteNull(output); + else output.Write(idRaw); } - internal void SetParseErrorHandler(Func handler) + + /// Writes an id held as a CLR value (a handler replaced the request id). + private static void WriteIdValue(PooledByteBufferWriter output, JsonRpcSerializer serializer, object id) { - parseErrorHandler = handler; + switch (id) + { + case null: Utf8Json.WriteNull(output); break; + case string s: Utf8Json.WriteString(output, s); break; + case long l: Utf8Json.WriteInt64(output, l); break; + case int i: Utf8Json.WriteInt64(output, i); break; + default: serializer.Write(output, id, id.GetType()); break; + } } - - private object CleanUpParameter(object p, SMDAdditionalParameters metaData) + + // ---- exception mapping / handler hooks ---- + + private JsonRpcException MapException(JsonRpcRequestReader reader, Exception ex) { - var bob = p as JValue; + return MapException(externalErrorHandler == null ? null : MaterializeForHandler(reader), ex); + } - if (bob != null) + /// + /// The request as an error handler sees it. Params is null when the serializer cannot materialise it + /// (that failure is usually the error being reported, so it must not be repeated here). + /// + private static JsonRequest MaterializeForHandler(JsonRpcRequestReader reader) + { + object parameters; + try { - if (bob.Value == null || metaData.ObjectType == bob.Value.GetType()) - { - return bob.Value; - } + parameters = reader.ParamsValue; + } + catch (Exception) + { + parameters = null; + } + return new JsonRequest(reader.Method, parameters, SafeIdValue(reader)); + } - try - { - // Avoid calling DeserializeObject on types that JValue has an explicit converter for - // try to optimize for the most common types - if (metaData.ObjectType == typeof(string)) return (string)bob; - if (metaData.ObjectType == typeof(int)) return (int)bob; - if (metaData.ObjectType == typeof(double)) return (double)bob; - if (metaData.ObjectType == typeof(float)) return (float)bob; - //if (metaData.ObjectType == typeof(long)) return (long)bob; - //if (metaData.ObjectType == typeof(uint)) return (uint)bob; - //if (metaData.ObjectType == typeof(ulong)) return (ulong)bob; - //if (metaData.ObjectType == typeof(byte[])) return (byte[])bob; - //if (metaData.ObjectType == typeof(Guid)) return (Guid)bob; - if (metaData.ObjectType == typeof(decimal)) return (decimal)bob; - //if (metaData.ObjectType == typeof(TimeSpan)) return (TimeSpan)bob; - //if (metaData.ObjectType == typeof(short)) return (short)bob; - //if (metaData.ObjectType == typeof(ushort)) return (ushort)bob; - //if (metaData.ObjectType == typeof(char)) return (char)bob; - //if (metaData.ObjectType == typeof(DateTime)) return (DateTime)bob; - //if (metaData.ObjectType == typeof(bool)) return (bool)bob; - //if (metaData.ObjectType == typeof(DateTimeOffset)) return (DateTimeOffset)bob; - - if (metaData.ObjectType.IsAssignableFrom(typeof(JValue))) - return bob; - - return bob.ToObject(metaData.ObjectType); - } - catch (Exception) - { - // no need to throw here, they will - // get an invalid cast exception right after this. - } + private static object SafeIdValue(JsonRpcRequestReader reader) + { + try + { + return reader.IdValue; } - else + catch (Exception) + { + return null; + } + } + + private JsonRpcException MapException(JsonRequest request, Exception ex) + { + if (ex is TargetInvocationException tie && tie.InnerException != null) ex = tie.InnerException; + if (ex is JsonRpcException rpcEx) return ProcessException(request, rpcEx); + if (ex.InnerException is JsonRpcException innerRpc) return ProcessException(request, innerRpc); + if (ex is JsonRpcBindException) return ProcessException(request, new JsonRpcException(-32603, "Internal Error", ex)); + if (ex.InnerException != null) return ProcessException(request, new JsonRpcException(-32603, "Internal Error", ex.InnerException)); + return ProcessException(request, new JsonRpcException(-32603, "Internal Error", ex)); + } + + /// + /// Tells a bad argument from a failure inside the method after an invocation threw : the + /// arguments are read again, in order, and the first one the serializer refuses is the culprit (reads are + /// deterministic, and the method never ran when an argument failed). A conversion failure is the client's + /// fault: -32602 with a naming the parameter. Null when every argument reads + /// back fine (the method itself failed), when the failure is not a conversion (a type the serializer does not + /// support), or when the exception is an authored ; those keep the -32603 + /// mapping of . Only ever runs on the error path. + /// + private static JsonRpcException BindingFailure(JsonRpcRequestReader reader, RpcMethod method, int[] map, Exception ex) + { + if (ex is TargetInvocationException tie && tie.InnerException != null) ex = tie.InnerException; + if (ex is JsonRpcException || ex.InnerException is JsonRpcException) return null; + if (!ParameterErrorInfo.IsConversionFailure(ex)) return null; + var parameters = method.Parameters; + for (int p = 0; p < parameters.Length; p++) { + int index = map[p]; + if (index < 0) continue; try { - if (p is string) - return JsonConvert.DeserializeObject((string)p, metaData.ObjectType); - return JsonConvert.DeserializeObject(p.ToString(), metaData.ObjectType); + reader.ReadParam(index, parameters[p].Type); } catch (Exception) { - // no need to throw here, they will - // get an invalid cast exception right after this. + return new JsonRpcException(-32602, "Invalid params", new ParameterErrorInfo(parameters[p], p, ex)); } } + return null; + } + + private JsonRpcException ProcessException(JsonRpcRequestReader reader, JsonRpcException ex) + { + if (externalErrorHandler != null) + return externalErrorHandler(MaterializeForHandler(reader), ex); + return ex; + } + + private JsonRpcException ProcessException(JsonRequest req, JsonRpcException ex) + { + if (externalErrorHandler != null) + return externalErrorHandler(req, ex); + return ex; + } + + internal JsonRpcException ProcessParseException(JsonRpcRequestReader reader, JsonRpcException ex) + { + if (parseErrorHandler != null) + return parseErrorHandler(Utf8Json.ToStringUtf8(reader.Document.Span), ex); + return ex; + } - return p; + internal JsonRpcException ProcessParseException(string req, JsonRpcException ex) + { + if (parseErrorHandler != null) + return parseErrorHandler(req, ex); + return ex; + } + + internal bool HasParseErrorHandler => parseErrorHandler != null; + + internal void SetErrorHandler(Func handler) + { + externalErrorHandler = handler; + } + internal void SetParseErrorHandler(Func handler) + { + parseErrorHandler = handler; } private JsonRpcException PreProcess(JsonRequest request, object context) @@ -504,18 +955,17 @@ private JsonResponse PostProcess(JsonRequest request, JsonResponse response, obj JsonRpcException exception = externalPostProcessingHandler(request, response, context); if (exception != null) { - response = new JsonResponse() { Error = exception }; + response = new JsonResponse() { Error = exception, Id = request.Id }; } } catch (Exception ex) { - response = new JsonResponse() { Error = ProcessException(request, new JsonRpcException(-32603, "Internal Error", ex)) }; + response = new JsonResponse() { Error = ProcessException(request, new JsonRpcException(-32603, "Internal Error", ex)), Id = request.Id }; } } return response; } + #endregion } - } - diff --git a/Json-Rpc/Invocation/RpcMethod.Async.cs b/Json-Rpc/Invocation/RpcMethod.Async.cs new file mode 100644 index 0000000..6b458d7 --- /dev/null +++ b/Json-Rpc/Invocation/RpcMethod.Async.cs @@ -0,0 +1,337 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc.Jsmn; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc.Invocation +{ + internal delegate Task AsyncJsmnInvoker(JsmnRequestReader reader, int[] map, PooledByteBufferWriter output, CancellationToken cancellationToken); + internal delegate Task AsyncStreamingInvoker(JsonRpcRequestReader reader, int[] map, JsonRpcSerializer serializer, IBufferWriter output, CancellationToken cancellationToken); + internal delegate ValueTask AsyncBoxedInvoker(JsonRpcRequestReader reader, int[] map, CancellationToken cancellationToken); + + public sealed partial class RpcMethod + { + /// The eventual result type, or void for a non-generic Task or ValueTask. + public Type ResultType { get; private set; } + /// Whether this registration requires asynchronous processing. + public bool IsAsync { get; private set; } + /// The ambient context policy selected at registration. + public RpcContextFlow ContextFlow { get; private set; } + internal bool HasCancellation { get; private set; } + internal AsyncJsmnInvoker InvokeJsmnAsync { get; private set; } + internal AsyncStreamingInvoker InvokeAsync { get; private set; } + internal AsyncBoxedInvoker InvokeBoxedAsync { get; private set; } + + private enum AsyncReturnShape { Sync, Task, TaskResult, ValueTask, ValueTaskResult } + + private static AsyncReturnShape ClassifyReturn(string name, Type type, out Type resultType) + { + resultType = type; + if (type == typeof(Task)) { resultType = typeof(void); return AsyncReturnShape.Task; } + if (type == typeof(ValueTask)) { resultType = typeof(void); return AsyncReturnShape.ValueTask; } + if (type.IsGenericType) + { + var definition = type.GetGenericTypeDefinition(); + if (definition == typeof(Task<>) || definition == typeof(ValueTask<>)) + { + resultType = type.GetGenericArguments()[0]; + // Nested awaitables and async streams are not JSON result values. + if (ClassifyReturn(name, resultType, out _) != AsyncReturnShape.Sync) + throw new NotSupportedException("JSON-RPC method '" + name + "' returns a nested awaitable."); + return definition == typeof(Task<>) ? AsyncReturnShape.TaskResult : AsyncReturnShape.ValueTaskResult; + } + } + if (typeof(Task).IsAssignableFrom(type) || type.GetMethod("GetAwaiter", Type.EmptyTypes) != null || + type.IsByRef || type.IsPointer || type.ContainsGenericParameters || + type.GetInterfaces().Concat(new[] { type }).Any(t => t.IsGenericType && t.GetGenericTypeDefinition().FullName == "System.Collections.Generic.IAsyncEnumerable`1")) + throw new NotSupportedException("JSON-RPC method '" + name + "' has unsupported return type " + type + "; use Task, Task, ValueTask or ValueTask."); + return AsyncReturnShape.Sync; + } + + private static void ValidateCancellationParameters(string name, ParameterInfo[] parameters, AsyncReturnShape shape) + { + foreach (var p in parameters) + { + bool marked = p.IsDefined(typeof(JsonRpcCancellationAttribute), false); + if (p.ParameterType == typeof(CancellationToken) && !marked) + throw new NotSupportedException("JSON-RPC method '" + name + "': CancellationToken parameter '" + p.Name + "' requires [JsonRpcCancellation]."); + if (marked && p.ParameterType != typeof(CancellationToken)) + throw new NotSupportedException("[JsonRpcCancellation] requires a CancellationToken parameter."); + if (shape != AsyncReturnShape.Sync && p.ParameterType.IsByRef) + throw new NotSupportedException("JSON-RPC method '" + name + "': asynchronous registrations cannot have by-ref parameters, including ref JsonRpcException."); + } + } + + private static RpcMethod BuildCancellableSync(string name, ParameterInfo[] ps, Type returnType, + string[] parameterNames, Func makeCall, IDictionary defaults) + { + var wire = ps.Where(p => !p.IsDefined(typeof(JsonRpcCancellationAttribute), false)).ToArray(); + var names = parameterNames == null ? null : ps.Select((p, i) => new { p, i }) + .Where(v => !v.p.IsDefined(typeof(JsonRpcCancellationAttribute), false)) + .Select(v => v.i < parameterNames.Length ? parameterNames[v.i] : null).ToArray(); + var sync = Build(name, wire, returnType, names, args => + { + int index = 0; + return makeCall(ps.Select(p => p.IsDefined(typeof(JsonRpcCancellationAttribute), false) + ? (Expression)Expression.Default(typeof(CancellationToken)) : args[index++]).ToArray()); + }, defaults); + var plan = BuildAsync(name, ps, returnType, returnType, AsyncReturnShape.Sync, parameterNames, makeCall, defaults, RpcContextFlow.None); + plan.IsAsync = false; + plan.Invoke = sync.Invoke; + plan.InvokeBoxed = sync.InvokeBoxed; + plan.InvokeJsmn = sync.InvokeJsmn; + plan.ExpectsRefException = sync.ExpectsRefException; + return plan; + } + + private static RpcMethod BuildAsync(string name, ParameterInfo[] ps, Type returnType, Type resultType, + AsyncReturnShape shape, string[] parameterNames, Func makeCall, + IDictionary defaults, RpcContextFlow contextFlow) + { + if (contextFlow != RpcContextFlow.Flow && contextFlow != RpcContextFlow.None) + throw new ArgumentOutOfRangeException(nameof(contextFlow)); + var reader = Expression.Parameter(typeof(JsonRpcRequestReader), "reader"); + var jsmn = Expression.Parameter(typeof(JsmnRequestReader), "reader"); + var map = Expression.Parameter(typeof(int[]), "map"); + var serializer = Expression.Parameter(typeof(JsonRpcSerializer), "serializer"); + var output = Expression.Parameter(typeof(IBufferWriter), "output"); + var pooled = Expression.Parameter(typeof(PooledByteBufferWriter), "output"); + var token = Expression.Parameter(typeof(CancellationToken), "cancellationToken"); + var args = new Expression[ps.Length]; + var jsmnArgs = new Expression[ps.Length]; + var parameters = new List(); + bool expectsRef = shape == AsyncReturnShape.Sync && ps.Length > 0 && ps[ps.Length - 1].ParameterType == typeof(JsonRpcException).MakeByRefType(); + var refError = Expression.Variable(typeof(JsonRpcException), "refError"); + for (int i = 0; i < ps.Length; i++) + { + var p = ps[i]; + if (expectsRef && i == ps.Length - 1) { args[i] = jsmnArgs[i] = refError; continue; } + if (p.IsDefined(typeof(JsonRpcCancellationAttribute), false)) + { + args[i] = jsmnArgs[i] = token; + continue; + } + string jsonName = parameterNames != null && i < parameterNames.Length && parameterNames[i] != null ? parameterNames[i] : p.Name; + bool hasDefault = p.IsOptional || (defaults != null && defaults.ContainsKey(jsonName)); + object defaultValue = null; + if (defaults != null && defaults.TryGetValue(jsonName, out var dv)) defaultValue = dv; + else if (p.IsOptional && p.DefaultValue != DBNull.Value && p.DefaultValue != Type.Missing) defaultValue = p.DefaultValue; + var index = Expression.ArrayIndex(map, Expression.Constant(parameters.Count)); + parameters.Add(new RpcParameter(jsonName, p.ParameterType, hasDefault, defaultValue)); + var supplied = Expression.GreaterThanOrEqual(index, Expression.Constant(0)); + var fallback = MakeDefault(p.ParameterType, hasDefault, defaultValue); + args[i] = Expression.Condition(supplied, Expression.Call(reader, ReadParamGeneric.MakeGenericMethod(p.ParameterType), index), fallback); + jsmnArgs[i] = Expression.Condition(supplied, MakeJsmnRead(jsmn, index, p.ParameterType), fallback); + } + Expression MakeCall(Expression[] arguments) + { + var call = makeCall(arguments); + if (!expectsRef) return call; + var throwIfError = Expression.IfThen(Expression.NotEqual(refError, Expression.Constant(null, typeof(JsonRpcException))), Expression.Throw(refError)); + if (returnType == typeof(void)) return Expression.Block(new[] { refError }, call, throwIfError); + var result = Expression.Variable(returnType, "result"); + return Expression.Block(new[] { refError, result }, Expression.Assign(result, call), throwIfError, result); + } + return new RpcMethod + { + Name = name, + Parameters = parameters.ToArray(), + ReturnType = returnType, + ResultType = resultType, + IsAsync = true, + HasCancellation = ps.Any(p => p.IsDefined(typeof(JsonRpcCancellationAttribute), false)), + ContextFlow = contextFlow, + DefaultCount = parameters.Count(p => p.HasDefault), + IdentityMap = Enumerable.Range(0, parameters.Count).ToArray(), + HasUniqueNames = parameters.Select(p => p.Name).Distinct().Count() == parameters.Count, + Invoke = (r, m, s, w) => throw SynchronousAsyncError(name, r), + InvokeBoxed = (r, m) => throw SynchronousAsyncError(name, r), + InvokeJsmn = (r, m, w) => throw SynchronousAsyncError(name, r), + InvokeAsync = Expression.Lambda(MakeAsyncBody(name, MakeCall(args), returnType, resultType, shape, serializer, output, false), reader, map, serializer, output, token).Compile(), + InvokeJsmnAsync = Expression.Lambda(MakeAsyncBody(name, MakeCall(jsmnArgs), returnType, resultType, shape, null, pooled, false), jsmn, map, pooled, token).Compile(), + InvokeBoxedAsync = Expression.Lambda(MakeAsyncBody(name, MakeCall(args), returnType, resultType, shape, null, null, true), reader, map, token).Compile() + }; + } + + private static Expression MakeAsyncBody(string name, Expression call, Type returnType, Type resultType, + AsyncReturnShape shape, ParameterExpression serializer, ParameterExpression output, bool boxed) + { + if (shape == AsyncReturnShape.Sync) + { + if (boxed) + { + var ctor = typeof(ValueTask).GetConstructor(new[] { typeof(object) }); + return returnType == typeof(void) ? Expression.Block(call, Expression.New(ctor, Expression.Constant(null, typeof(object)))) + : (Expression)Expression.New(ctor, Expression.Convert(call, typeof(object))); + } + if (returnType == typeof(void)) + return Expression.Block(call, Expression.Call(serializer != null ? WriteNullMethod : WriteNullPooled, output), Expression.Constant(Task.CompletedTask)); + var value = Expression.Variable(returnType, "value"); + return Expression.Block(new[] { value }, Expression.Assign(value, call), + serializer != null ? (Expression)Expression.Call(serializer, WriteGeneric.MakeGenericMethod(returnType), output, value) : MakeJsmnWrite(output, value, returnType), + Expression.Constant(Task.CompletedTask)); + } + var operation = Expression.Variable(returnType, "operation"); + bool hasResult = resultType != typeof(void); + bool isTask = shape == AsyncReturnShape.Task || shape == AsyncReturnShape.TaskResult; + var awaiter = Expression.Call(operation, returnType.GetMethod("GetAwaiter", Type.EmptyTypes)); + var getResult = Expression.Call(awaiter, awaiter.Type.GetMethod("GetResult", Type.EmptyTypes)); + var valueTaskType = hasResult ? typeof(ValueTask<>).MakeGenericType(resultType) : typeof(ValueTask); + Expression slowOperation = isTask ? Expression.New(valueTaskType.GetConstructor(new[] { returnType }), operation) : (Expression)operation; + Expression completed; + Expression slow; + if (boxed) + { + var ctor = typeof(ValueTask).GetConstructor(new[] { typeof(object) }); + completed = hasResult ? (Expression)Expression.New(ctor, Expression.Convert(getResult, typeof(object))) + : Expression.Block(getResult, Expression.New(ctor, Expression.Constant(null, typeof(object)))); + slow = Expression.Call(AsyncHelper(hasResult ? nameof(AwaitBoxed) : nameof(AwaitVoidBoxed), hasResult ? resultType : null), slowOperation); + } + else + { + var done = Expression.Constant(Task.CompletedTask, typeof(Task)); + if (hasResult) + { + var result = Expression.Variable(resultType, "result"); + var write = serializer != null ? (Expression)Expression.Call(serializer, WriteGeneric.MakeGenericMethod(resultType), output, result) : MakeJsmnWrite(output, result, resultType); + completed = Expression.Block(new[] { result }, Expression.Assign(result, getResult), write, done); + slow = serializer != null + ? Expression.Call(AsyncHelper(nameof(AwaitAndWrite), resultType), slowOperation, serializer, output) + : Expression.Call(JsmnAsyncHelper(resultType), slowOperation, output); + } + else + { + completed = Expression.Block(getResult, Expression.Call(serializer != null ? WriteNullMethod : WriteNullPooled, output), done); + slow = Expression.Call(AsyncHelper(serializer != null ? nameof(AwaitVoidAndWrite) : nameof(AwaitVoidAndWritePooled), null), slowOperation, output); + } + } + var expressions = new List { Expression.Assign(operation, call) }; + if (isTask) + expressions.Add(Expression.IfThen(Expression.Equal(operation, Expression.Constant(null, returnType)), + Expression.Throw(Expression.Call(AsyncHelper(nameof(NullTaskReturned), null), Expression.Constant(name))))); + expressions.Add(Expression.Condition(Expression.Property(operation, "IsCompleted"), completed, slow)); + return Expression.Block(new[] { operation }, expressions); + } + + private static MethodInfo AsyncHelper(string name, Type type) + { + var method = typeof(RpcMethod).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static); + return type == null ? method : method.MakeGenericMethod(type); + } + + private static JsonRpcException SynchronousAsyncError(string name, JsonRpcRequestReader reader) => new JsonRpcException(-32603, + "Method '" + (string.IsNullOrEmpty(name) ? reader.Method : name) + "' is asynchronous; process the request with JsonRpcProcessor.ProcessAsync", null); + + private static JsonRpcException NullTaskReturned(string name) => new JsonRpcException(-32603, "Method '" + name + "' returned a null Task.", null); + + private static MethodInfo JsmnAsyncHelper(Type type) + { + var underlying = Nullable.GetUnderlyingType(type); + var primitive = underlying ?? type; + if (Primitives.ContainsKey(primitive)) + return AsyncHelper("Await" + primitive.Name + (underlying != null ? "Nullable" : "") + "AndWrite", null); + return AsyncHelper(nameof(AwaitAndWritePooled), type); + } + + private static async Task AwaitAndWrite(ValueTask operation, JsonRpcSerializer serializer, IBufferWriter output) + { + T result = await operation.ConfigureAwait(false); + serializer.Write(output, result); + } + + private static async Task AwaitAndWritePooled(ValueTask operation, PooledByteBufferWriter output) + { + T result = await operation.ConfigureAwait(false); + JsmnWriter.Write(output, result); + } + + private static async ValueTask AwaitBoxed(ValueTask operation) => await operation.ConfigureAwait(false); + private static async ValueTask AwaitVoidBoxed(ValueTask operation) { await operation.ConfigureAwait(false); return null; } + private static async Task AwaitVoidAndWrite(ValueTask operation, IBufferWriter output) { await operation.ConfigureAwait(false); Utf8Json.WriteNull(output); } + private static async Task AwaitVoidAndWritePooled(ValueTask operation, PooledByteBufferWriter output) { await operation.ConfigureAwait(false); Utf8Json.WriteNull(output); } + + private static async Task AwaitStringAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + string result = await operation.ConfigureAwait(false); + Utf8Json.WriteString(output, result); + } + + private static async Task AwaitInt32AndWrite(ValueTask operation, PooledByteBufferWriter output) + { + int result = await operation.ConfigureAwait(false); + Utf8Json.WriteInt64(output, result); + } + + private static async Task AwaitInt32NullableAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + int? result = await operation.ConfigureAwait(false); + if (result.HasValue) Utf8Json.WriteInt64(output, result.Value); else Utf8Json.WriteNull(output); + } + + private static async Task AwaitInt64AndWrite(ValueTask operation, PooledByteBufferWriter output) + { + long result = await operation.ConfigureAwait(false); + Utf8Json.WriteInt64(output, result); + } + + private static async Task AwaitInt64NullableAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + long? result = await operation.ConfigureAwait(false); + if (result.HasValue) Utf8Json.WriteInt64(output, result.Value); else Utf8Json.WriteNull(output); + } + + private static async Task AwaitDoubleAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + double result = await operation.ConfigureAwait(false); + Utf8Json.WriteDouble(output, result); + } + + private static async Task AwaitDoubleNullableAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + double? result = await operation.ConfigureAwait(false); + if (result.HasValue) Utf8Json.WriteDouble(output, result.Value); else Utf8Json.WriteNull(output); + } + + private static async Task AwaitSingleAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + float result = await operation.ConfigureAwait(false); + Utf8Json.WriteSingle(output, result); + } + + private static async Task AwaitSingleNullableAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + float? result = await operation.ConfigureAwait(false); + if (result.HasValue) Utf8Json.WriteSingle(output, result.Value); else Utf8Json.WriteNull(output); + } + + private static async Task AwaitBooleanAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + bool result = await operation.ConfigureAwait(false); + Utf8Json.WriteBool(output, result); + } + + private static async Task AwaitBooleanNullableAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + bool? result = await operation.ConfigureAwait(false); + if (result.HasValue) Utf8Json.WriteBool(output, result.Value); else Utf8Json.WriteNull(output); + } + + private static async Task AwaitDecimalAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + decimal result = await operation.ConfigureAwait(false); + Utf8Json.WriteDecimal(output, result); + } + + private static async Task AwaitDecimalNullableAndWrite(ValueTask operation, PooledByteBufferWriter output) + { + decimal? result = await operation.ConfigureAwait(false); + if (result.HasValue) Utf8Json.WriteDecimal(output, result.Value); else Utf8Json.WriteNull(output); + } + } +} diff --git a/Json-Rpc/Invocation/RpcMethod.cs b/Json-Rpc/Invocation/RpcMethod.cs new file mode 100644 index 0000000..0d79280 --- /dev/null +++ b/Json-Rpc/Invocation/RpcMethod.cs @@ -0,0 +1,353 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using AustinHarris.JsonRpc.Jsmn; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc.Invocation +{ + /// + /// Binds parameters straight from the request reader, invokes the target and streams the result + /// into through the serializer. No boxing, no object[] and no DynamicInvoke. + /// [p] is the reader parameter index for method parameter p, or -1 to use its default. + /// + public delegate void StreamingInvoker(JsonRpcRequestReader reader, int[] map, JsonRpcSerializer serializer, IBufferWriter output); + + /// Same binding, but returns the boxed result. Used when pre/post handlers need a . + public delegate object BoxedInvoker(JsonRpcRequestReader reader, int[] map); + + /// + /// The invoker specialised for the built-in serializer: parameters are read from the tokens by direct static + /// calls and the result is formatted into the concrete pooled writer, so a primitive request goes from bytes to + /// bytes without a virtual, delegate or interface call around the service method. + /// + internal delegate void JsmnInvoker(JsmnRequestReader reader, int[] map, PooledByteBufferWriter output); + + public sealed class RpcParameter + { + internal RpcParameter(string name, Type type, bool hasDefault, object defaultValue) + { + Name = name; + Type = type; + HasDefault = hasDefault; + DefaultValue = defaultValue; + NameUtf8 = System.Text.Encoding.UTF8.GetBytes(name); + } + + public string Name { get; } + public Type Type { get; } + public bool HasDefault { get; } + public object DefaultValue { get; } + internal readonly byte[] NameUtf8; + } + + /// + /// A registered JSON-RPC method: its parameter shape plus two compiled invokers built with expression trees. + /// + public sealed partial class RpcMethod + { + public string Name { get; private set; } + /// Bindable parameters, in order. A trailing ref JsonRpcException parameter is not included. + public RpcParameter[] Parameters { get; private set; } + public Type ReturnType { get; private set; } + public bool ExpectsRefException { get; private set; } + public int DefaultCount { get; private set; } + public StreamingInvoker Invoke { get; private set; } + public BoxedInvoker InvokeBoxed { get; private set; } + internal JsmnInvoker InvokeJsmn { get; private set; } + /// The identity map (0,1,2,...) used when positional params match exactly. + internal int[] IdentityMap { get; private set; } + /// True when no two parameters share a JSON name (named params in declaration order can then use ). + internal bool HasUniqueNames { get; private set; } + + private static readonly MethodInfo ReadParamGeneric = typeof(JsonRpcRequestReader) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .First(m => m.Name == nameof(JsonRpcRequestReader.ReadParam) && m.IsGenericMethodDefinition); + + private static readonly MethodInfo WriteGeneric = typeof(JsonRpcSerializer) + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .First(m => m.Name == nameof(JsonRpcSerializer.Write) && m.IsGenericMethodDefinition); + + private static readonly MethodInfo WriteNullMethod = typeof(Utf8Json).GetMethod(nameof(Utf8Json.WriteNull), new[] { typeof(IBufferWriter) }); + private static readonly MethodInfo WriteNullPooled = typeof(Utf8Json).GetMethod(nameof(Utf8Json.WriteNull), new[] { typeof(PooledByteBufferWriter) }); + private static readonly MethodInfo ReadTypedParamGeneric = typeof(JsmnRequestReader).GetMethod(nameof(JsmnRequestReader.ReadTypedParam), BindingFlags.NonPublic | BindingFlags.Static); + private static readonly MethodInfo ParamIsNullLiteral = typeof(JsmnRequestReader).GetMethod(nameof(JsmnRequestReader.ParamIsNullLiteral), BindingFlags.NonPublic | BindingFlags.Static); + private static readonly MethodInfo WriteValuePooledGeneric = typeof(RpcMethod).GetMethod(nameof(WriteValuePooled), BindingFlags.NonPublic | BindingFlags.Static); + + /// The built-in typed writer for a non-primitive result (POCO, collection, nullable of a non-primitive). + private static void WriteValuePooled(PooledByteBufferWriter output, T value) => JsmnWriter.Write(output, value); + + // primitive type -> (static reader on JsmnRequestReader, static writer on Utf8Json taking the pooled writer) + private static readonly Dictionary Primitives = BuildPrimitiveTable(); + + private static Dictionary BuildPrimitiveTable() + { + var reader = typeof(JsmnRequestReader); + MethodInfo Read(string name) => reader.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static); + MethodInfo Write(string name, Type t) => typeof(Utf8Json).GetMethod(name, new[] { typeof(PooledByteBufferWriter), t }); + return new Dictionary + { + [typeof(string)] = (Read(nameof(JsmnRequestReader.ReadStringParam)), Write(nameof(Utf8Json.WriteString), typeof(string))), + [typeof(int)] = (Read(nameof(JsmnRequestReader.ReadInt32Param)), Write(nameof(Utf8Json.WriteInt64), typeof(long))), + [typeof(long)] = (Read(nameof(JsmnRequestReader.ReadInt64Param)), Write(nameof(Utf8Json.WriteInt64), typeof(long))), + [typeof(double)] = (Read(nameof(JsmnRequestReader.ReadDoubleParam)), Write(nameof(Utf8Json.WriteDouble), typeof(double))), + [typeof(float)] = (Read(nameof(JsmnRequestReader.ReadSingleParam)), Write(nameof(Utf8Json.WriteSingle), typeof(float))), + [typeof(bool)] = (Read(nameof(JsmnRequestReader.ReadBooleanParam)), Write(nameof(Utf8Json.WriteBool), typeof(bool))), + [typeof(decimal)] = (Read(nameof(JsmnRequestReader.ReadDecimalParam)), Write(nameof(Utf8Json.WriteDecimal), typeof(decimal))), + }; + } + + /// Compatibility overload preserving the original registration signature. + public static RpcMethod FromMethod(string name, MethodInfo method, object target, string[] parameterNames) + { + return FromMethod(name, method, target, parameterNames, RpcContextFlow.None); + } + + /// Compatibility overload preserving the original delegate registration signature. + public static RpcMethod FromDelegate(string name, Delegate implementation, string[] parameterNames, IDictionary defaults) + { + return FromDelegate(name, implementation, parameterNames, defaults, RpcContextFlow.None); + } + + /// Builds the invokers for an instance (or static) method; are the JSON names (null = CLR names). + public static RpcMethod FromMethod(string name, MethodInfo method, object target, string[] parameterNames = null, RpcContextFlow contextFlow = RpcContextFlow.None) + { + RejectAsyncReturnType(name, method); + var ps = method.GetParameters(); + Expression instance = method.IsStatic ? null : Expression.Constant(target, method.DeclaringType); + return Build(name, ps, method.ReturnType, parameterNames, args => Expression.Call(instance, method, args), contextFlow: contextFlow); + } + + /// + /// 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 + /// implementation method with the receiver typed as the exact implementation type. + /// + internal static RpcMethod FromMappedMethod(string name, MethodInfo contractMethod, MethodInfo targetMethod, + object target, string[] parameterNames, RpcContextFlow contextFlow = RpcContextFlow.None) + { + RejectAsyncReturnType(name, contractMethod); + RejectAsyncReturnType(name, targetMethod, contractMethod.ReturnType); + var instance = Expression.Constant(target, target.GetType()); + return Build(name, contractMethod.GetParameters(), contractMethod.ReturnType, parameterNames, + args => Expression.Call(instance, targetMethod, args), contextFlow: contextFlow); + } + + /// + /// Builds the invokers for any delegate (lambda, closed instance method, ...). The parameter list is the + /// delegate type's Invoke signature; the names come from , else from + /// the target method when it has the same shape (a lambda's own parameter names), else arg1, arg2... + /// + public static RpcMethod FromDelegate(string name, Delegate implementation, string[] parameterNames = null, IDictionary defaults = null, RpcContextFlow contextFlow = RpcContextFlow.None) + { + if (implementation == null) throw new ArgumentNullException(nameof(implementation)); + if (implementation.GetInvocationList().Length != 1) + { + throw new ArgumentException("JSON-RPC method '" + name + "': a multicast delegate cannot be registered; it has no single return value.", nameof(implementation)); + } + var invoke = implementation.GetType().GetMethod("Invoke"); + var shape = invoke.GetParameters(); + var target = implementation.Method; + RejectAsyncReturnType(name, target, invoke.ReturnType); + + // the target's own ParameterInfo carries names and optional-parameter defaults, but only describes the + // delegate when it has the same shape (not for a closed static method or an extension-method delegate) + var ps = shape; + var targetPs = target.GetParameters(); + if (targetPs.Length == shape.Length) + { + bool same = true; + for (int i = 0; i < shape.Length && same; i++) same = targetPs[i].ParameterType == shape[i].ParameterType; + if (same) ps = targetPs; + } + var del = Expression.Constant(implementation); + return Build(name, ps, invoke.ReturnType, parameterNames, args => Expression.Invoke(del, args), defaults, contextFlow); + } + + private static void RejectAsyncReturnType(string name, MethodInfo method) + { + RejectAsyncReturnType(name, method, method.ReturnType); + } + + private static void RejectAsyncReturnType(string name, MethodInfo method, Type returnType) + { + if (returnType == typeof(void) && method.IsDefined(typeof(System.Runtime.CompilerServices.AsyncStateMachineAttribute), false)) + throw new NotSupportedException("JSON-RPC method '" + name + "' is declared async void; return Task or ValueTask instead."); + } + + private static RpcMethod Build(string name, ParameterInfo[] ps, Type returnType, string[] parameterNames, + Func makeCall, IDictionary defaults = null, RpcContextFlow contextFlow = RpcContextFlow.None) + { + var shape = ClassifyReturn(name, returnType, out var resultType); + ValidateCancellationParameters(name, ps, shape); + if (shape == AsyncReturnShape.Sync && ps.Any(p => p.IsDefined(typeof(JsonRpcCancellationAttribute), false))) + return BuildCancellableSync(name, ps, returnType, parameterNames, makeCall, defaults); + if (shape != AsyncReturnShape.Sync) + return BuildAsync(name, ps, returnType, resultType, shape, parameterNames, makeCall, defaults, contextFlow); + + var reader = Expression.Parameter(typeof(JsonRpcRequestReader), "reader"); + var map = Expression.Parameter(typeof(int[]), "map"); + var serializer = Expression.Parameter(typeof(JsonRpcSerializer), "serializer"); + var output = Expression.Parameter(typeof(IBufferWriter), "output"); + var refEx = Expression.Variable(typeof(JsonRpcException), "refException"); + + bool expectsRef = ps.Length > 0 && ps[ps.Length - 1].ParameterType == typeof(JsonRpcException).MakeByRefType(); + int bindable = expectsRef ? ps.Length - 1 : ps.Length; + + // the built-in invoker binds the same parameters through direct static reads + var jsmnReader = Expression.Parameter(typeof(JsmnRequestReader), "reader"); + var pooled = Expression.Parameter(typeof(PooledByteBufferWriter), "output"); + + // The invokers carry no error handling of their own: an argument the serializer refuses throws straight + // out, and the dispatcher then re-reads the arguments to tell a conversion failure (-32602, naming the + // parameter) from a failure inside the method (-32603). That keeps the non-throwing path free of any + // try region or bookkeeping store. + var parameters = new RpcParameter[bindable]; + var args = new Expression[ps.Length]; + var jsmnArgs = new Expression[ps.Length]; + int defaultCount = 0; + for (int i = 0; i < bindable; i++) + { + var p = ps[i]; + string jsonName = parameterNames != null && i < parameterNames.Length && parameterNames[i] != null ? parameterNames[i] : p.Name; + bool hasDefault = p.IsOptional || (defaults != null && defaults.ContainsKey(jsonName)); + object defaultValue = null; + if (defaults != null && defaults.TryGetValue(jsonName, out var dv)) defaultValue = dv; + else if (p.IsOptional && p.DefaultValue != DBNull.Value && p.DefaultValue != Type.Missing) defaultValue = p.DefaultValue; + if (hasDefault) defaultCount++; + + parameters[i] = new RpcParameter(jsonName, p.ParameterType, hasDefault, defaultValue); + + var index = Expression.ArrayIndex(map, Expression.Constant(i)); + var read = Expression.Call(reader, ReadParamGeneric.MakeGenericMethod(p.ParameterType), index); + Expression fallback = MakeDefault(p.ParameterType, hasDefault, defaultValue); + var supplied = Expression.GreaterThanOrEqual(index, Expression.Constant(0)); + args[i] = Expression.Condition(supplied, read, fallback); + jsmnArgs[i] = Expression.Condition(supplied, MakeJsmnRead(jsmnReader, index, p.ParameterType), fallback); + } + if (expectsRef) args[ps.Length - 1] = jsmnArgs[ps.Length - 1] = refEx; + + var call = makeCall(args); + var jsmnCall = makeCall(jsmnArgs); + var throwIfRef = expectsRef + ? (Expression)Expression.IfThen(Expression.NotEqual(refEx, Expression.Constant(null, typeof(JsonRpcException))), Expression.Throw(refEx)) + : Expression.Empty(); + var clearRef = Expression.Assign(refEx, Expression.Constant(null, typeof(JsonRpcException))); + + // streaming: result = call(); if (refEx != null) throw; serializer.Write(output, result) + Expression streamingBody; + Expression boxedBody; + Expression jsmnBody; + if (returnType == typeof(void)) + { + streamingBody = Expression.Block(new[] { refEx }, clearRef, call, throwIfRef, Expression.Call(WriteNullMethod, output)); + boxedBody = Expression.Block(typeof(object), new[] { refEx }, clearRef, call, throwIfRef, Expression.Constant(null, typeof(object))); + jsmnBody = Expression.Block(new[] { refEx }, clearRef, jsmnCall, throwIfRef, Expression.Call(WriteNullPooled, pooled)); + } + else + { + var result = Expression.Variable(returnType, "result"); + streamingBody = Expression.Block(new[] { refEx, result }, + clearRef, + Expression.Assign(result, call), + throwIfRef, + Expression.Call(serializer, WriteGeneric.MakeGenericMethod(returnType), output, result)); + boxedBody = Expression.Block(typeof(object), new[] { refEx, result }, + clearRef, + Expression.Assign(result, call), + throwIfRef, + Expression.Convert(result, typeof(object))); + jsmnBody = Expression.Block(new[] { refEx, result }, + clearRef, + Expression.Assign(result, jsmnCall), + throwIfRef, + MakeJsmnWrite(pooled, result, returnType)); + } + + var streaming = Expression.Lambda(streamingBody, reader, map, serializer, output).Compile(); + var boxed = Expression.Lambda(boxedBody, reader, map).Compile(); + var jsmnInvoker = Expression.Lambda(jsmnBody, jsmnReader, map, pooled).Compile(); + + var identity = new int[bindable]; + for (int i = 0; i < bindable; i++) identity[i] = i; + var names = new HashSet(); + bool unique = true; + foreach (var parameter in parameters) unique &= names.Add(parameter.Name); + + return new RpcMethod + { + Name = name, + Parameters = parameters, + ReturnType = returnType, + ResultType = returnType, + ExpectsRefException = expectsRef, + DefaultCount = defaultCount, + Invoke = streaming, + InvokeBoxed = boxed, + InvokeJsmn = jsmnInvoker, + IdentityMap = identity, + HasUniqueNames = unique + }; + } + + /// + /// The built-in read of parameter as : a direct static call + /// for the primitives, a null test plus the value read for their nullables, the typed reader cache for the rest. + /// + private static Expression MakeJsmnRead(ParameterExpression reader, Expression index, Type type) + { + if (Primitives.TryGetValue(type, out var primitive)) return Expression.Call(primitive.Read, reader, index); + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null && Primitives.TryGetValue(underlying, out var inner)) + { + return Expression.Condition( + Expression.Call(ParamIsNullLiteral, reader, index), + Expression.Constant(null, type), + Expression.Convert(Expression.Call(inner.Read, reader, index), type)); + } + return Expression.Call(ReadTypedParamGeneric.MakeGenericMethod(type), reader, index); + } + + /// The built-in write of : the concrete-writer formatter for primitives (and their nullables), the typed writer cache otherwise. + private static Expression MakeJsmnWrite(ParameterExpression output, ParameterExpression result, Type type) + { + if (Primitives.TryGetValue(type, out var primitive)) + { + Expression value = result; + if (type == typeof(int)) value = Expression.Convert(result, typeof(long)); + return Expression.Call(primitive.Write, output, value); + } + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null && Primitives.TryGetValue(underlying, out var inner)) + { + Expression value = Expression.Property(result, "Value"); + if (underlying == typeof(int)) value = Expression.Convert(value, typeof(long)); + return Expression.IfThenElse( + Expression.Property(result, "HasValue"), + Expression.Call(inner.Write, output, value), + Expression.Call(WriteNullPooled, output)); + } + return Expression.Call(WriteValuePooledGeneric.MakeGenericMethod(type), output, result); + } + + private static Expression MakeDefault(Type type, bool hasDefault, object value) + { + if (!hasDefault || value == null) return Expression.Default(type); + if (type.IsInstanceOfType(value)) return Expression.Constant(value, type); + var underlying = Nullable.GetUnderlyingType(type) ?? type; + object converted; + try + { + converted = underlying.IsEnum ? Enum.ToObject(underlying, value) : Convert.ChangeType(value, underlying, System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + return Expression.Default(type); + } + return Expression.Convert(Expression.Constant(converted, underlying), type); + } + } +} diff --git a/Json-Rpc/Jsmn/JsmnMapper.cs b/Json-Rpc/Jsmn/JsmnMapper.cs new file mode 100644 index 0000000..d9ced33 --- /dev/null +++ b/Json-Rpc/Jsmn/JsmnMapper.cs @@ -0,0 +1,811 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc.Jsmn +{ + /// A position inside a tokenized document. + public ref struct JsmnCursor + { + public ReadOnlySpan Doc; + public JsmnToken[] Tokens; + public int Index; + + public JsmnCursor(ReadOnlySpan doc, JsmnToken[] tokens, int index) + { + Doc = doc; + Tokens = tokens; + Index = index; + } + + public ref JsmnToken Token => ref Tokens[Index]; + public ReadOnlySpan Text => JsmnTokenizer.Slice(Doc, Tokens[Index]); + public bool IsNull => JsmnTokenizer.IsNull(Doc, Tokens[Index]); + + /// Index of the token following the current subtree. + public int Next() + { + int need = 1, i = Index; + while (need > 0) { need += Tokens[i].Size; need--; i++; } + return i; + } + } + + public delegate T JsmnTokenReader(ref JsmnCursor cursor); + + /// Typed reader cache: primitives bind without boxing, everything else goes through the boxed mapper. + public static class JsmnReader + { + public static readonly JsmnTokenReader Read = Build(); + + private static JsmnTokenReader Build() + { + var t = typeof(T); + object r = null; + if (t == typeof(string)) r = new JsmnTokenReader(JsmnMapper.ReadString); + else if (t == typeof(int)) r = new JsmnTokenReader(JsmnMapper.ReadInt32); + else if (t == typeof(long)) r = new JsmnTokenReader(JsmnMapper.ReadInt64); + else if (t == typeof(double)) r = new JsmnTokenReader(JsmnMapper.ReadDouble); + else if (t == typeof(float)) r = new JsmnTokenReader(JsmnMapper.ReadSingle); + else if (t == typeof(bool)) r = new JsmnTokenReader(JsmnMapper.ReadBoolean); + else if (t == typeof(decimal)) r = new JsmnTokenReader(JsmnMapper.ReadDecimal); + else if (t == typeof(short)) r = new JsmnTokenReader((ref JsmnCursor c) => checked((short)JsmnMapper.ReadInt64(ref c))); + else if (t == typeof(ushort)) r = new JsmnTokenReader((ref JsmnCursor c) => checked((ushort)JsmnMapper.ReadInt64(ref c))); + else if (t == typeof(byte)) r = new JsmnTokenReader((ref JsmnCursor c) => checked((byte)JsmnMapper.ReadInt64(ref c))); + else if (t == typeof(sbyte)) r = new JsmnTokenReader((ref JsmnCursor c) => checked((sbyte)JsmnMapper.ReadInt64(ref c))); + else if (t == typeof(uint)) r = new JsmnTokenReader((ref JsmnCursor c) => checked((uint)JsmnMapper.ReadInt64(ref c))); + else if (t == typeof(ulong)) r = new JsmnTokenReader(JsmnMapper.ReadUInt64); + else if (t == typeof(char)) r = new JsmnTokenReader(JsmnMapper.ReadChar); + else if (t == typeof(DateTime)) r = new JsmnTokenReader(JsmnMapper.ReadDateTime); + if (r != null) return (JsmnTokenReader)r; + var underlying = Nullable.GetUnderlyingType(t); + if (underlying != null) + { + // Nullable: null token -> null, otherwise the unboxed U reader + var factory = typeof(JsmnReader).GetMethod(nameof(MakeNullable), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static).MakeGenericMethod(underlying); + return (JsmnTokenReader)factory.Invoke(null, null); + } + return (ref JsmnCursor c) => (T)JsmnMapper.ReadObject(ref c, typeof(T)); + } + + private static JsmnTokenReader MakeNullable() where U : struct + { + var inner = JsmnReader.Read; + return (ref JsmnCursor c) => c.IsNull ? (U?)null : inner(ref c); + } + } + + /// Typed writer cache, mirror of . + public static class JsmnWriter + { + public static readonly Action, T> Write = Build(); + + private static Action, T> Build() + { + var t = typeof(T); + object w = null; + if (t == typeof(string)) w = new Action, string>(Utf8Json.WriteString); + else if (t == typeof(int)) w = new Action, int>((o, v) => Utf8Json.WriteInt64(o, v)); + else if (t == typeof(long)) w = new Action, long>(Utf8Json.WriteInt64); + else if (t == typeof(double)) w = new Action, double>(Utf8Json.WriteDouble); + else if (t == typeof(float)) w = new Action, float>(Utf8Json.WriteSingle); + else if (t == typeof(bool)) w = new Action, bool>(Utf8Json.WriteBool); + else if (t == typeof(decimal)) w = new Action, decimal>(Utf8Json.WriteDecimal); + else if (t == typeof(short)) w = new Action, short>((o, v) => Utf8Json.WriteInt64(o, v)); + else if (t == typeof(ushort)) w = new Action, ushort>((o, v) => Utf8Json.WriteInt64(o, v)); + else if (t == typeof(byte)) w = new Action, byte>((o, v) => Utf8Json.WriteInt64(o, v)); + else if (t == typeof(sbyte)) w = new Action, sbyte>((o, v) => Utf8Json.WriteInt64(o, v)); + else if (t == typeof(uint)) w = new Action, uint>((o, v) => Utf8Json.WriteUInt64(o, v)); + else if (t == typeof(ulong)) w = new Action, ulong>(Utf8Json.WriteUInt64); + else if (t == typeof(char)) w = new Action, char>(Utf8Json.WriteChar); + else if (t == typeof(DateTime)) w = new Action, DateTime>(Utf8Json.WriteDateTime); + if (w != null) return (Action, T>)w; + var underlying = Nullable.GetUnderlyingType(t); + if (underlying != null) + { + var factory = typeof(JsmnWriter).GetMethod(nameof(MakeNullable), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static).MakeGenericMethod(underlying); + return (Action, T>)factory.Invoke(null, null); + } + return (o, v) => JsmnMapper.WriteObject(o, v, typeof(T), 0); + } + + private static Action, U?> MakeNullable() where U : struct + { + var inner = JsmnWriter.Write; + return (o, v) => { if (v.HasValue) inner(o, v.Value); else Utf8Json.WriteNull(o); }; + } + } + + /// + /// Converts between jsmn tokens and CLR values. Coercions follow what the library has always accepted + /// through Json.NET: numbers to bool/char, integers to floating types, ISO strings to DateTime, + /// case-insensitive member names, public fields and properties in declaration order, nulls written out. + /// + public static class JsmnMapper + { + private const int MaxDepth = 64; + + // ------------------------------------------------------------------ primitives + + public static string ReadString(ref JsmnCursor c) + { + ref var t = ref c.Token; + if (t.Type == JsmnType.String) return Utf8Json.DecodeString(c.Text); + if (t.Type == JsmnType.Primitive) + { + if (c.IsNull) return null; + return Utf8Json.ToStringUtf8(c.Text); + } + throw Bind("string", ref c); + } + + public static long ReadInt64(ref JsmnCursor c) + { + ref var t = ref c.Token; + var text = c.Text; + if (t.Type == JsmnType.Primitive || t.Type == JsmnType.String) + { + if (Utf8Parser.TryParse(text, out long v, out int consumed) && consumed == text.Length) return v; + if (text.Length == 4 && text[0] == (byte)'t') return 1; + if (text.Length == 5 && text[0] == (byte)'f') return 0; + if (Utf8Parser.TryParse(text, out double d, out consumed) && consumed == text.Length) return checked((long)Math.Round(d, MidpointRounding.ToEven)); + } + throw Bind("integer", ref c); + } + + public static ulong ReadUInt64(ref JsmnCursor c) + { + var text = c.Text; + if (Utf8Parser.TryParse(text, out ulong v, out int consumed) && consumed == text.Length) return v; + return checked((ulong)ReadInt64(ref c)); + } + + public static int ReadInt32(ref JsmnCursor c) + { + var text = c.Text; + if (c.Token.Type == JsmnType.Primitive && Utf8Parser.TryParse(text, out int v, out int consumed) && consumed == text.Length) return v; + return checked((int)ReadInt64(ref c)); + } + + public static double ReadDouble(ref JsmnCursor c) + { + ref var t = ref c.Token; + var text = c.Text; + if (t.Type == JsmnType.Primitive || t.Type == JsmnType.String) + { + if (Utf8Parser.TryParse(text, out double v, out int consumed) && consumed == text.Length) return v; + if (text.Length == 4 && text[0] == (byte)'t') return 1; + if (text.Length == 5 && text[0] == (byte)'f') return 0; + // "NaN", "Infinity", "-Infinity": what Json.NET (and Utf8Json.WriteDouble) write for non-finite values + if (Utf8Json.TryParseNonFinite(text, out v)) return v; + if (t.Type == JsmnType.String) + { + var s = Utf8Json.DecodeString(text); + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out v)) return v; + } + } + throw Bind("number", ref c); + } + + public static float ReadSingle(ref JsmnCursor c) + { + var text = c.Text; + if (c.Token.Type == JsmnType.Primitive && Utf8Parser.TryParse(text, out float v, out int consumed) && consumed == text.Length) return v; + return (float)ReadDouble(ref c); + } + + public static decimal ReadDecimal(ref JsmnCursor c) + { + ref var t = ref c.Token; + var text = c.Text; + if (t.Type == JsmnType.Primitive || t.Type == JsmnType.String) + { + if (Utf8Parser.TryParse(text, out decimal v, out int consumed) && consumed == text.Length) return v; + if (text.Length == 4 && text[0] == (byte)'t') return 1; + if (text.Length == 5 && text[0] == (byte)'f') return 0; + if (Utf8Parser.TryParse(text, out double d, out consumed) && consumed == text.Length) return (decimal)d; + } + throw Bind("decimal", ref c); + } + + public static bool ReadBoolean(ref JsmnCursor c) + { + ref var t = ref c.Token; + var text = c.Text; + if (text.Length == 4 && text[0] == (byte)'t' && text[1] == (byte)'r' && text[2] == (byte)'u' && text[3] == (byte)'e') return true; + if (text.Length == 5 && text[0] == (byte)'f' && text[1] == (byte)'a') return false; + if (t.Type == JsmnType.Primitive || t.Type == JsmnType.String) + { + if (Utf8Parser.TryParse(text, out double d, out int consumed) && consumed == text.Length) return d != 0; + if (t.Type == JsmnType.String && bool.TryParse(Utf8Json.DecodeString(text), out bool b)) return b; + } + throw Bind("boolean", ref c); + } + + public static char ReadChar(ref JsmnCursor c) + { + ref var t = ref c.Token; + if (t.Type == JsmnType.String) + { + var s = Utf8Json.DecodeString(c.Text); + if (s.Length == 1) return s[0]; + throw Bind("single character", ref c); + } + return checked((char)ReadInt64(ref c)); + } + + public static DateTime ReadDateTime(ref JsmnCursor c) + { + ref var t = ref c.Token; + if (t.Type == JsmnType.String) + { + var s = Utf8Json.DecodeString(c.Text); + if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)) return dt; + } + throw Bind("DateTime", ref c); + } + + private static JsonRpcBindException Bind(string expected, ref JsmnCursor c) + { + var raw = JsmnTokenizer.RawJson(c.Doc, c.Token); + string text = raw.Length > 64 ? Utf8Json.ToStringUtf8(raw.Slice(0, 64)) + "..." : Utf8Json.ToStringUtf8(raw); + return new JsonRpcBindException("Could not convert " + text + " to " + expected + "."); + } + + // ------------------------------------------------------------------ boxed read + + public static object ReadObject(ref JsmnCursor c, Type type) + { + if (type == typeof(object)) return ReadDynamic(ref c); + var underlying = Nullable.GetUnderlyingType(type); + if (c.IsNull) + { + if (type.IsValueType && underlying == null) throw new JsonRpcBindException("Cannot convert null to " + type.Name + "."); + return null; + } + if (underlying != null) type = underlying; + + switch (Type.GetTypeCode(type)) + { + case TypeCode.String: return ReadString(ref c); + case TypeCode.Int32: return ReadInt32(ref c); + case TypeCode.Int64: return ReadInt64(ref c); + case TypeCode.Double: return ReadDouble(ref c); + case TypeCode.Single: return ReadSingle(ref c); + case TypeCode.Boolean: return ReadBoolean(ref c); + case TypeCode.Decimal: return ReadDecimal(ref c); + case TypeCode.Int16: return checked((short)ReadInt64(ref c)); + case TypeCode.UInt16: return checked((ushort)ReadInt64(ref c)); + case TypeCode.Byte: return checked((byte)ReadInt64(ref c)); + case TypeCode.SByte: return checked((sbyte)ReadInt64(ref c)); + case TypeCode.UInt32: return checked((uint)ReadInt64(ref c)); + case TypeCode.UInt64: return ReadUInt64(ref c); + case TypeCode.Char: return ReadChar(ref c); + case TypeCode.DateTime: return ReadDateTime(ref c); + } + if (type.IsEnum) + { + if (c.Token.Type == JsmnType.String) + { + var s = Utf8Json.DecodeString(c.Text); + try { return Enum.Parse(type, s, true); } + catch (Exception ex) { throw new JsonRpcBindException("Could not convert \"" + s + "\" to " + type.Name + ".", ex); } + } + return Enum.ToObject(type, ReadInt64(ref c)); + } + if (type == typeof(DateTimeOffset)) + { + var s = ReadString(ref c); + if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dto)) return dto; + throw Bind("DateTimeOffset", ref c); + } + if (type == typeof(TimeSpan)) + { + var s = ReadString(ref c); + if (TimeSpan.TryParse(s, CultureInfo.InvariantCulture, out var ts)) return ts; + throw Bind("TimeSpan", ref c); + } + if (type == typeof(Guid)) + { + var s = ReadString(ref c); + if (Guid.TryParse(s, out var g)) return g; + throw Bind("Guid", ref c); + } + if (type == typeof(Uri)) + { + var s = ReadString(ref c); + if (Uri.TryCreate(s, UriKind.RelativeOrAbsolute, out var u)) return u; + throw Bind("Uri", ref c); + } + if (type == typeof(byte[])) + { + if (c.Token.Type == JsmnType.String) + { + try { return Convert.FromBase64String(Utf8Json.DecodeString(c.Text)); } + catch (FormatException ex) { throw new JsonRpcBindException("Invalid base64 string.", ex); } + } + return ReadArray(ref c, typeof(byte)); + } + if (type.IsArray) + { + return ReadArray(ref c, type.GetElementType()); + } + var plan = TypePlan.For(type); + switch (plan.Kind) + { + case PlanKind.List: + { + var list = ReadList(ref c, plan.ElementType, plan); + return list; + } + case PlanKind.Dictionary: + return ReadDictionary(ref c, plan); + case PlanKind.Poco: + return ReadPoco(ref c, plan); + default: + throw new NotSupportedException("Type " + type.FullName + " is not supported by the built-in serializer."); + } + } + + private static object ReadDynamic(ref JsmnCursor c) + { + ref var t = ref c.Token; + switch (t.Type) + { + case JsmnType.String: + return Utf8Json.DecodeString(c.Text); + case JsmnType.Primitive: + { + var text = c.Text; + if (c.IsNull) return null; + if (text.Length == 4 && text[0] == (byte)'t') return true; + if (text.Length == 5 && text[0] == (byte)'f') return false; + if (Utf8Parser.TryParse(text, out long l, out int consumed) && consumed == text.Length) return l; + if (Utf8Parser.TryParse(text, out double d, out consumed) && consumed == text.Length) return d; + return Utf8Json.ToStringUtf8(text); + } + case JsmnType.Array: + { + var list = new List(t.Size); + int idx = c.Index + 1; + for (int i = 0; i < t.Size; i++) + { + var child = new JsmnCursor(c.Doc, c.Tokens, idx); + list.Add(ReadDynamic(ref child)); + idx = child.Next(); + } + return list; + } + case JsmnType.Object: + { + var dict = new Dictionary(t.Size); + int idx = c.Index + 1; + for (int i = 0; i < t.Size; i++) + { + var key = new JsmnCursor(c.Doc, c.Tokens, idx); + string name = Utf8Json.DecodeString(key.Text); + if (key.Token.Size > 0) + { + var val = new JsmnCursor(c.Doc, c.Tokens, idx + 1); + dict[name] = ReadDynamic(ref val); + } + idx = key.Next(); + } + return dict; + } + } + throw Bind("value", ref c); + } + + private static Array ReadArray(ref JsmnCursor c, Type elementType) + { + ref var t = ref c.Token; + if (t.Type != JsmnType.Array) throw Bind("array", ref c); + var arr = Array.CreateInstance(elementType, t.Size); + int idx = c.Index + 1; + for (int i = 0; i < t.Size; i++) + { + var child = new JsmnCursor(c.Doc, c.Tokens, idx); + arr.SetValue(ReadObject(ref child, elementType), i); + idx = child.Next(); + } + return arr; + } + + private static object ReadList(ref JsmnCursor c, Type elementType, TypePlan plan) + { + ref var t = ref c.Token; + if (t.Type != JsmnType.Array) throw Bind("array", ref c); + var list = plan.Create(); + int idx = c.Index + 1; + for (int i = 0; i < t.Size; i++) + { + var child = new JsmnCursor(c.Doc, c.Tokens, idx); + plan.Add(list, ReadObject(ref child, elementType)); + idx = child.Next(); + } + return list; + } + + private static object ReadDictionary(ref JsmnCursor c, TypePlan plan) + { + ref var t = ref c.Token; + if (t.Type != JsmnType.Object) throw Bind("object", ref c); + var dict = plan.Create(); + int idx = c.Index + 1; + for (int i = 0; i < t.Size; i++) + { + var key = new JsmnCursor(c.Doc, c.Tokens, idx); + if (key.Token.Size > 0) + { + string name = Utf8Json.DecodeString(key.Text); + var val = new JsmnCursor(c.Doc, c.Tokens, idx + 1); + object k = plan.KeyType == typeof(string) ? name : Convert.ChangeType(name, plan.KeyType, CultureInfo.InvariantCulture); + plan.DictionaryAdd(dict, k, ReadObject(ref val, plan.ElementType)); + } + idx = key.Next(); + } + return dict; + } + + private static object ReadPoco(ref JsmnCursor c, TypePlan plan) + { + ref var t = ref c.Token; + if (t.Type != JsmnType.Object) throw Bind("object", ref c); + var obj = plan.Create(); + int idx = c.Index + 1; + for (int i = 0; i < t.Size; i++) + { + var key = new JsmnCursor(c.Doc, c.Tokens, idx); + if (key.Token.Size > 0) + { + var member = plan.FindMember(key.Text, key.Token.Escaped); + if (member != null && member.Set != null) + { + var val = new JsmnCursor(c.Doc, c.Tokens, idx + 1); + member.Set(obj, ReadObject(ref val, member.Type)); + } + } + idx = key.Next(); + } + return obj; + } + + // ------------------------------------------------------------------ write + + public static void WriteObject(IBufferWriter w, object value, Type declaredType, int depth) + { + if (value == null) { Utf8Json.WriteNull(w); return; } + if (depth > MaxDepth) throw new JsonRpcBindException("Object graph is too deep (possible cycle)."); + var type = value.GetType(); + switch (Type.GetTypeCode(type)) + { + case TypeCode.String: Utf8Json.WriteString(w, (string)value); return; + case TypeCode.Int32: Utf8Json.WriteInt64(w, (int)value); return; + case TypeCode.Int64: Utf8Json.WriteInt64(w, (long)value); return; + case TypeCode.Double: Utf8Json.WriteDouble(w, (double)value); return; + case TypeCode.Single: Utf8Json.WriteSingle(w, (float)value); return; + case TypeCode.Boolean: Utf8Json.WriteBool(w, (bool)value); return; + case TypeCode.Decimal: Utf8Json.WriteDecimal(w, (decimal)value); return; + case TypeCode.Int16: Utf8Json.WriteInt64(w, (short)value); return; + case TypeCode.UInt16: Utf8Json.WriteInt64(w, (ushort)value); return; + case TypeCode.Byte: Utf8Json.WriteInt64(w, (byte)value); return; + case TypeCode.SByte: Utf8Json.WriteInt64(w, (sbyte)value); return; + case TypeCode.UInt32: Utf8Json.WriteInt64(w, (uint)value); return; + case TypeCode.UInt64: Utf8Json.WriteUInt64(w, (ulong)value); return; + case TypeCode.Char: Utf8Json.WriteChar(w, (char)value); return; + case TypeCode.DateTime: Utf8Json.WriteDateTime(w, (DateTime)value); return; + } + if (type.IsEnum) + { + Utf8Json.WriteInt64(w, Convert.ToInt64(value, CultureInfo.InvariantCulture)); + return; + } + if (value is DateTimeOffset dto) { Utf8Json.WriteDateTimeOffset(w, dto); return; } + if (value is TimeSpan ts) { Utf8Json.WriteQuotedAscii(w, ts.ToString("c", CultureInfo.InvariantCulture)); return; } + if (value is Guid g) { Utf8Json.WriteQuotedAscii(w, g.ToString("D")); return; } + if (value is Uri u) { Utf8Json.WriteString(w, u.OriginalString); return; } + if (value is byte[] bytes) { Utf8Json.WriteString(w, Convert.ToBase64String(bytes)); return; } + if (value is Exception ex && !(value is JsonRpcException)) { WriteObject(w, ExceptionInfo.From(ex), typeof(ExceptionInfo), depth + 1); return; } + if (value is IDictionary dict) + { + Utf8Json.WriteByte(w, (byte)'{'); + bool first = true; + foreach (DictionaryEntry e in dict) + { + if (!first) Utf8Json.WriteByte(w, (byte)','); + first = false; + Utf8Json.WritePropertyName(w, Convert.ToString(e.Key, CultureInfo.InvariantCulture)); + WriteObject(w, e.Value, typeof(object), depth + 1); + } + Utf8Json.WriteByte(w, (byte)'}'); + return; + } + var plan = TypePlan.For(type); + if (plan.Kind == PlanKind.Dictionary) + { + Utf8Json.WriteByte(w, (byte)'{'); + bool first = true; + foreach (var kv in plan.Enumerate(value)) + { + if (!first) Utf8Json.WriteByte(w, (byte)','); + first = false; + Utf8Json.WritePropertyName(w, Convert.ToString(kv.Key, CultureInfo.InvariantCulture)); + WriteObject(w, kv.Value, plan.ElementType, depth + 1); + } + Utf8Json.WriteByte(w, (byte)'}'); + return; + } + if (value is IEnumerable seq) + { + Utf8Json.WriteByte(w, (byte)'['); + bool first = true; + foreach (var item in seq) + { + if (!first) Utf8Json.WriteByte(w, (byte)','); + first = false; + WriteObject(w, item, typeof(object), depth + 1); + } + Utf8Json.WriteByte(w, (byte)']'); + return; + } + if (plan.Kind != PlanKind.Poco) throw new NotSupportedException("Type " + type.FullName + " is not supported by the built-in serializer."); + Utf8Json.WriteByte(w, (byte)'{'); + // Only readable members (write-only properties are skipped at plan time), so the comma follows the + // members actually emitted rather than their declaration index. + var members = plan.ReadableMembers; + for (int i = 0; i < members.Length; i++) + { + var m = members[i]; + if (i > 0) Utf8Json.WriteByte(w, (byte)','); + Utf8Json.WriteRaw(w, m.NameJson); + WriteObject(w, m.Get(value), m.Type, depth + 1); + } + Utf8Json.WriteByte(w, (byte)'}'); + } + + // ------------------------------------------------------------------ type plans + + internal enum PlanKind { Poco, List, Dictionary, Unsupported } + + internal sealed class MemberPlan + { + public string Name; + public byte[] NameUtf8; + public byte[] NameJson; // "name": + public Type Type; + public Func Get; + public Action Set; + } + + internal sealed class TypePlan + { + private static readonly ConcurrentDictionary Cache = new ConcurrentDictionary(); + + public PlanKind Kind; + public Type ElementType; + public Type KeyType; + public MemberPlan[] Members = Array.Empty(); + /// The subset of with a getter, in the same order: what the writer emits. + public MemberPlan[] ReadableMembers = Array.Empty(); + public Func Create; + public Action Add; + public Action DictionaryAdd; + public Func>> Enumerate; + + public static TypePlan For(Type type) => Cache.GetOrAdd(type, Build); + + public MemberPlan FindMember(ReadOnlySpan name, bool escaped) + { + if (escaped) + { + string decoded = Utf8Json.DecodeString(name); + foreach (var m in Members) if (string.Equals(m.Name, decoded, StringComparison.OrdinalIgnoreCase)) return m; + return null; + } + // exact first, then case-insensitive (Json.NET semantics) + foreach (var m in Members) if (name.SequenceEqual(m.NameUtf8)) return m; + foreach (var m in Members) if (Utf8Json.EqualsIgnoreAsciiCase(name, m.NameUtf8)) return m; + return null; + } + + private static TypePlan Build(Type type) + { + var plan = new TypePlan(); + + // dictionaries: Dictionary, IDictionary, IReadOnlyDictionary + var dictIface = FindGenericInterface(type, typeof(IDictionary<,>)); + if (dictIface != null) + { + var args = dictIface.GetGenericArguments(); + plan.Kind = PlanKind.Dictionary; + plan.KeyType = args[0]; + plan.ElementType = args[1]; + var concrete = type.IsInterface ? typeof(Dictionary<,>).MakeGenericType(args) : type; + plan.Create = MakeCreator(concrete); + plan.DictionaryAdd = CompileAdd(dictIface.GetMethod("Add"), args[0], args[1]); + plan.Enumerate = d => EnumerateDictionary((IEnumerable)d, args[0], args[1]); + return plan; + } + var roDictIface = FindGenericInterface(type, typeof(IReadOnlyDictionary<,>)); + if (roDictIface != null) + { + var args = roDictIface.GetGenericArguments(); + plan.Kind = PlanKind.Dictionary; + plan.KeyType = args[0]; + plan.ElementType = args[1]; + var concrete = typeof(Dictionary<,>).MakeGenericType(args); + plan.Create = MakeCreator(concrete); + plan.DictionaryAdd = CompileAdd(concrete.GetMethod("Add"), args[0], args[1]); + plan.Enumerate = d => EnumerateDictionary((IEnumerable)d, args[0], args[1]); + return plan; + } + + // lists: T[] handled by caller; List, IList, ICollection, IEnumerable, IReadOnlyList, IReadOnlyCollection, ISet, HashSet, ... + var enumIface = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>) ? type : FindGenericInterface(type, typeof(IEnumerable<>)); + if (enumIface != null && type != typeof(string)) + { + var elem = enumIface.GetGenericArguments()[0]; + plan.Kind = PlanKind.List; + plan.ElementType = elem; + Type concrete = type; + if (type.IsInterface) + { + var def = type.GetGenericTypeDefinition(); + if (def == typeof(ISet<>)) concrete = typeof(HashSet<>).MakeGenericType(elem); + else concrete = typeof(List<>).MakeGenericType(elem); + } + var addMethod = concrete.GetMethod("Add", new[] { elem }) ?? FindGenericInterface(concrete, typeof(ICollection<>))?.GetMethod("Add"); + if (addMethod == null || concrete.IsAbstract || concrete.GetConstructor(Type.EmptyTypes) == null) + { + plan.Kind = PlanKind.Unsupported; + return plan; + } + plan.Create = MakeCreator(concrete); + var target = Expression.Parameter(typeof(object), "list"); + var item = Expression.Parameter(typeof(object), "item"); + plan.Add = Expression.Lambda>( + Expression.Call(Expression.Convert(target, addMethod.DeclaringType), addMethod, Expression.Convert(item, elem)), target, item).Compile(); + return plan; + } + + if (type.IsInterface || type.IsAbstract || type.IsPrimitive || type == typeof(string)) + { + plan.Kind = PlanKind.Unsupported; + return plan; + } + + plan.Kind = PlanKind.Poco; + // Structs always have a default value (MakeCreator boxes `default(T)`; the boxed setters update the + // box in place), so only classes need an explicit parameterless constructor. + plan.Create = type.IsValueType || type.GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null) != null + ? MakeCreator(type) + : () => throw new NotSupportedException("Type " + type.FullName + " has no parameterless constructor."); + plan.Members = BuildMembers(type); + plan.ReadableMembers = plan.Members.Where(m => m.Get != null).ToArray(); + return plan; + } + + private static IEnumerable> EnumerateDictionary(IEnumerable dict, Type keyType, Type valueType) + { + var kvType = typeof(KeyValuePair<,>).MakeGenericType(keyType, valueType); + var keyProp = kvType.GetProperty("Key"); + var valueProp = kvType.GetProperty("Value"); + foreach (var kv in dict) + { + yield return new KeyValuePair(keyProp.GetValue(kv), valueProp.GetValue(kv)); + } + } + + private static Type FindGenericInterface(Type type, Type definition) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == definition) return type; + foreach (var i in type.GetInterfaces()) + { + if (i.IsGenericType && i.GetGenericTypeDefinition() == definition) return i; + } + return null; + } + + private static Func MakeCreator(Type type) + { + var ctor = type.GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null); + if (ctor == null && type.IsValueType) return Expression.Lambda>(Expression.Convert(Expression.New(type), typeof(object))).Compile(); + return Expression.Lambda>(Expression.Convert(Expression.New(ctor), typeof(object))).Compile(); + } + + private static MemberPlan[] BuildMembers(Type type) + { + var result = new List(); + var chain = new List(); + for (var t = type; t != null && t != typeof(object); t = t.BaseType) chain.Add(t); + chain.Reverse(); // base first, like Json.NET + + var seen = new HashSet(); + foreach (var t in chain) + { + var fields = t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .OrderBy(f => f.MetadataToken); + foreach (var f in fields) + { + if (f.IsDefined(typeof(NonSerializedAttribute), false) || !seen.Add(f.Name)) continue; + result.Add(MakeMember(f.Name, f.FieldType, + get: MakeGetter(type, f), + set: f.IsInitOnly ? null : MakeSetter(type, f))); + } + var props = t.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(p => p.GetIndexParameters().Length == 0) + .OrderBy(p => p.MetadataToken); + foreach (var p in props) + { + if (!seen.Add(p.Name)) continue; + var getter = p.GetGetMethod(false); + var setter = p.GetSetMethod(true); + result.Add(MakeMember(p.Name, p.PropertyType, + get: getter == null ? null : MakeGetter(type, p), + set: setter == null ? null : MakeSetter(type, p))); + } + } + return result.ToArray(); + } + + private static MemberPlan MakeMember(string name, Type memberType, Func get, Action set) + { + var json = new PooledByteBufferWriter(name.Length * 6 + 4); + Utf8Json.WritePropertyName(json, name); + var m = new MemberPlan + { + Name = name, + NameUtf8 = System.Text.Encoding.UTF8.GetBytes(name), + NameJson = json.ToArray(), + Type = memberType, + Get = get, + Set = set + }; + json.Dispose(); + return m; + } + + /// A compiled ((TDict)d).Add((TKey)k, (TValue)v): no MethodInfo.Invoke and no argument array per entry. + private static Action CompileAdd(MethodInfo add, Type keyType, Type valueType) + { + var d = Expression.Parameter(typeof(object), "d"); + var k = Expression.Parameter(typeof(object), "k"); + var v = Expression.Parameter(typeof(object), "v"); + var call = Expression.Call(Expression.Convert(d, add.DeclaringType), add, Expression.Convert(k, keyType), Expression.Convert(v, valueType)); + return Expression.Lambda>(call, d, k, v).Compile(); + } + + private static Func MakeGetter(Type owner, MemberInfo member) + { + var obj = Expression.Parameter(typeof(object), "obj"); + var access = Expression.MakeMemberAccess(Expression.Convert(obj, owner), member); + return Expression.Lambda>(Expression.Convert(access, typeof(object)), obj).Compile(); + } + + private static Action MakeSetter(Type owner, MemberInfo member) + { + var obj = Expression.Parameter(typeof(object), "obj"); + var val = Expression.Parameter(typeof(object), "val"); + var memberType = member is FieldInfo f ? f.FieldType : ((PropertyInfo)member).PropertyType; + if (owner.IsValueType) + { + // boxed struct: set through reflection so the box itself is updated + return member is FieldInfo fi + ? new Action((o, v) => fi.SetValue(o, v)) + : (o, v) => ((PropertyInfo)member).SetValue(o, v); + } + var access = Expression.MakeMemberAccess(Expression.Convert(obj, owner), member); + return Expression.Lambda>(Expression.Assign(access, Expression.Convert(val, memberType)), obj, val).Compile(); + } + } + } +} diff --git a/Json-Rpc/Jsmn/JsmnRequestReader.cs b/Json-Rpc/Jsmn/JsmnRequestReader.cs new file mode 100644 index 0000000..b5bbf49 --- /dev/null +++ b/Json-Rpc/Jsmn/JsmnRequestReader.cs @@ -0,0 +1,367 @@ +using System; +using System.Buffers; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc.Jsmn +{ + /// + /// The default envelope reader: tokenizes the document with and exposes the + /// method / params / id of each request as slices of the original bytes. Values are converted by the + /// owning serializer (directly from the tokens when it is the built-in ). + /// + public sealed class JsmnRequestReader : JsonRpcRequestReader + { + private static readonly byte[] KeyMethod = { (byte)'m', (byte)'e', (byte)'t', (byte)'h', (byte)'o', (byte)'d' }; + private static readonly byte[] KeyParams = { (byte)'p', (byte)'a', (byte)'r', (byte)'a', (byte)'m', (byte)'s' }; + private static readonly byte[] KeyId = { (byte)'i', (byte)'d' }; + private static readonly byte[] Version2 = { (byte)'2', (byte)'.', (byte)'0' }; + private static readonly byte[] KeyJsonRpc = { (byte)'j', (byte)'s', (byte)'o', (byte)'n', (byte)'r', (byte)'p', (byte)'c' }; + + private readonly JsonRpcSerializer _serializer; + private readonly JsmnSerializer _jsmn; + private readonly JsmnTokenizer _tok = new JsmnTokenizer(); + private ReadOnlyMemory _doc; + + private bool _isBatch; + private int _count; + private int[] _requests = new int[8]; + + private int _methodTok = -1, _paramsTok = -1, _idTok = -1, _versionTok = -1; + private JsonRpcParamsKind _paramsKind; + private int _paramCount; + private int[] _paramVals = new int[8]; + private int[] _paramKeys = new int[8]; + // Transient decode buffer for escaped names (member names, method, parameter names). Each caller + // consumes the returned span before the next reader call, so one buffer serves them all. + private byte[] _scratch; + // Storage for a normalized (lenient, single-quoted) id. The handler keeps the IdRaw span alive across + // MethodUtf8 / ParamNameUtf8 calls, so it must never share a buffer with the name decoding above. + private byte[] _idScratch; + + public JsmnRequestReader(JsonRpcSerializer serializer) + { + _serializer = serializer; + _jsmn = serializer as JsmnSerializer; + _tok.MaxDepth = serializer != null ? serializer.MaxDepth : JsmnTokenizer.DefaultMaxDepth; + } + + public JsmnTokenizer Tokenizer => _tok; + public override ReadOnlyMemory Document => _doc; + + /// True when the owning serializer is the built-in , so parameters bind straight from the tokens. + internal bool IsBuiltIn => _jsmn != null; + + // Typed parameter reads for the compiled built-in invoker (see RpcMethod): direct static calls, no cursor + // construction through a virtual generic method and no delegate in between. + internal JsmnCursor CursorAt(int i) => new JsmnCursor(_doc.Span, _tok.Tokens, _paramVals[i]); + internal static string ReadStringParam(JsmnRequestReader r, int i) { var c = r.CursorAt(i); return JsmnMapper.ReadString(ref c); } + internal static int ReadInt32Param(JsmnRequestReader r, int i) { var c = r.CursorAt(i); return JsmnMapper.ReadInt32(ref c); } + internal static long ReadInt64Param(JsmnRequestReader r, int i) { var c = r.CursorAt(i); return JsmnMapper.ReadInt64(ref c); } + internal static double ReadDoubleParam(JsmnRequestReader r, int i) { var c = r.CursorAt(i); return JsmnMapper.ReadDouble(ref c); } + internal static float ReadSingleParam(JsmnRequestReader r, int i) { var c = r.CursorAt(i); return JsmnMapper.ReadSingle(ref c); } + internal static bool ReadBooleanParam(JsmnRequestReader r, int i) { var c = r.CursorAt(i); return JsmnMapper.ReadBoolean(ref c); } + internal static decimal ReadDecimalParam(JsmnRequestReader r, int i) { var c = r.CursorAt(i); return JsmnMapper.ReadDecimal(ref c); } + internal static T ReadTypedParam(JsmnRequestReader r, int i) { var c = r.CursorAt(i); return JsmnReader.Read(ref c); } + /// Nullable primitives: the JSON null literal binds to null, anything else through the value reader. + internal static bool ParamIsNullLiteral(JsmnRequestReader r, int i) => JsmnTokenizer.IsNull(r._doc.Span, r._tok.Tokens[r._paramVals[i]]); + + public override bool TryParse(ReadOnlyMemory utf8Document, out string error) + { + _doc = utf8Document; + _tok.Lenient = _serializer.Lenient; + _methodTok = _paramsTok = _idTok = _versionTok = -1; + _paramCount = 0; + _paramsKind = JsonRpcParamsKind.Absent; + + int n = _tok.Parse(utf8Document.Span); + if (n < 0) + { + switch (n) + { + case JsmnTokenizer.ErrorPartial: + error = "Unexpected end of JSON input."; + break; + case JsmnTokenizer.ErrorDepth: + error = "Invalid JSON was received by the server. The maximum nesting depth of " + _tok.MaxDepth + " was exceeded."; + break; + default: + error = "Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text."; + break; + } + return false; + } + if (n == 0) + { + error = "Empty request."; + return false; + } + var tokens = _tok.Tokens; + ref var root = ref tokens[0]; + if (root.Type == JsmnType.Array) + { + _isBatch = true; + _count = root.Size; + if (_requests.Length < _count) _requests = new int[Math.Max(_count, _requests.Length * 2)]; + int idx = 1; + for (int i = 0; i < _count; i++) + { + _requests[i] = idx; + idx = _tok.Skip(idx); + } + } + else if (root.Type == JsmnType.Object) + { + _isBatch = false; + _count = 1; + _requests[0] = 0; + } + else + { + error = "A JSON-RPC request must be an object or an array of objects."; + return false; + } + error = null; + return true; + } + + public override bool IsBatch => _isBatch; + public override int Count => _count; + + public override bool Select(int index) + { + _methodTok = _paramsTok = _idTok = _versionTok = -1; + _paramCount = 0; + _paramsKind = JsonRpcParamsKind.Absent; + + var tokens = _tok.Tokens; + var doc = _doc.Span; + int obj = _requests[index]; + if (tokens[obj].Type != JsmnType.Object) return false; + + int k = obj + 1; + for (int m = 0; m < tokens[obj].Size; m++) + { + ref var key = ref tokens[k]; + if (key.Size == 0) { k = _tok.Skip(k); continue; } // key without a value; ignore + int val = k + 1; + var name = JsmnTokenizer.Slice(doc, key); + if (key.Escaped) + { + // A name written with escapes (m\u0065thod) is the same member as "method": decode before matching. The tokenizer has + // already validated the escapes, so Unescape cannot throw here. + if (_scratch == null || _scratch.Length < name.Length) _scratch = new byte[Math.Max(64, name.Length)]; + name = new ReadOnlySpan(_scratch, 0, Utf8Json.Unescape(name, _scratch)); + } + // the vocabulary is four names of three distinct lengths: select by length, then compare + switch (name.Length) + { + case 2: + if (Utf8Json.EqualsIgnoreAsciiCase(name, KeyId)) _idTok = val; + break; + case 6: + if ((name[0] | 0x20) == (byte)'m') { if (Utf8Json.EqualsIgnoreAsciiCase(name, KeyMethod)) _methodTok = val; } + else if (Utf8Json.EqualsIgnoreAsciiCase(name, KeyParams)) _paramsTok = val; + break; + case 7: + if (Utf8Json.EqualsIgnoreAsciiCase(name, KeyJsonRpc)) _versionTok = val; + break; + } + k = _tok.Skip(k); + } + + if (_paramsTok >= 0) + { + ref var p = ref tokens[_paramsTok]; + if (p.Type == JsmnType.Array) + { + _paramsKind = JsonRpcParamsKind.Array; + _paramCount = p.Size; + EnsureParamCapacity(_paramCount); + int idx = _paramsTok + 1; + for (int i = 0; i < _paramCount; i++) + { + _paramVals[i] = idx; + _paramKeys[i] = -1; + idx = _tok.Skip(idx); + } + } + else if (p.Type == JsmnType.Object) + { + _paramsKind = JsonRpcParamsKind.Object; + EnsureParamCapacity(p.Size); + int idx = _paramsTok + 1; + int n = 0; + for (int i = 0; i < p.Size; i++) + { + if (tokens[idx].Size > 0) + { + _paramKeys[n] = idx; + _paramVals[n] = idx + 1; + n++; + } + idx = _tok.Skip(idx); + } + _paramCount = n; + } + else if (JsmnTokenizer.IsNull(doc, p)) + { + _paramsKind = JsonRpcParamsKind.Absent; + } + else + { + _paramsKind = JsonRpcParamsKind.Invalid; + } + } + return true; + } + + private void EnsureParamCapacity(int n) + { + if (_paramVals.Length < n) + { + _paramVals = new int[Math.Max(n, _paramVals.Length * 2)]; + _paramKeys = new int[_paramVals.Length]; + } + } + + public override bool HasMethod => _methodTok >= 0 && _tok.Tokens[_methodTok].Type == JsmnType.String; + + public override JsonRpcVersionKind VersionKind + { + get + { + if (_versionTok < 0) return JsonRpcVersionKind.Absent; + ref var t = ref _tok.Tokens[_versionTok]; + if (t.Type != JsmnType.String) return JsonRpcVersionKind.Other; + var raw = JsmnTokenizer.Slice(_doc.Span, t); + if (t.Escaped) + { + if (_scratch == null || _scratch.Length < raw.Length) _scratch = new byte[Math.Max(64, raw.Length)]; + raw = new ReadOnlySpan(_scratch, 0, Utf8Json.Unescape(raw, _scratch)); + } + return raw.SequenceEqual(Version2) ? JsonRpcVersionKind.V2 : JsonRpcVersionKind.Other; + } + } + + public override ReadOnlySpan MethodUtf8 + { + get + { + if (_methodTok < 0) return default; + ref var t = ref _tok.Tokens[_methodTok]; + var raw = JsmnTokenizer.Slice(_doc.Span, t); + if (!t.Escaped) return raw; + if (_scratch == null || _scratch.Length < raw.Length) _scratch = new byte[Math.Max(64, raw.Length)]; + int n = Utf8Json.Unescape(raw, _scratch); + return new ReadOnlySpan(_scratch, 0, n); + } + } + + public override string Method + { + get + { + if (_methodTok < 0) return null; + ref var t = ref _tok.Tokens[_methodTok]; + if (t.Type != JsmnType.String) return null; + return Utf8Json.DecodeString(JsmnTokenizer.Slice(_doc.Span, t)); + } + } + + public override JsonRpcIdKind IdKind + { + get + { + if (_idTok < 0) return JsonRpcIdKind.Absent; + ref var t = ref _tok.Tokens[_idTok]; + if (t.Type == JsmnType.String) return JsonRpcIdKind.String; + if (t.Type != JsmnType.Primitive) return JsonRpcIdKind.Invalid; + return Utf8Json.ClassifyId(JsmnTokenizer.Slice(_doc.Span, t)); + } + } + + public override ReadOnlySpan IdRaw + { + get + { + if (_idTok < 0) return default; + ref var t = ref _tok.Tokens[_idTok]; + var doc = _doc.Span; + if (t.Type == JsmnType.String && doc[t.Start - 1] != (byte)'"') + { + // single-quoted (lenient) string: re-encode as a proper JSON string into the id's own buffer + using (var w = new PooledByteBufferWriter(t.End - t.Start + 8)) + { + Utf8Json.WriteString(w, Utf8Json.DecodeString(JsmnTokenizer.Slice(doc, t))); + var bytes = w.WrittenSpan; + if (_idScratch == null || _idScratch.Length < bytes.Length) _idScratch = new byte[Math.Max(64, bytes.Length)]; + bytes.CopyTo(_idScratch); + return new ReadOnlySpan(_idScratch, 0, bytes.Length); + } + } + return JsmnTokenizer.RawJson(doc, t); + } + } + + public override object IdValue => _idTok < 0 ? null : Utf8Json.IdToObject(IdRaw, IdKind); + + public override JsonRpcParamsKind ParamsKind => _paramsKind; + public override int ParamCount => _paramCount; + + public override ReadOnlySpan ParamNameUtf8(int i) + { + int k = _paramKeys[i]; + if (k < 0) return default; + ref var t = ref _tok.Tokens[k]; + var raw = JsmnTokenizer.Slice(_doc.Span, t); + if (!t.Escaped) return raw; + if (_scratch == null || _scratch.Length < raw.Length) _scratch = new byte[Math.Max(64, raw.Length)]; + int n = Utf8Json.Unescape(raw, _scratch); + return new ReadOnlySpan(_scratch, 0, n); + } + + public override ReadOnlySpan ParamRaw(int i) => JsmnTokenizer.RawJson(_doc.Span, _tok.Tokens[_paramVals[i]]); + + public override bool ParamIsNull(int i) => JsmnTokenizer.IsNull(_doc.Span, _tok.Tokens[_paramVals[i]]); + + public override T ReadParam(int i) + { + if (_jsmn != null) + { + var c = new JsmnCursor(_doc.Span, _tok.Tokens, _paramVals[i]); + return JsmnReader.Read(ref c); + } + return _serializer.Read(ParamRaw(i)); + } + + public override object ReadParam(int i, Type type) + { + if (_jsmn != null) + { + var c = new JsmnCursor(_doc.Span, _tok.Tokens, _paramVals[i]); + return JsmnMapper.ReadObject(ref c, type); + } + return _serializer.Read(ParamRaw(i), type); + } + + public override object ParamsValue + { + get + { + if (_paramsTok < 0) return null; + if (_jsmn != null) + { + var c = new JsmnCursor(_doc.Span, _tok.Tokens, _paramsTok); + return JsmnMapper.ReadObject(ref c, typeof(object)); + } + return _serializer.Read(JsmnTokenizer.RawJson(_doc.Span, _tok.Tokens[_paramsTok]), typeof(object)); + } + } + + public override void Release() + { + _tok.Release(); + _doc = default; + } + } +} diff --git a/Json-Rpc/Jsmn/JsmnSerializer.cs b/Json-Rpc/Jsmn/JsmnSerializer.cs new file mode 100644 index 0000000..9b9ea77 --- /dev/null +++ b/Json-Rpc/Jsmn/JsmnSerializer.cs @@ -0,0 +1,109 @@ +using System; +using System.Buffers; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc.Jsmn +{ + /// + /// The built-in, dependency-free serializer. Tokenizes with and binds values + /// with (primitives without boxing, POCOs and collections through cached + /// reflection plans). It is the default when no other serializer is configured. + /// + public sealed class JsmnSerializer : JsonRpcSerializer + { + public static readonly JsmnSerializer Instance = new JsmnSerializer(); + + [ThreadStatic] private static JsmnTokenizer _scratch; + [ThreadStatic] private static bool _scratchInUse; + + private readonly bool _lenient; + private readonly int _maxDepth; + + public JsmnSerializer() : this(false) { } + + /// Accept single-quoted strings, unquoted keys and trailing commas. + public JsmnSerializer(bool lenient) : this(lenient, JsmnTokenizer.DefaultMaxDepth) { } + + /// Accept single-quoted strings, unquoted keys and trailing commas. + /// + /// Maximum number of nested objects/arrays in a request or value (the root counts as one). Enforced by + /// the tokenizer before any hook or binding runs, so it also bounds every recursive reader downstream. + /// Default 64, the same as System.Text.Json and Json.NET. + /// + public JsmnSerializer(bool lenient, int maxDepth) + { + if (maxDepth < 1) throw new ArgumentOutOfRangeException(nameof(maxDepth), "maxDepth must be at least 1."); + _lenient = lenient; + _maxDepth = maxDepth; + } + + public override string Name => "jsmn"; + public override bool Lenient => _lenient; + + /// Maximum nesting depth accepted by the envelope reader and by . + public override int MaxDepth => _maxDepth; + + public override T Read(ReadOnlySpan utf8Json) + { + var tok = RentTokenizer(); + try + { + Tokenize(tok, utf8Json); + var c = new JsmnCursor(utf8Json, tok.Tokens, 0); + return JsmnReader.Read(ref c); + } + finally + { + ReturnTokenizer(tok); + } + } + + public override object Read(ReadOnlySpan utf8Json, Type type) + { + var tok = RentTokenizer(); + try + { + Tokenize(tok, utf8Json); + var c = new JsmnCursor(utf8Json, tok.Tokens, 0); + return JsmnMapper.ReadObject(ref c, type); + } + finally + { + ReturnTokenizer(tok); + } + } + + public override void Write(IBufferWriter output, T value) => JsmnWriter.Write(output, value); + + public override void Write(IBufferWriter output, object value, Type type) => JsmnMapper.WriteObject(output, value, type, 0); + + private void Tokenize(JsmnTokenizer tok, ReadOnlySpan json) + { + tok.Lenient = _lenient; + tok.MaxDepth = _maxDepth; + int n = tok.Parse(json); + if (n <= 0) + { + switch (n) + { + case JsmnTokenizer.ErrorPartial: throw new JsonRpcBindException("Unexpected end of JSON input."); + case JsmnTokenizer.ErrorDepth: throw new JsonRpcBindException("The maximum nesting depth of " + _maxDepth + " was exceeded."); + default: throw new JsonRpcBindException("Invalid JSON."); + } + } + } + + private static JsmnTokenizer RentTokenizer() + { + if (_scratchInUse) return new JsmnTokenizer(); + var t = _scratch ?? (_scratch = new JsmnTokenizer()); + _scratchInUse = true; + return t; + } + + private static void ReturnTokenizer(JsmnTokenizer t) + { + if (ReferenceEquals(t, _scratch)) _scratchInUse = false; + } + } +} diff --git a/Json-Rpc/Jsmn/JsmnTokenizer.cs b/Json-Rpc/Jsmn/JsmnTokenizer.cs new file mode 100644 index 0000000..14c8c62 --- /dev/null +++ b/Json-Rpc/Jsmn/JsmnTokenizer.cs @@ -0,0 +1,522 @@ +using System; +using System.Buffers; + +namespace AustinHarris.JsonRpc.Jsmn +{ + /// JSON token kinds, mirroring jsmn. + public enum JsmnType : byte + { + Undefined = 0, + Object = 1, + Array = 2, + String = 3, + /// number, true, false, null (and bare-word keys in lenient mode) + Primitive = 4 + } + + /// + /// A token: type plus [Start, End) byte range in the source. For strings the range excludes the quotes. + /// is the number of direct children (members for objects, elements for arrays, + /// 1 for a key that has a value). is the index of the enclosing token or -1. + /// + public struct JsmnToken + { + // The three byte-sized fields lead so the struct packs into 20 bytes instead of 24 (denser token arrays). + public JsmnType Type; + /// True when the string token contains a backslash escape and must be decoded. + public bool Escaped; + /// True when the token is an object member name (a string directly under an object). + public bool IsKey; + public int Start; + public int End; + public int Size; + public int Parent; + } + + /// + /// A safe port of the jsmn tokenizer (https://github.com/zserge/jsmn) over . + /// It records token boundaries without copying or decoding anything; a document of N tokens needs one + /// pooled array of N tokens and no other allocation. + /// + /// Unlike the original, the tokenizer validates the JSON grammar: exactly one root value, no trailing + /// commas or trailing content, literals that are exactly true/false/null, RFC 8259 + /// number syntax, no unescaped control characters and only well-formed UTF-8 and escape sequences inside + /// strings. Lenient mode additionally accepts single-quoted strings, unquoted (bare-word) member names and + /// trailing commas; everything else is validated the same way. Nesting is limited to + /// open containers, and closing a container is O(1) (the open containers are kept on an explicit stack). + /// + /// + /// Upstream: ported from jsmn master as of commit 25647e6 (2021-10-14, the latest at the time of writing; + /// v1.1.0 is from 2019). Open upstream pull requests were reviewed on 2026-09-23: #241 (memoise the parent per + /// depth level) is what the explicit stack here already does; #242/#98/#197 (a next-sibling link per token for + /// O(1) subtree skips) is not adopted because only walks the few tokens of an envelope + /// member; #248 (packed token fields) is worth revisiting for cache density; #194/#197 (RFC 8259 strict mode), + /// #168 (unterminated strings) and #102 (primitive root) are covered by the validation above. + /// + /// + public sealed class JsmnTokenizer + { + public const int ErrorNoMemory = -1; // never surfaced: the token array grows + public const int ErrorInvalid = -2; // invalid character or grammar inside the document + public const int ErrorPartial = -3; // the document ended early + public const int ErrorDepth = -4; // more than MaxDepth nested containers + + /// The default nesting limit (the same as System.Text.Json and Json.NET). + public const int DefaultMaxDepth = 64; + + // grammar state, as flags: what the next significant character may be + private const byte ExpectValue = 1; // a value may start here + private const byte ExpectKey = 2; // a member name may start here + private const byte AllowClose = 4; // the enclosing container may close here + private const byte ExpectComma = 8; // a ',' may follow + private const byte ExpectColon = 16; // a ':' must follow (after a member name) + private const byte ExpectEnd = 32; // the root value is complete; only whitespace may follow + + private JsmnToken[] _tokens = ArrayPool.Shared.Rent(64); + private int[] _stack = new int[16]; // indices of the open containers, innermost last + private int _tokNext; + private int _pos; + private int _maxDepth = DefaultMaxDepth; + public bool Lenient; + + public JsmnToken[] Tokens => _tokens; + public int TokenCount => _tokNext; + /// Where the last stopped: the document length on success, the offending byte on an error. + public int Position => _pos; + + /// Maximum number of nested containers (objects/arrays, the root counts as one). At least 1. + public int MaxDepth + { + get => _maxDepth; + set => _maxDepth = value < 1 ? 1 : value; + } + + public void Release() + { + if (_tokens != null && _tokens.Length > 4096) + { + ArrayPool.Shared.Return(_tokens); + _tokens = ArrayPool.Shared.Rent(64); + } + if (_stack.Length > 4096) _stack = new int[16]; + _tokNext = 0; + } + + /// + /// Tokenizes the whole document. Returns the token count (0 for a document that is empty or whitespace) + /// or a negative error code (, , ). + /// + public int Parse(ReadOnlySpan js) + { + // The scanner state lives in locals for the whole loop (position, token count, current parent, depth + // and the arrays) and is published to the fields once on exit, so the JIT can keep it in registers + // instead of reloading object fields after every helper call. The helpers are static scanners that + // return an end position or an error; every token is written here, fully, on allocation. + bool lenient = Lenient; + int maxDepth = _maxDepth; + var tokens = _tokens; + var stack = _stack; + int tokNext = 0; + int tokSuper = -1; + int depth = 0; + byte state = ExpectValue; + int pos = 0; + int result; + + for (; pos < js.Length; pos++) + { + byte c = js[pos]; + switch (c) + { + case (byte)'{': + case (byte)'[': + { + if ((state & ExpectValue) == 0) { result = ErrorInvalid; goto Done; } + if (depth >= maxDepth) { result = ErrorDepth; goto Done; } + if (tokNext == tokens.Length) tokens = Grow(tokNext); + if (tokSuper != -1) tokens[tokSuper].Size++; + ref var tok = ref tokens[tokNext]; + tok.Type = c == (byte)'{' ? JsmnType.Object : JsmnType.Array; + tok.Escaped = false; + tok.IsKey = false; + tok.Start = pos; + tok.End = -1; + tok.Size = 0; + tok.Parent = tokSuper; + tokSuper = tokNext++; + if (depth == stack.Length) + { + Array.Resize(ref stack, stack.Length * 2); + _stack = stack; + } + stack[depth++] = tokSuper; + state = c == (byte)'{' ? (byte)(ExpectKey | AllowClose) : (byte)(ExpectValue | AllowClose); + break; + } + case (byte)'}': + case (byte)']': + { + if ((state & AllowClose) == 0) { result = ErrorInvalid; goto Done; } // implies depth > 0 + ref var open = ref tokens[stack[depth - 1]]; + if (open.Type != (c == (byte)'}' ? JsmnType.Object : JsmnType.Array)) { result = ErrorInvalid; goto Done; } + open.End = pos + 1; + depth--; + tokSuper = open.Parent; + state = depth == 0 ? ExpectEnd : (byte)(ExpectComma | AllowClose); + break; + } + case (byte)'"': + case (byte)'\'': + { + if (c == (byte)'\'' && !lenient) { result = ErrorInvalid; goto Done; } + bool isKey; + if ((state & ExpectValue) != 0) isKey = false; + else if ((state & ExpectKey) != 0) isKey = true; + else { result = ErrorInvalid; goto Done; } + int end = ScanString(js, pos, c, lenient, out bool escaped); + if (end < 0) { result = end; goto Done; } + if (tokNext == tokens.Length) tokens = Grow(tokNext); + if (tokSuper != -1) tokens[tokSuper].Size++; + ref var tok = ref tokens[tokNext++]; + tok.Type = JsmnType.String; + tok.Escaped = escaped; + tok.IsKey = isKey; + tok.Start = pos + 1; + tok.End = end; + tok.Size = 0; + tok.Parent = tokSuper; + state = isKey ? ExpectColon : depth == 0 ? ExpectEnd : (byte)(ExpectComma | AllowClose); + pos = end; + break; + } + case (byte)'\t': + case (byte)'\r': + case (byte)'\n': + case (byte)' ': + break; + case (byte)':': + { + if ((state & ExpectColon) == 0) { result = ErrorInvalid; goto Done; } + tokSuper = tokNext - 1; // the member name: its value becomes its child + state = ExpectValue; + break; + } + case (byte)',': + { + if ((state & ExpectComma) == 0) { result = ErrorInvalid; goto Done; } // implies depth > 0 + int container = stack[depth - 1]; + tokSuper = container; + state = tokens[container].Type == JsmnType.Object ? ExpectKey : ExpectValue; + if (lenient) state |= AllowClose; // trailing comma + break; + } + default: + { + int end; + bool isKey; + if ((state & ExpectValue) != 0) + { + end = ScanPrimitive(js, pos); + isKey = false; + } + else if ((state & ExpectKey) != 0 && lenient) + { + end = ScanBareKey(js, pos); + isKey = true; + } + else { result = ErrorInvalid; goto Done; } + if (end < 0) { result = end; goto Done; } + if (tokNext == tokens.Length) tokens = Grow(tokNext); + if (tokSuper != -1) tokens[tokSuper].Size++; + ref var tok = ref tokens[tokNext++]; + tok.Type = JsmnType.Primitive; + tok.Escaped = false; + tok.IsKey = isKey; + tok.Start = pos; + tok.End = end; + tok.Size = 0; + tok.Parent = tokSuper; + state = isKey ? ExpectColon : depth == 0 ? ExpectEnd : (byte)(ExpectComma | AllowClose); + pos = end - 1; + break; + } + } + } + + if (depth != 0) result = ErrorPartial; + else if (state == ExpectEnd) result = tokNext; + else result = 0; // only the initial state can remain at depth 0: nothing but whitespace was seen + + Done: + _pos = pos; + _tokNext = tokNext; + return result; + } + + /// Doubles the token array (cold path); the caller reloads its local reference from the return value. + private JsmnToken[] Grow(int count) + { + var old = _tokens; + var bigger = ArrayPool.Shared.Rent(old.Length * 2); + Array.Copy(old, bigger, count); + ArrayPool.Shared.Return(old); + _tokens = bigger; + return bigger; + } + + /// True for the characters that may follow a literal or number. + private static bool IsDelimiter(byte c) + { + switch (c) + { + case (byte)' ': case (byte)'\t': case (byte)'\r': case (byte)'\n': + case (byte)',': case (byte)']': case (byte)'}': case (byte)':': + return true; + default: + return false; + } + } + + /// + /// A literal (true/false/null) or a number starting at , validated against the JSON + /// grammar and followed by a delimiter or the end. Returns the end (exclusive) or an error. + /// + private static int ScanPrimitive(ReadOnlySpan js, int start) + { + int end; + switch (js[start]) + { + case (byte)'t': end = MatchLiteral(js, start, (byte)'r', (byte)'u', (byte)'e', 0); break; + case (byte)'f': end = MatchLiteral(js, start, (byte)'a', (byte)'l', (byte)'s', (byte)'e'); break; + case (byte)'n': end = MatchLiteral(js, start, (byte)'u', (byte)'l', (byte)'l', 0); break; + case (byte)'-': + case (byte)'0': case (byte)'1': case (byte)'2': case (byte)'3': case (byte)'4': + case (byte)'5': case (byte)'6': case (byte)'7': case (byte)'8': case (byte)'9': + end = ScanNumber(js, start); + break; + default: + return ErrorInvalid; + } + if (end < 0) return end; + if (end < js.Length && !IsDelimiter(js[end])) return ErrorInvalid; // e.g. truX, 01, 1x + return end; + } + + /// Matches the rest of a literal after its first byte (c4 == 0 for a 4-byte literal). Returns the end or an error. + private static int MatchLiteral(ReadOnlySpan js, int start, byte c1, byte c2, byte c3, byte c4) + { + int len = c4 == 0 ? 4 : 5; + int avail = js.Length - start; + if (avail < len) + { + // the document ends inside the literal: partial if what is there matches, invalid otherwise + if (avail >= 2 && js[start + 1] != c1) return ErrorInvalid; + if (avail >= 3 && js[start + 2] != c2) return ErrorInvalid; + if (avail >= 4 && js[start + 3] != c3) return ErrorInvalid; + return ErrorPartial; + } + if (js[start + 1] != c1 || js[start + 2] != c2 || js[start + 3] != c3) return ErrorInvalid; + if (len == 5 && js[start + 4] != c4) return ErrorInvalid; + return start + len; + } + + /// -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? — returns the end index or an error. + private static int ScanNumber(ReadOnlySpan js, int start) + { + int i = start; + int len = js.Length; + if (js[i] == (byte)'-') + { + i++; + if (i >= len) return ErrorPartial; + } + uint d = (uint)(js[i] - (byte)'0'); + if (d == 0) + { + i++; + } + else if (d <= 9) + { + i++; + while (i < len && (uint)(js[i] - (byte)'0') <= 9) i++; + } + else + { + return ErrorInvalid; + } + if (i < len && js[i] == (byte)'.') + { + i++; + if (i >= len) return ErrorPartial; + if ((uint)(js[i] - (byte)'0') > 9) return ErrorInvalid; + while (i < len && (uint)(js[i] - (byte)'0') <= 9) i++; + } + if (i < len && (js[i] == (byte)'e' || js[i] == (byte)'E')) + { + i++; + if (i >= len) return ErrorPartial; + if (js[i] == (byte)'+' || js[i] == (byte)'-') + { + i++; + if (i >= len) return ErrorPartial; + } + if ((uint)(js[i] - (byte)'0') > 9) return ErrorInvalid; + while (i < len && (uint)(js[i] - (byte)'0') <= 9) i++; + } + return i; + } + + /// Lenient mode only: an unquoted member name, read up to the next delimiter (printable ASCII). Returns the end (exclusive) or an error. + private static int ScanBareKey(ReadOnlySpan js, int start) + { + int i = start; + for (; i < js.Length; i++) + { + byte c = js[i]; + if (IsDelimiter(c)) break; + if (c < 32 || c >= 127) return ErrorInvalid; + } + return i; + } + + /// + /// A quoted string whose opening quote is at . Validates escapes (including + /// surrogate pairs), rejects unescaped control characters and malformed UTF-8. Returns the index of the + /// closing quote or an error; reports whether the contents need decoding. + /// + private static int ScanString(ReadOnlySpan js, int start, byte quote, bool lenient, out bool escaped) + { + bool seenEscape = false; + for (int i = start + 1; i < js.Length; i++) + { + byte c = js[i]; + if (c == quote) + { + escaped = seenEscape; + return i; + } + if (c == (byte)'\\') + { + seenEscape = true; + i++; + if (i >= js.Length) { escaped = true; return ErrorPartial; } + switch (js[i]) + { + case (byte)'"': case (byte)'/': case (byte)'\\': case (byte)'b': + case (byte)'f': case (byte)'r': case (byte)'n': case (byte)'t': + break; + case (byte)'\'': + if (!lenient) { escaped = true; return ErrorInvalid; } + break; + case (byte)'u': + { + if (i + 4 >= js.Length) { escaped = true; return ErrorPartial; } + int cp = Hex4(js, i + 1); + if (cp < 0) { escaped = true; return ErrorInvalid; } + i += 4; + if (cp >= 0xD800 && cp <= 0xDBFF) + { + // a high surrogate must be followed by an escaped low surrogate + if (i + 6 >= js.Length) { escaped = true; return ErrorPartial; } + if (js[i + 1] != (byte)'\\' || js[i + 2] != (byte)'u') { escaped = true; return ErrorInvalid; } + int low = Hex4(js, i + 3); + if (low < 0xDC00 || low > 0xDFFF) { escaped = true; return ErrorInvalid; } + i += 6; + } + else if (cp >= 0xDC00 && cp <= 0xDFFF) + { + escaped = true; + return ErrorInvalid; // lone low surrogate + } + break; + } + default: + escaped = true; + return ErrorInvalid; + } + continue; + } + if ((uint)(c - 0x20) < 0x60) continue; // printable ASCII: the common case + if (c < 0x20) { escaped = seenEscape; return ErrorInvalid; } // unescaped control character + int n = Utf8SequenceLength(js, i); // c >= 0x80: validate the multi-byte sequence + if (n < 0) { escaped = seenEscape; return n; } + i += n - 1; + } + escaped = seenEscape; + return ErrorPartial; + } + + /// Four hex digits at (caller guarantees they exist), or -1. + private static int Hex4(ReadOnlySpan js, int at) + { + int v = 0; + for (int k = 0; k < 4; k++) + { + int b = js[at + k]; + int d = b >= '0' && b <= '9' ? b - '0' : b >= 'a' && b <= 'f' ? b - 'a' + 10 : b >= 'A' && b <= 'F' ? b - 'A' + 10 : -1; + if (d < 0) return -1; + v = (v << 4) | d; + } + return v; + } + + /// + /// Length of the well-formed UTF-8 sequence starting at (a lead byte >= 0x80), per + /// RFC 3629 (no overlongs, no surrogates, nothing above U+10FFFF); when it is + /// malformed, when the document ends inside it. + /// + private static int Utf8SequenceLength(ReadOnlySpan js, int i) + { + byte b0 = js[i]; + int need; + byte lo = 0x80, hi = 0xBF; + if (b0 >= 0xC2 && b0 <= 0xDF) need = 1; + else if (b0 == 0xE0) { need = 2; lo = 0xA0; } + else if ((b0 >= 0xE1 && b0 <= 0xEC) || b0 == 0xEE || b0 == 0xEF) need = 2; + else if (b0 == 0xED) { need = 2; hi = 0x9F; } + else if (b0 == 0xF0) { need = 3; lo = 0x90; } + else if (b0 >= 0xF1 && b0 <= 0xF3) need = 3; + else if (b0 == 0xF4) { need = 3; hi = 0x8F; } + else return ErrorInvalid; // continuation byte, overlong lead (C0/C1) or out of range (F5..FF) + + if (i + need >= js.Length) return ErrorPartial; + byte b1 = js[i + 1]; + if (b1 < lo || b1 > hi) return ErrorInvalid; + for (int k = 2; k <= need; k++) + { + byte b = js[i + k]; + if (b < 0x80 || b > 0xBF) return ErrorInvalid; + } + return need + 1; + } + + /// Index of the first token after the subtree rooted at . + public int Skip(int index) + { + int need = 1; + int i = index; + while (need > 0) + { + need += _tokens[i].Size; + need--; + i++; + } + return i; + } + + /// Raw bytes of a token: for strings the contents without quotes, otherwise the literal text. + public static ReadOnlySpan Slice(ReadOnlySpan js, in JsmnToken tok) => js.Slice(tok.Start, tok.End - tok.Start); + + /// Raw JSON of a token including quotes for strings. + public static ReadOnlySpan RawJson(ReadOnlySpan js, in JsmnToken tok) + { + if (tok.Type == JsmnType.String) return js.Slice(tok.Start - 1, tok.End - tok.Start + 2); + return js.Slice(tok.Start, tok.End - tok.Start); + } + + public static bool IsNull(ReadOnlySpan js, in JsmnToken tok) + { + return tok.Type == JsmnType.Primitive && tok.End - tok.Start == 4 && js[tok.Start] == (byte)'n'; + } + } +} diff --git a/Json-Rpc/JsonRequest.cs b/Json-Rpc/JsonRequest.cs index e68c1ff..fee5518 100644 --- a/Json-Rpc/JsonRequest.cs +++ b/Json-Rpc/JsonRequest.cs @@ -1,11 +1,9 @@ -using Newtonsoft.Json; - namespace AustinHarris.JsonRpc { /// - /// Represents a JsonRpc request + /// Represents a JsonRpc request. Only materialised for pre/post-process handlers and ; + /// the fast path binds parameters straight from the request bytes. /// - [JsonObject(MemberSerialization.OptIn)] public class JsonRequest { public JsonRequest() @@ -19,19 +17,17 @@ public JsonRequest(string method, object pars, object id) Id = id; } - [JsonProperty("jsonrpc")] public string JsonRpc { get { return "2.0"; } } - [JsonProperty("method")] public string Method { get; set; } - [JsonProperty("params")] + /// The params value in the serializer's own object model (JArray/JObject for Json.NET, JsonElement for System.Text.Json, List/Dictionary for the built-in serializer). public object Params { get; set; } - [JsonProperty("id")] + /// The id: a long, a string, or null. public object Id { get; set; } } } diff --git a/Json-Rpc/JsonResponse.cs b/Json-Rpc/JsonResponse.cs index 941de82..3c3d9ab 100644 --- a/Json-Rpc/JsonResponse.cs +++ b/Json-Rpc/JsonResponse.cs @@ -1,46 +1,30 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Newtonsoft.Json; - namespace AustinHarris.JsonRpc { /// - /// Represents a Json Rpc Response + /// Represents a Json Rpc Response. Materialised only for post-process handlers and . /// - [JsonObject(MemberSerialization.OptIn)] public class JsonResponse { - [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "jsonrpc")] public string JsonRpc { get; set; } = "2.0"; - [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "result")] public object Result { get; set; } - [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error")] public JsonRpcException Error { get; set; } - [JsonProperty(PropertyName = "id")] public object Id { get; set; } } /// - /// Represents a Json Rpc Response + /// Represents a Json Rpc Response with a typed result (used by clients). /// - [JsonObject(MemberSerialization.OptIn)] public class JsonResponse { - [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "jsonrpc")] public string JsonRpc { get; set; } = "2.0"; - [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "result")] public T Result { get; set; } - [JsonProperty(NullValueHandling = NullValueHandling.Ignore, PropertyName = "error")] public JsonRpcException Error { get; set; } - [JsonProperty(PropertyName = "id")] public object Id { get; set; } } } diff --git a/Json-Rpc/JsonResponseErrorObject.cs b/Json-Rpc/JsonResponseErrorObject.cs index c23a241..a80a91c 100644 --- a/Json-Rpc/JsonResponseErrorObject.cs +++ b/Json-Rpc/JsonResponseErrorObject.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Newtonsoft.Json; +using System; namespace AustinHarris.JsonRpc { @@ -10,16 +6,15 @@ namespace AustinHarris.JsonRpc /// 5.1 Error object /// /// When a rpc call encounters an error, the Response Object MUST contain the error member with a value that is a Object with the following members: - /// codeA Number that indicates the error type that occurred. - /// This MUST be an integer.messageA String providing a short description of the error. - /// The message SHOULD be limited to a concise single sentence.dataA Primitive or Structured value that contains additional information about the error. - /// This may be omitted. - /// The value of this member is defined by the Server (e.g. detailed error information, nested errors etc.). - /// The error codes from and including -32768 to -32000 are reserved for pre-defined errors. Any code within this range, but not defined explicitly below is reserved for future use. The error codes are nearly the same as those suggested for XML-RPC at the following url: http://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php + /// code A Number that indicates the error type that occurred. This MUST be an integer. + /// message A String providing a short description of the error. The message SHOULD be limited to a concise single sentence. + /// data A Primitive or Structured value that contains additional information about the error. This may be omitted. + /// The value of this member is defined by the Server (e.g. detailed error information, nested errors etc.). /// - /// code message meaning + /// The error codes from and including -32768 to -32000 are reserved for pre-defined errors. /// - /// -32700 Parse error Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. + /// code message meaning + /// -32700 Parse error Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. /// -32600 Invalid Request The JSON sent is not a valid Request object. /// -32601 Method not found The method does not exist / is not available. /// -32602 Invalid params Invalid method parameter(s). @@ -27,21 +22,21 @@ namespace AustinHarris.JsonRpc /// -32000 to -32099 Server error Reserved for implementation-defined server-errors. /// /// The remainder of the space is available for application defined errors. + /// + /// On the wire the object is always written as {"code":..,"message":..,"data":..}; when is an + /// it is written as a . /// [Serializable] - [JsonObject(MemberSerialization.OptIn)] public class JsonRpcException : System.ApplicationException { - [JsonProperty] public int code { get; set; } - [JsonProperty] public string message { get; set; } - [JsonProperty] public object data { get; set; } public JsonRpcException(int code, string message, object data) + : base(message) { this.code = code; this.message = message; diff --git a/Json-Rpc/JsonRpcContext.cs b/Json-Rpc/JsonRpcContext.cs index e9a5ee6..aac887c 100644 --- a/Json-Rpc/JsonRpcContext.cs +++ b/Json-Rpc/JsonRpcContext.cs @@ -1,15 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace AustinHarris.JsonRpc +namespace AustinHarris.JsonRpc { /// /// Provides access to a context specific to each JsonRpc method invocation. /// This is a convienence class that wraps calls to Context specific methods on AustinHarris.JsonRpc.Handler /// - public class JsonRpcContext + public sealed class JsonRpcContext { private JsonRpcContext(object value) { @@ -40,5 +35,15 @@ public static JsonRpcContext Current() { return new JsonRpcContext(Handler.RpcContext()); } + + /// + /// The id of the request being served, as an owned snapshot (see ); absent + /// for a notification and outside an invocation. Same as ; the raw bytes + /// are available from . Must be called on the thread running the method. + /// + public static JsonRpcRequestId CurrentRequestId() + { + return Handler.RpcRequestId(); + } } } diff --git a/Json-Rpc/JsonRpcProcessor.Async.cs b/Json-Rpc/JsonRpcProcessor.Async.cs new file mode 100644 index 0000000..2a9fac3 --- /dev/null +++ b/Json-Rpc/JsonRpcProcessor.Async.cs @@ -0,0 +1,287 @@ +using System; +using System.Buffers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc +{ + public static partial class JsonRpcProcessor + { + /// + /// Processes a document asynchronously. Keep the request bytes immutable and valid, and the output + /// writer valid and exclusive, until completion. Successful inline completion returns Task.CompletedTask. + /// Cancellation waits for the running method to terminate and discards all staged output. + /// + public static Task ProcessAsync(string sessionId, ReadOnlySequence request, IBufferWriter output, + object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) + { + if (request.IsSingleSegment) return ProcessAsync(sessionId, request.First, output, context, serializer, cancellationToken); + var scratch = AsyncScratch.Rent(); + int length; + byte[] buffer; + try + { + length = checked((int)request.Length); + buffer = scratch.Input(length); + request.CopyTo(buffer); + } + catch { scratch.Return(); throw; } + return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, length), output, context, serializer, cancellationToken, scratch); + } + + /// + /// Processes borrowed UTF-8 memory. Keep the bytes immutable and valid, and the output writer valid + /// and exclusive, until the task completes. Cancellation commits no response bytes. + /// + public static Task ProcessAsync(string sessionId, ReadOnlyMemory request, IBufferWriter output, + object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) + { + return StartAsyncDocument(sessionId, request, output, context, serializer, cancellationToken, AsyncScratch.Rent()); + } + + /// + /// Copies a UTF-8 span into owned pooled storage before processing. The output writer must stay valid + /// and exclusive until completion; the caller may reuse the input span as soon as this method returns. + /// + public static Task ProcessAsync(string sessionId, ReadOnlySpan request, IBufferWriter output, + object context = null, JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) + { + var scratch = AsyncScratch.Rent(); + byte[] buffer; + try + { + buffer = scratch.Input(request.Length); + request.CopyTo(buffer); + } + catch { scratch.Return(); throw; } + return StartAsyncDocument(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, cancellationToken, scratch); + } + + /// Processes a string asynchronously on the selected session, returning an empty string for notifications. + public static async Task ProcessAsync(string sessionId, string jsonRpc, object context = null, + JsonRpcSerializer serializer = null, CancellationToken cancellationToken = default) + { + var input = Encoding.UTF8.GetBytes(jsonRpc); + using (var output = new PooledByteBufferWriter()) + { + await ProcessAsync(sessionId, new ReadOnlyMemory(input), output, context, serializer, cancellationToken).ConfigureAwait(false); + return output.ToString(); + } + } + + /// Processes a string asynchronously on the default session; notifications return an empty string. + public static Task ProcessAsync(string jsonRpc, object context = null, CancellationToken cancellationToken = default) + { + return ProcessAsync(Handler.DefaultSessionId(), jsonRpc, context, null, cancellationToken); + } + + private static Task StartAsyncDocument(string sessionId, ReadOnlyMemory document, IBufferWriter destination, + object context, JsonRpcSerializer serializer, CancellationToken token, AsyncScratch scratch) + { + bool transferred = false; + try + { + token.ThrowIfCancellationRequested(); + var handler = Handler.GetSessionHandler(sessionId); + serializer = serializer ?? handler.Serializer ?? Config.Serializer; + scratch.DocumentLength = document.Length; + var reader = scratch.GetReader(serializer); + var output = scratch.Output; + if (!reader.TryParse(document, out var parseError)) + { + var ex = new JsonRpcException(-32700, "Parse error", parseError); + if (handler.HasParseErrorHandler) ex = handler.ProcessParseException(Utf8Json.ToStringUtf8(document.Span), ex); + Handler.WriteErrorEnvelope(output, serializer, ex, default); + } + else if (!reader.IsBatch) + { + var pending = handler.HandleRequestAsync(reader, 0, serializer, output, context, token); + if (!pending.IsCompleted) + { + var completion = FinishSingleDocumentAsync(pending, scratch, destination, token); + transferred = true; + return completion; + } + pending.GetAwaiter().GetResult(); + } + else if (reader.Count == 0) + { + var ex = new JsonRpcException(-32600, "Invalid Request", "Batch of calls was empty."); + if (handler.HasParseErrorHandler) ex = handler.ProcessParseException(Utf8Json.ToStringUtf8(document.Span), ex); + Handler.WriteErrorEnvelope(output, serializer, ex, default); + } + else + { + output.Write((byte)'['); + int written = 0; + for (int i = 0; i < reader.Count; i++) + { + token.ThrowIfCancellationRequested(); + int before = output.WrittenCount; + if (written > 0) output.Write((byte)','); + var pending = handler.HandleRequestAsync(reader, i, serializer, output, context, token); + if (!pending.IsCompleted) + { + var completion = FinishBatchDocumentAsync(pending, i, before, written, handler, scratch, serializer, context, destination, token); + transferred = true; + return completion; + } + if (pending.GetAwaiter().GetResult()) written++; + else output.Rewind(before); + } + if (written == 0) output.Rewind(0); + else output.Write((byte)']'); + } + CommitAsyncDocument(scratch, destination, token); + return Task.CompletedTask; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return Task.FromCanceled(token); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + finally { if (!transferred) scratch.Return(); } + } + + private static async Task FinishSingleDocumentAsync(ValueTask pending, AsyncScratch scratch, IBufferWriter destination, CancellationToken token) + { + try + { + await pending.ConfigureAwait(false); + CommitAsyncDocument(scratch, destination, token); + } + finally { scratch.Return(); } + } + + private static async Task FinishBatchDocumentAsync(ValueTask pending, int index, int before, int written, + Handler handler, AsyncScratch scratch, JsonRpcSerializer serializer, object context, IBufferWriter destination, CancellationToken token) + { + try + { + var output = scratch.Output; + var reader = scratch.Reader; + if (await pending.ConfigureAwait(false)) written++; + else output.Rewind(before); + for (int i = index + 1; i < reader.Count; i++) + { + token.ThrowIfCancellationRequested(); + before = output.WrittenCount; + if (written > 0) output.Write((byte)','); + if (await handler.HandleRequestAsync(reader, i, serializer, output, context, token).ConfigureAwait(false)) written++; + else output.Rewind(before); + } + if (written == 0) output.Rewind(0); + else output.Write((byte)']'); + CommitAsyncDocument(scratch, destination, token); + } + finally { scratch.Return(); } + } + + private static void CommitAsyncDocument(AsyncScratch scratch, IBufferWriter destination, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (scratch.Output.WrittenCount != 0) scratch.Output.CopyTo(destination); + } + + // Transferable exclusive leases. A bounded shared array avoids thread-affine ownership and + // linked-list node allocations. Nothing from synchronous Scratch is used here. + private sealed class AsyncScratch + { + private const int CapacityLimit = 64 * 1024; + private static readonly AsyncScratch[] Pool = new AsyncScratch[64]; + private static int _count; + private byte[] _input; + private JsonRpcSerializer _readerOwner; + internal JsonRpcRequestReader Reader; + internal PooledByteBufferWriter Output = new PooledByteBufferWriter(4096); + internal int DocumentLength; + private bool _readerLeased; + + internal static AsyncScratch Rent() + { + lock (Pool) + { + if (_count > 0) + { + var scratch = Pool[--_count]; + Pool[_count] = null; + return scratch; + } + } + return new AsyncScratch(); + } + + internal byte[] Input(int length) + { + if (_input == null || _input.Length < length) + { + if (_input != null) ArrayPool.Shared.Return(_input); + _input = ArrayPool.Shared.Rent(length); + } + return _input; + } + + internal JsonRpcRequestReader GetReader(JsonRpcSerializer serializer) + { + if (!ReferenceEquals(_readerOwner, serializer) || Reader == null) + { + Reader = serializer.CreateReader(); + _readerOwner = serializer; + } + _readerLeased = true; + return Reader; + } + + internal void Return() + { + bool reusable = false; + try + { + if (_readerLeased) + { + _readerLeased = false; + Reader.Release(); + } + reusable = true; + } + finally + { + if (DocumentLength > CapacityLimit) { Reader = null; _readerOwner = null; } + DocumentLength = 0; + if (_input != null && _input.Length > CapacityLimit) + { + ArrayPool.Shared.Return(_input); + _input = null; + } + if (Output.WrittenSegment.Array.Length > CapacityLimit) + { + Output.Dispose(); + Output = new PooledByteBufferWriter(4096); + } + else Output.Clear(); + bool retained = false; + if (reusable) + { + lock (Pool) + { + if (_count < Pool.Length) { Pool[_count++] = this; retained = true; } + } + } + if (!retained) + { + if (_input != null) ArrayPool.Shared.Return(_input); + _input = null; + Output.Dispose(); + Reader = null; + _readerOwner = null; + } + } + } + } + } +} diff --git a/Json-Rpc/JsonRpcProcessor.cs b/Json-Rpc/JsonRpcProcessor.cs index 5020e88..1230642 100644 --- a/Json-Rpc/JsonRpcProcessor.cs +++ b/Json-Rpc/JsonRpcProcessor.cs @@ -1,28 +1,102 @@ -using System; -using System.Globalization; -using System.Threading; -using System.Threading.Tasks; -using System.Reflection; -using System.IO; -using System.Collections.Generic; -using System.Linq; +using System; +using System.Buffers; using System.Text; -using Newtonsoft.Json; +using System.Threading.Tasks; +using AustinHarris.JsonRpc.Serialization; namespace AustinHarris.JsonRpc { - public static class JsonRpcProcessor + /// + /// Entry points for processing JSON-RPC documents. The native shape is bytes in, bytes out: + /// + /// takes what a PipeReader hands you and writes to a PipeWriter / HTTP BodyWriter. The string overloads + /// transcode once at the edge and are kept for existing hosts. + /// + public static partial class JsonRpcProcessor { - public static void Process(JsonRpcStateAsync async, object context = null, - JsonSerializerSettings settings = null) + // ------------------------------------------------------------------ bytes in, bytes out + + /// Processes one document (a request or a batch). Writes the response bytes to ; writes nothing for notifications. + public static void Process(string sessionId, in ReadOnlySequence request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) + { + if (request.IsSingleSegment) + { + Process(sessionId, request.First, output, context, serializer); + return; + } + int length = checked((int)request.Length); + var scratch = Scratch.Rent(); + try + { + var buffer = scratch.Input(length); + request.CopyTo(buffer); + ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, length), output, context, serializer, scratch); + } + finally + { + scratch.Return(); + } + } + + /// Processes one document held in memory. The memory must stay valid until the call returns. + public static void Process(string sessionId, ReadOnlyMemory request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) + { + var scratch = Scratch.Rent(); + try + { + ProcessCore(sessionId, request, output, context, serializer, scratch); + } + finally + { + scratch.Return(); + } + } + + /// Processes one document from a span (copied into a pooled buffer). + public static void Process(string sessionId, ReadOnlySpan request, IBufferWriter output, object context = null, JsonRpcSerializer serializer = null) + { + var scratch = Scratch.Rent(); + try + { + var buffer = scratch.Input(request.Length); + request.CopyTo(buffer); + ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch); + } + finally + { + scratch.Return(); + } + } + + /// Processes a UTF-8 document and returns the UTF-8 response (empty for notifications). + public static byte[] ProcessBytes(string sessionId, ReadOnlySpan request, object context = null, JsonRpcSerializer serializer = null) { - Process(Handler.DefaultSessionId(), async, context, settings); + var scratch = Scratch.Rent(); + try + { + var buffer = scratch.Input(request.Length); + request.CopyTo(buffer); + var output = scratch.Output; + output.Clear(); + ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, request.Length), output, context, serializer, scratch, true); + return output.ToArray(); + } + finally + { + scratch.Return(); + } + } + + // ------------------------------------------------------------------ strings (compatibility) + + public static void Process(JsonRpcStateAsync async, object context = null, JsonRpcSerializer serializer = null) + { + Process(Handler.DefaultSessionId(), async, context, serializer); } - public static void Process(string sessionId, JsonRpcStateAsync async, object context = null, - JsonSerializerSettings settings = null) + public static void Process(string sessionId, JsonRpcStateAsync async, object context = null, JsonRpcSerializer serializer = null) { - Process(sessionId, async.JsonRpc, context, settings) + Process(sessionId, async.JsonRpc, context, serializer) .ContinueWith(t => { async.Result = t.Result; @@ -30,171 +104,186 @@ public static void Process(string sessionId, JsonRpcStateAsync async, object con }); } - public static Task Process(string jsonRpc, object context = null, - JsonSerializerSettings settings = null) + public static Task Process(string jsonRpc, object context = null) + { + return Process(Handler.DefaultSessionId(), jsonRpc, context, null); + } + + /// + /// Processes on the default session with an explicit serializer. The serializer comes first so that + /// Process(sessionId, json, context) can never bind here by mistake. + /// + public static Task Process(JsonRpcSerializer serializer, string jsonRpc, object context = null) { - return Process(Handler.DefaultSessionId(), jsonRpc, context, settings); + return Process(Handler.DefaultSessionId(), jsonRpc, context, serializer); } - public static Task Process(string sessionId, string jsonRpc, object context = null, - JsonSerializerSettings settings = null) + public static Task Process(string sessionId, string jsonRpc, object context = null, JsonRpcSerializer serializer = null) { - return Task.Factory.StartNew((_) => + return Task.Factory.StartNew(state => { - var tuple = (Tuple)_; + var tuple = (Tuple)state; return ProcessSync(tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4); - }, new Tuple(sessionId, jsonRpc, context, settings)); + }, Tuple.Create(sessionId, jsonRpc, context, serializer)); } - public static string ProcessSync(string sessionId, string jsonRpc, object jsonRpcContext, - JsonSerializerSettings settings = null) + public static string ProcessSync(string jsonRpc, object context = null) { - var handler = Handler.GetSessionHandler(sessionId); + return ProcessSync(Handler.DefaultSessionId(), jsonRpc, context, null); + } + /// Synchronous processing on the default session with an explicit serializer (serializer first, see ). + public static string ProcessSync(JsonRpcSerializer serializer, string jsonRpc, object context = null) + { + return ProcessSync(Handler.DefaultSessionId(), jsonRpc, context, serializer); + } - JsonRequest[] batch = null; + /// Processes a request synchronously on the calling thread. Returns the response JSON, or an empty string for notifications. + // jsonRpcContext is deliberately required: with it optional, ProcessSync(json, null) would bind here + // (null converts to string better than to object) with a null document. + public static string ProcessSync(string sessionId, string jsonRpc, object jsonRpcContext, JsonRpcSerializer serializer = null) + { + var scratch = Scratch.Rent(); try { - if (isSingleRpc(jsonRpc)) - { - var foo = JsonConvert.DeserializeObject(jsonRpc, settings); - batch = new[] { foo }; - } - else - { - batch = JsonConvert.DeserializeObject(jsonRpc, settings); - } + int max = Encoding.UTF8.GetMaxByteCount(jsonRpc.Length); + var buffer = scratch.Input(max); + int length = Encoding.UTF8.GetBytes(jsonRpc, 0, jsonRpc.Length, buffer, 0); + var output = scratch.Output; + output.Clear(); + ProcessCore(sessionId, new ReadOnlyMemory(buffer, 0, length), output, jsonRpcContext, serializer, scratch, true); + return output.ToString(); } - catch (Exception ex) + finally { - return Newtonsoft.Json.JsonConvert.SerializeObject(new JsonResponse - { - Error = handler.ProcessParseException(jsonRpc, new JsonRpcException(-32700, "Parse error", ex)) - }, settings); + scratch.Return(); } + } - if (batch.Length == 0) - { - return Newtonsoft.Json.JsonConvert.SerializeObject(new JsonResponse - { - Error = handler.ProcessParseException(jsonRpc, - new JsonRpcException(3200, "Invalid Request", "Batch of calls was empty.")) - }, settings); - } + // ------------------------------------------------------------------ core - var singleBatch = batch.Length == 1; - StringBuilder sbResult = null; - for (var i = 0; i < batch.Length; i++) - { - var jsonRequest = batch[i]; - var jsonResponse = new JsonResponse(); + private static void ProcessCore(string sessionId, ReadOnlyMemory document, IBufferWriter destination, object context, JsonRpcSerializer serializer, Scratch scratch, bool destinationIsScratch = false) + { + var handler = Handler.GetSessionHandler(sessionId); + serializer = serializer ?? handler.Serializer ?? Config.Serializer; - if (jsonRequest == null) + // Always render into the rewindable scratch buffer, then hand the bytes to the caller's writer. + PooledByteBufferWriter output = scratch.Output; + if (!destinationIsScratch) output.Clear(); + + var reader = scratch.GetReader(serializer); + try + { + if (!reader.TryParse(document, out var parseError)) { - jsonResponse.Error = handler.ProcessParseException(jsonRpc, - new JsonRpcException(-32700, "Parse error", - "Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text.")); + var ex = new JsonRpcException(-32700, "Parse error", parseError); + if (handler.HasParseErrorHandler) ex = handler.ProcessParseException(Utf8Json.ToStringUtf8(document.Span), ex); + Handler.WriteErrorEnvelope(output, serializer, ex, default); } - else if (jsonRequest.Method == null) + else if (!reader.IsBatch) { - jsonResponse.Error = handler.ProcessParseException(jsonRpc, - new JsonRpcException(-32600, "Invalid Request", "Missing property 'method'")); + handler.HandleRequest(reader, 0, serializer, output, context); } - else if (!isSimpleValueType(jsonRequest.Id)) + else if (reader.Count == 0) { - jsonResponse.Error = handler.ProcessParseException(jsonRpc, - new JsonRpcException(-32600, "Invalid Request", "Id property must be either null or string or integer.")); + var ex = new JsonRpcException(-32600, "Invalid Request", "Batch of calls was empty."); + if (handler.HasParseErrorHandler) ex = handler.ProcessParseException(Utf8Json.ToStringUtf8(document.Span), ex); + Handler.WriteErrorEnvelope(output, serializer, ex, default); } else { - jsonResponse.Id = jsonRequest.Id; - - var data = handler.Handle(jsonRequest, jsonRpcContext); - - if (data == null) continue; - - jsonResponse.JsonRpc = data.JsonRpc; - jsonResponse.Error = data.Error; - jsonResponse.Result = data.Result; - - } - if (jsonResponse.Result == null && jsonResponse.Error == null) - { - // Per json rpc 2.0 spec - // result : This member is REQUIRED on success. - // This member MUST NOT exist if there was an error invoking the method. - // Either the result member or error member MUST be included, but both members MUST NOT be included. - jsonResponse.Result = new Newtonsoft.Json.Linq.JValue((Object)null); - } - // special case optimization for single Item batch - if (singleBatch && (jsonResponse.Id != null || jsonResponse.Error != null)) - { - StringWriter sw = new StringWriter(); - JsonTextWriter writer = new JsonTextWriter(sw); - writer.WriteStartObject(); - if (!string.IsNullOrEmpty(jsonResponse.JsonRpc)) + int start = output.WrittenCount; + output.Write((byte)'['); + int written = 0; + for (int i = 0; i < reader.Count; i++) { - writer.WritePropertyName("jsonrpc"); writer.WriteValue(jsonResponse.JsonRpc); + int before = output.WrittenCount; + if (written > 0) output.Write((byte)','); + if (handler.HandleRequest(reader, i, serializer, output, context)) written++; + else output.Rewind(before); } - if (jsonResponse.Error != null) + // A batch answers with an array whenever it produced a response (even a single one); + // a batch of notifications only produces nothing at all. + if (written == 0) { - writer.WritePropertyName("error"); writer.WriteRawValue(JsonConvert.SerializeObject(jsonResponse.Error, settings)); + output.Rewind(start); } else { - writer.WritePropertyName("result"); writer.WriteRawValue(JsonConvert.SerializeObject(jsonResponse.Result, settings)); + output.Write((byte)']'); } - writer.WritePropertyName("id"); writer.WriteValue(jsonResponse.Id); - writer.WriteEndObject(); - return sw.ToString(); - - //return JsonConvert.SerializeObject(jsonResponse); } - else if (jsonResponse.Id == null && jsonResponse.Error == null) + } + finally + { + reader.Release(); + } + + if (!destinationIsScratch && output.WrittenCount > 0) + { + output.CopyTo(destination); + } + } + + /// Per-thread pooled buffers and a cached reader. Re-entrant calls get a fresh instance. + private sealed class Scratch + { + [ThreadStatic] private static Scratch _current; + + private byte[] _input = ArrayPool.Shared.Rent(4096); + public readonly PooledByteBufferWriter Output = new PooledByteBufferWriter(4096); + private JsonRpcSerializer _readerOwner; + private JsonRpcRequestReader _reader; + private bool _inUse; + + public static Scratch Rent() + { + var s = _current; + if (s == null) { - // do nothing - sbResult = new StringBuilder(0); + s = new Scratch(); + _current = s; } - else + else if (s._inUse) { - // write out the response - if (i == 0) - { - sbResult = new StringBuilder("["); - } + return new Scratch { _inUse = true }; + } + s._inUse = true; + return s; + } - sbResult.Append(JsonConvert.SerializeObject(jsonResponse, settings)); - if (i < batch.Length - 1) - { - sbResult.Append(','); - } - else if (i == batch.Length - 1) - { - sbResult.Append(']'); - } + public void Return() + { + if (ReferenceEquals(this, _current)) + { + _inUse = false; + return; } + // a nested (re-entrant) instance is used once: give its buffers back to the pool + ArrayPool.Shared.Return(_input); + _input = null; + Output.Dispose(); } - return sbResult.ToString(); - } - private static bool isSingleRpc(string json) - { - for (int i = 0; i < json.Length; i++) + public byte[] Input(int length) { - if (json[i] == '{') return true; - else if (json[i] == '[') return false; + if (_input.Length < length) + { + ArrayPool.Shared.Return(_input); + _input = ArrayPool.Shared.Rent(length); + } + return _input; } - return true; - } - private static bool isSimpleValueType(object property) - { - if (property == null) - return true; - return property.GetType() == typeof(System.String) || - property.GetType() == typeof(System.Int64) || - property.GetType() == typeof(System.Int32) || - property.GetType() == typeof(System.Int16); + public JsonRpcRequestReader GetReader(JsonRpcSerializer serializer) + { + if (!ReferenceEquals(_readerOwner, serializer) || _reader == null) + { + _reader = serializer.CreateReader(); + _readerOwner = serializer; + } + return _reader; + } } } } diff --git a/Json-Rpc/JsonRpcRequestId.cs b/Json-Rpc/JsonRpcRequestId.cs new file mode 100644 index 0000000..8bcf2b1 --- /dev/null +++ b/Json-Rpc/JsonRpcRequestId.cs @@ -0,0 +1,184 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Globalization; +using AustinHarris.JsonRpc.Serialization; + +namespace AustinHarris.JsonRpc +{ + /// + /// An owned snapshot of a request's id: its kind, the integer when it fits in an , the + /// decoded text of a string id, or the exact digits of an integer too large for Int64. Read it inside a method + /// with or ; unlike the raw span + /// from it may be stored, captured and handed to other threads. + /// The default value is , the id of a notification and of code that runs outside an invocation. + /// + public readonly struct JsonRpcRequestId : IEquatable + { + private readonly JsonRpcIdKind _kind; + private readonly long _integer; + private readonly string _text; + + private JsonRpcRequestId(JsonRpcIdKind kind, long integer, string text) + { + _kind = kind; + _integer = integer; + _text = text; + } + + /// No id: a notification, or no invocation in progress. + public static JsonRpcRequestId Absent => default; + + /// The JSON literal null. + public static JsonRpcRequestId Null => new JsonRpcRequestId(JsonRpcIdKind.Null, 0, null); + + public static JsonRpcRequestId FromInt64(long value) => new JsonRpcRequestId(JsonRpcIdKind.Integer, value, null); + + public static JsonRpcRequestId FromString(string value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + return new JsonRpcRequestId(JsonRpcIdKind.String, 0, value); + } + + /// + /// Builds the snapshot from the raw JSON of an id (what yields: + /// 12, "abc", null, or empty when absent). An integer that does not fit in Int64 keeps its + /// digits in ; a value that is not a valid JSON-RPC id is . + /// + public static JsonRpcRequestId FromRaw(ReadOnlySpan raw) + { + return FromRaw(raw, Utf8Json.ClassifyId(raw)); + } + + internal static JsonRpcRequestId FromRaw(ReadOnlySpan raw, JsonRpcIdKind kind) + { + switch (kind) + { + case JsonRpcIdKind.Integer: + if (Utf8Parser.TryParse(raw, out long value, out int consumed) && consumed == raw.Length) return FromInt64(value); + return new JsonRpcRequestId(JsonRpcIdKind.Integer, 0, Utf8Json.ToStringUtf8(raw)); + case JsonRpcIdKind.String: + return FromString(raw.Length >= 2 ? Utf8Json.DecodeString(raw.Slice(1, raw.Length - 2)) : string.Empty); + case JsonRpcIdKind.Null: + return Null; + case JsonRpcIdKind.Absent: + return Absent; + default: + return new JsonRpcRequestId(JsonRpcIdKind.Invalid, 0, Utf8Json.ToStringUtf8(raw)); + } + } + + /// The snapshot of a CLR id value as pre/post handlers see it on (long, string or null). + public static JsonRpcRequestId FromObject(object id) + { + switch (id) + { + case null: return Null; + case string s: return FromString(s); + case long l: return FromInt64(l); + case int i: return FromInt64(i); + case short sh: return FromInt64(sh); + case byte b: return FromInt64(b); + case sbyte sb: return FromInt64(sb); + case ushort us: return FromInt64(us); + case uint ui: return FromInt64(ui); + case ulong ul: + return ul <= long.MaxValue ? FromInt64((long)ul) : new JsonRpcRequestId(JsonRpcIdKind.Integer, 0, ul.ToString(CultureInfo.InvariantCulture)); + case JsonRpcRequestId r: return r; + default: + return new JsonRpcRequestId(JsonRpcIdKind.Invalid, 0, Convert.ToString(id, CultureInfo.InvariantCulture)); + } + } + + public JsonRpcIdKind Kind => _kind; + public bool IsAbsent => _kind == JsonRpcIdKind.Absent; + public bool IsNull => _kind == JsonRpcIdKind.Null; + public bool IsInteger => _kind == JsonRpcIdKind.Integer; + public bool IsString => _kind == JsonRpcIdKind.String; + + /// True for an integer id that fits in Int64; false for a string id, null, an absent id, or an integer outside the Int64 range. + public bool TryGetInt64(out long value) + { + if (_kind == JsonRpcIdKind.Integer && _text == null) + { + value = _integer; + return true; + } + value = 0; + return false; + } + + /// The decoded text of a string id; null for every other kind. + public string GetString() => _kind == JsonRpcIdKind.String ? _text : null; + + /// The exact digits of an integer id (also when it is outside the Int64 range); null for every other kind. + public string GetIntegerText() + { + if (_kind != JsonRpcIdKind.Integer) return null; + return _text ?? _integer.ToString(CultureInfo.InvariantCulture); + } + + /// The id as the boxed path carries it: a long, a string, null; the digits as a string for an oversized integer. + public object ToObject() + { + switch (_kind) + { + case JsonRpcIdKind.Integer: return _text ?? (object)_integer; + case JsonRpcIdKind.String: return _text; + default: return null; + } + } + + /// Writes the id as JSON (null for null, absent and invalid ids). + public void WriteTo(IBufferWriter output) + { + switch (_kind) + { + case JsonRpcIdKind.Integer: + if (_text == null) Utf8Json.WriteInt64(output, _integer); + else Utf8Json.WriteAscii(output, _text.AsSpan()); + break; + case JsonRpcIdKind.String: + Utf8Json.WriteString(output, _text); + break; + default: + Utf8Json.WriteNull(output); + break; + } + } + + public bool Equals(JsonRpcRequestId other) + { + return _kind == other._kind && _integer == other._integer && string.Equals(_text, other._text, StringComparison.Ordinal); + } + + public override bool Equals(object obj) => obj is JsonRpcRequestId other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + int h = (int)_kind * 397; + h = (h * 31) ^ _integer.GetHashCode(); + if (_text != null) h = (h * 31) ^ StringComparer.Ordinal.GetHashCode(_text); + return h; + } + } + + public static bool operator ==(JsonRpcRequestId left, JsonRpcRequestId right) => left.Equals(right); + public static bool operator !=(JsonRpcRequestId left, JsonRpcRequestId right) => !left.Equals(right); + + /// The value as text: the digits, the decoded string, "null", or "" when absent. + public override string ToString() + { + switch (_kind) + { + case JsonRpcIdKind.Integer: return GetIntegerText(); + case JsonRpcIdKind.String: return _text; + case JsonRpcIdKind.Null: return "null"; + case JsonRpcIdKind.Invalid: return _text ?? string.Empty; + default: return string.Empty; + } + } + } +} diff --git a/Json-Rpc/JsonRpcStateAsync.cs b/Json-Rpc/JsonRpcStateAsync.cs index 87e780d..1561f47 100644 --- a/Json-Rpc/JsonRpcStateAsync.cs +++ b/Json-Rpc/JsonRpcStateAsync.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; using System.Threading; namespace AustinHarris.JsonRpc @@ -19,8 +15,8 @@ public JsonRpcStateAsync(AsyncCallback cb, Object extraData) public string JsonRpc { get; set; } public string Result { get; set; } - private AsyncCallback cb = null; - private Object asyncState; + private readonly AsyncCallback cb; + private readonly object asyncState; public object AsyncState { get diff --git a/Json-Rpc/JsonRpcVersionPolicy.cs b/Json-Rpc/JsonRpcVersionPolicy.cs new file mode 100644 index 0000000..d2cae15 --- /dev/null +++ b/Json-Rpc/JsonRpcVersionPolicy.cs @@ -0,0 +1,24 @@ +namespace AustinHarris.JsonRpc +{ + /// + /// How the server treats the jsonrpc member of an incoming request. JSON-RPC 2.0 says it MUST be + /// exactly "2.0", but many real clients (tool harnesses, hand-written fetch calls) omit it. Set the + /// process default with and a per-session override with + /// . + /// + public enum JsonRpcVersionPolicy : byte + { + /// + /// The default. A missing member is accepted; when present it must be "2.0", anything else is + /// answered with -32600 Invalid Request. Sloppy clients work, clients speaking another version + /// are told so. + /// + Lenient = 0, + + /// The member is never inspected: missing, "1.0", a number, anything goes. + Ignore, + + /// The specification: the member must be present and be "2.0". + Strict + } +} diff --git a/Json-Rpc/RpcBinding.cs b/Json-Rpc/RpcBinding.cs new file mode 100644 index 0000000..ec9a265 --- /dev/null +++ b/Json-Rpc/RpcBinding.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AustinHarris.JsonRpc +{ + /// Owns a published interface tree. Disposing it unbinds methods without disposing application objects. + public sealed class RpcBinding : IDisposable + { + private readonly object _sync = new object(); + private readonly SMDServiceCollection _services; + private IReadOnlyDictionary _entries; + + internal RpcBinding(string sessionId, SMDServiceCollection services, IReadOnlyDictionary entries) + { + SessionId = sessionId; + Methods = Array.AsReadOnly(entries.Keys.ToArray()); + _services = services; + _entries = entries; + } + + /// The session on which this tree was registered. + public string SessionId { get; } + + /// The immutable list of wire names originally published by this binding. + public IReadOnlyList Methods { get; } + + /// + /// Removes, in one batch, entries still owned by this binding. Later replacements are left alone. + /// Repeated calls do nothing. Already resolved calls can finish on their captured implementation. + /// + public void Dispose() + { + lock (_sync) + { + if (_entries == null) return; + _services.RemoveBatch(_entries); + _entries = null; + } + } + } +} diff --git a/Json-Rpc/RpcInterfaceBindingOptions.cs b/Json-Rpc/RpcInterfaceBindingOptions.cs new file mode 100644 index 0000000..48086c2 --- /dev/null +++ b/Json-Rpc/RpcInterfaceBindingOptions.cs @@ -0,0 +1,70 @@ +using System; +using System.Reflection; + +namespace AustinHarris.JsonRpc +{ + /// Casing of generated property and method name segments. Explicit aliases remain literal. + public enum RpcNameCasing + { + /// Keep CLR spelling. + Preserve, + /// Lowercase the initial capital or acronym using invariant casing. + CamelCase + } + + /// Registration-time naming and selection for an interface tree. Values are captured before discovery. + public sealed class RpcInterfaceBindingOptions + { + /// Text prepended verbatim to every default wire name. + public string Prefix { get; set; } = ""; + + /// Text joining property segments and the leaf. Defaults to a dot. + public string Separator { get; set; } = "."; + + /// Casing applied to generated segments only; explicit aliases and the prefix are unchanged. + public RpcNameCasing Casing { get; set; } = RpcNameCasing.Preserve; + + /// + /// Walk readable, non-indexed instance properties declared as interfaces. Each getter runs once per + /// mount at registration and may have side effects. Captured children do not follow later property changes. + /// Null children, getter failures, cycles, and paths longer than 32 properties reject the whole tree. + /// + public bool Recursive { get; set; } = true; + + /// Optional predicate called once per leaf alias; null includes all methods. Reads interface metadata. + public Func Include { get; set; } + + /// Optional rule called once per included alias, returning the complete wire name in place of default naming. + public Func NameRule { get; set; } + } + + /// An interface declaration and mounted alias presented to registration callbacks. + public sealed class RpcInterfaceMethod + { + private readonly string[] _path; + + internal RpcInterfaceMethod(MethodInfo method, string[] path, string leaf, string defaultName) + { + Method = method; + Interface = method.DeclaringType; + _path = (string[])path.Clone(); + Leaf = leaf; + DefaultName = defaultName; + } + + /// The interface method declaration, including its parameter metadata and attributes. + public MethodInfo Method { get; } + + /// The closed interface declaring . + public Type Interface { get; } + + /// A copy of the CLR property names from the root; empty for root methods. + public string[] Path => (string[])_path.Clone(); + + /// The explicit alias, or the CLR method name when no nonempty alias was supplied. + public string Leaf { get; } + + /// The prefix plus joined path and leaf, with generated segments cased as requested. + public string DefaultName { get; } + } +} diff --git a/Json-Rpc/SMDService.cs b/Json-Rpc/SMDService.cs index fe84bb5..5f07a27 100644 --- a/Json-Rpc/SMDService.cs +++ b/Json-Rpc/SMDService.cs @@ -1,13 +1,16 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using Newtonsoft.Json; using System.Reflection; -using Newtonsoft.Json.Linq; +using AustinHarris.JsonRpc.Invocation; +using AustinHarris.JsonRpc.Serialization; namespace AustinHarris.JsonRpc { + /// + /// Service Mapping Description for one session: the registered methods plus a serializer-neutral + /// description of their parameter and return types (http://dojotoolkit.org/reference-guide/1.8/dojox/rpc/smd.html). + /// public class SMD { public string transport { get; set; } @@ -15,80 +18,357 @@ public class SMD public string target { get; set; } public bool additonalParameters { get; set; } public SMDAdditionalParameters[] parameters { get; set; } - [JsonIgnore] - public static List TypeHashes { get; set; } - [JsonProperty("types")] - public static Dictionary Types { get; set; } - [JsonProperty("services")] - public Dictionary Services { get; set; } - - public SMD () - { + + private static readonly List _typeHashes = new List(); + private static readonly Dictionary> _types = new Dictionary>(); + + /// Process-wide registry of described types (shared by every session). + public static Dictionary> Types => _types; + + /// + /// The registered services by JSON method name. and + /// go through it; editing it directly (Add, Remove, the indexer, + /// Clear) is supported and takes effect for the next request, because every mutation also updates the + /// dispatch table. + /// + public SMDServiceCollection Services { get; } + + public SMD() + { transport = "POST"; envelope = "URL"; target = "/json.rpc"; additonalParameters = false; parameters = new SMDAdditionalParameters[0]; - Services = new Dictionary(); - Types = new Dictionary(); - TypeHashes = new List(); - } + Services = new SMDServiceCollection(); + } + + internal void AddService(string method, Dictionary parameters, Dictionary defaultValues, Delegate dele) + { + var names = parameters.Keys.Take(Math.Max(0, parameters.Count - 1)).ToArray(); + var rpc = RpcMethod.FromDelegate(method, dele, names, defaultValues); + AddService(method, parameters, defaultValues, dele, rpc); + } + + internal void AddService(string method, Dictionary parameters, Dictionary defaultValues, Delegate dele, RpcMethod rpc) + { + Services[method] = new SMDService(transport, "JSON-RPC-2.0", parameters, defaultValues, dele, rpc); + } + + internal bool RemoveService(string method) + { + return Services.Remove(method); + } + + internal void Clear() + { + Services.Clear(); + } + + /// Span-keyed lookup used by the request path (lock-free). + internal SMDService Find(ReadOnlySpan methodUtf8) + { + return Services.Find(methodUtf8); + } - internal void AddService(string method, Dictionary parameters, Dictionary defaultValues, Delegate dele) + internal SMDService Find(string method) { - var newService = new SMDService(transport,"JSON-RPC-2.0",parameters, defaultValues, dele); - Services.Add(method,newService); + if (method == null) return null; + return Services.TryGetValue(method, out var s) ? s : null; } - public static int AddType(JObject jo) + public static int AddType(Dictionary jo) { - var hash = string.Format("t_{0}", jo.ToString().GetHashCode()); - - lock (TypeHashes) + var hash = TypeHash(jo); + lock (_typeHashes) { - if (TypeHashes.Contains(hash)) return TypeHashes.IndexOf(hash); - - TypeHashes.Add(hash); - var idx = TypeHashes.IndexOf(hash); - Types.Add(idx, jo); + var existing = _typeHashes.IndexOf(hash); + if (existing >= 0) return existing; + _typeHashes.Add(hash); + var idx = _typeHashes.Count - 1; + _types.Add(idx, jo); + return idx; } + } - return TypeHashes.IndexOf(hash); + public static bool ContainsType(Dictionary jo) + { + lock (_typeHashes) + { + return _typeHashes.Contains(TypeHash(jo)); + } } - public static bool ContainsType(JObject jo) + private static string TypeHash(Dictionary jo) { - return TypeHashes.Contains(string.Format("t_{0}", jo.ToString().GetHashCode())); + return "t_" + Jsmn.JsmnSerializer.Instance.Serialize(jo, typeof(Dictionary)).GetHashCode(); + } + } + + /// + /// The services of one session keyed by JSON method name. A dictionary for callers; underneath, every + /// mutation also replaces the lock-free UTF-8 dispatch table the request path resolves methods from, so + /// an added, removed or replaced service is visible to the next request. Reads of the dictionary take a + /// lock; the request path never does. + /// + public sealed class SMDServiceCollection : IDictionary, IReadOnlyDictionary + { + private Dictionary _services = new Dictionary(); + private readonly Utf8KeyTable _table = new Utf8KeyTable(); + private readonly object _sync = new object(); + + internal void AddBatch(IReadOnlyDictionary entries) + { + if (entries.Count == 0) return; + lock (_sync) + { + var next = new Dictionary(_services); + foreach (var entry in entries) + { + if (next.ContainsKey(entry.Key)) + throw new ArgumentException("JSON-RPC method '" + entry.Key + "' is already registered; unbind it first.", nameof(entries)); + next.Add(entry.Key, entry.Value); + } + // No fallible work remains after the table publishes; dictionary readers hold this same lock. + _table.ReplaceAll(next); + _services = next; + } + } + + internal void RemoveBatch(IReadOnlyDictionary entries) + { + lock (_sync) + { + Dictionary next = null; + foreach (var entry in entries) + { + if (_services.TryGetValue(entry.Key, out var current) && ReferenceEquals(current, entry.Value)) + { + if (next == null) next = new Dictionary(_services); + next.Remove(entry.Key); + } + } + if (next == null) return; + _table.ReplaceAll(next); + _services = next; + } + } + + /// Lock-free span-keyed lookup used by the request path. + internal SMDService Find(ReadOnlySpan methodUtf8) + { + return _table.Find(methodUtf8); + } + + public SMDService this[string key] + { + get + { + lock (_sync) + { + return _services[key]; + } + } + set + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value == null) throw new ArgumentNullException(nameof(value)); + lock (_sync) + { + _services[key] = value; + _table.Set(key, value); + } + } + } + + public int Count + { + get + { + lock (_sync) + { + return _services.Count; + } + } + } + + public bool IsReadOnly => false; + + /// A snapshot of the method names. + public ICollection Keys + { + get + { + lock (_sync) + { + return new List(_services.Keys); + } + } + } + + /// A snapshot of the services. + public ICollection Values + { + get + { + lock (_sync) + { + return new List(_services.Values); + } + } + } + + IEnumerable IReadOnlyDictionary.Keys => Keys; + IEnumerable IReadOnlyDictionary.Values => Values; + + public void Add(string key, SMDService value) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value == null) throw new ArgumentNullException(nameof(value)); + lock (_sync) + { + _services.Add(key, value); + _table.Set(key, value); + } + } + + public void Add(KeyValuePair item) + { + Add(item.Key, item.Value); + } + + public bool Remove(string key) + { + if (key == null) return false; + lock (_sync) + { + if (!_services.Remove(key)) return false; + _table.Remove(key); + return true; + } + } + + public bool Remove(KeyValuePair item) + { + lock (_sync) + { + if (!_services.TryGetValue(item.Key, out var existing) || !ReferenceEquals(existing, item.Value)) return false; + _services.Remove(item.Key); + _table.Remove(item.Key); + return true; + } + } + + public void Clear() + { + lock (_sync) + { + _services.Clear(); + _table.Clear(); + } + } + + public bool ContainsKey(string key) + { + if (key == null) return false; + lock (_sync) + { + return _services.ContainsKey(key); + } + } + + public bool Contains(KeyValuePair item) + { + lock (_sync) + { + return _services.TryGetValue(item.Key, out var existing) && ReferenceEquals(existing, item.Value); + } + } + + public bool TryGetValue(string key, out SMDService value) + { + if (key == null) + { + value = null; + return false; + } + lock (_sync) + { + return _services.TryGetValue(key, out value); + } + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + lock (_sync) + { + ((ICollection>)_services).CopyTo(array, arrayIndex); + } + } + + /// Enumerates a snapshot, so the collection may be edited while it is enumerated. + public IEnumerator> GetEnumerator() + { + KeyValuePair[] snapshot; + lock (_sync) + { + snapshot = new KeyValuePair[_services.Count]; + ((ICollection>)_services).CopyTo(snapshot, 0); + } + return ((IEnumerable>)snapshot).GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + return GetEnumerator(); } } public class SMDService { + /// The registered delegate (kept for backwards compatibility; invocation uses ). public Delegate dele; + + /// The compiled invokers for this service method. + public RpcMethod Method { get; private set; } + /// /// Defines a service method http://dojotoolkit.org/reference-guide/1.8/dojox/rpc/smd.html /// /// POST, GET, REST, JSONP, TCP/IP /// URL, PATH, JSON, JSON-RPC-1.0, JSON-RPC-1.1, JSON-RPC-2.0 - /// - /// + /// parameter names and types; the last entry is the return type + /// default values for optional parameters + /// the implementation public SMDService(string transport, string envelope, Dictionary parameters, Dictionary defaultValues, Delegate dele) + : this(transport, envelope, parameters, defaultValues, dele, + RpcMethod.FromDelegate("", dele, parameters.Keys.Take(Math.Max(0, parameters.Count - 1)).ToArray(), defaultValues)) + { + } + + internal SMDService(string transport, string envelope, Dictionary parameters, Dictionary defaultValues, Delegate dele, RpcMethod method) { - // TODO: Complete member initialization this.dele = dele; + this.Method = method; this.transport = transport; this.envelope = envelope; - this.parameters = new SMDAdditionalParameters[parameters.Count-1]; // last param is return type similar to Func<,> - int ctr=0; + // Async metadata describes only wire parameters and the eventual result. + if (method.IsAsync || method.HasCancellation) + { + parameters = method.Parameters.ToDictionary(p => p.Name, p => p.Type); + parameters.Add("returns", method.ResultType); + defaultValues = method.Parameters.Where(p => p.HasDefault).ToDictionary(p => p.Name, p => p.DefaultValue); + } + this.parameters = new SMDAdditionalParameters[Math.Max(0, parameters.Count - 1)]; // last param is return type similar to Func<,> + int ctr = 0; foreach (var item in parameters) - { - if (ctr < parameters.Count -1)// never the last one. last one is the return type. + { + if (ctr < parameters.Count - 1)// never the last one. last one is the return type. { this.parameters[ctr++] = new SMDAdditionalParameters(item.Key, item.Value); } - } + } - // create the default values storage for optional parameters. this.defaultValues = new ParameterDefaultValue[defaultValues.Count]; int counter = 0; foreach (var item in defaultValues) @@ -96,7 +376,6 @@ public SMDService(string transport, string envelope, Dictionary pa this.defaultValues[counter++] = new ParameterDefaultValue(item.Key, item.Value); } - // this is getting the return type from the end of the param list this.returns = new SMDResult(parameters.Values.LastOrDefault()); } public string transport { get; private set; } @@ -104,10 +383,10 @@ public SMDService(string transport, string envelope, Dictionary pa public SMDResult returns { get; private set; } /// - /// This indicates what parameters may be supplied for the service calls. - /// A parameters value MUST be an Array. Each value in the parameters Array should describe a parameter + /// This indicates what parameters may be supplied for the service calls. + /// A parameters value MUST be an Array. Each value in the parameters Array should describe a parameter /// and follow the JSON Schema property definition. Each of parameters that are defined at the root level - /// are inherited by each of service definition's parameters. The parameter definition follows the + /// are inherited by each of service definition's parameters. The parameter definition follows the /// JSON Schema property definition with the additional properties: /// public SMDAdditionalParameters[] parameters { get; private set; } @@ -120,12 +399,13 @@ public SMDService(string transport, string envelope, Dictionary pa public class SMDResult { - [JsonProperty("__type")] - public int Type { get; private set; } + public int __type { get; private set; } + + public int Type => __type; public SMDResult(System.Type type) { - Type = SMDAdditionalParameters.GetTypeRecursive(type); + __type = type == null ? -1 : SMDAdditionalParameters.GetTypeRecursive(type); } } @@ -152,27 +432,25 @@ public ParameterDefaultValue(string name, object value) public class SMDAdditionalParameters { - public SMDAdditionalParameters(string parametername, System.Type type) + public SMDAdditionalParameters(string parametername, System.Type type) { Name = parametername; Type = GetTypeRecursive(ObjectType = type); - } - [JsonIgnore()] public Type ObjectType { get; set; } - [JsonProperty("__name")] + public string __name { get { return Name; } } public string Name { get; set; } - [JsonProperty("__type")] + public int __type { get { return Type; } } public int Type { get; set; } internal static int GetTypeRecursive(Type t) { - JObject jo = new JObject(); + var jo = new Dictionary(); jo.Add("__name", t.Name.ToLower()); if (isSimpleType(t) || SMD.ContainsType(jo)) - { + { return SMD.AddType(jo); } @@ -184,54 +462,28 @@ internal static int GetTypeRecursive(Type t) if (genArgs.Length > 0) { - var ja = new JArray(); + var ja = new List(); foreach (var item in genArgs) { - if (item != t) - { - var jt = GetTypeRecursive(item); - ja.Add(jt); - } - else - { - // make a special case where -1 indicates this type - ja.Add(-1); - } + // -1 marks a reference back to this type + ja.Add(item != t ? GetTypeRecursive(item) : -1); } jo.Add("__genericArguments", ja); } foreach (var item in properties) { - if (item.GetAccessors().Where(x => x.IsPublic).Count() > 0) + if (item.GetAccessors().Any(x => x.IsPublic) && !jo.ContainsKey(item.Name)) { - if (item.PropertyType != t) - { - var jt = GetTypeRecursive(item.PropertyType); - jo.Add(item.Name, jt); - } - else - { - // make a special case where -1 indicates this type - jo.Add(item.Name, -1); - } + jo.Add(item.Name, item.PropertyType != t ? GetTypeRecursive(item.PropertyType) : -1); } } foreach (var item in fields) { - if (item.IsPublic) + if (item.IsPublic && !jo.ContainsKey(item.Name)) { - if (item.FieldType != t) - { - var jt = GetTypeRecursive(item.FieldType); - jo.Add(item.Name, jt); - } - else - { - // make a special case where -1 indicates this type - jo.Add(item.Name, -1); - } + jo.Add(item.Name, item.FieldType != t ? GetTypeRecursive(item.FieldType) : -1); } } @@ -243,6 +495,7 @@ internal static bool isSimpleType(Type t) var name = t.FullName.ToLower(); if (name.Contains("newtonsoft") + || name.Contains("system.text.json") || name == "system.sbyte" || name == "system.byte" || name == "system.int16" @@ -262,7 +515,7 @@ internal static bool isSimpleType(Type t) || name == "system.string" || name == "system.object" || name == "system.type" - // || name == "system.datetime" + || name == "system.void" || name == "system.reflection.membertypes") { return true; diff --git a/Json-Rpc/Serialization/ErrorInfo.cs b/Json-Rpc/Serialization/ErrorInfo.cs new file mode 100644 index 0000000..8274760 --- /dev/null +++ b/Json-Rpc/Serialization/ErrorInfo.cs @@ -0,0 +1,159 @@ +using System; +using System.Buffers; +using System.Text; + +namespace AustinHarris.JsonRpc.Serialization +{ + /// + /// The data of a -32601 error: {"method":"name"}, the effective method name (decoded, and as + /// replaced by a pre-process handler). Written the same way by every serializer. Nothing else is disclosed: the + /// session and the registered methods are not the client's business. + /// + public sealed class MethodNotFoundInfo + { + private static readonly byte[] Prefix = Encoding.ASCII.GetBytes("{\"method\":"); + + public MethodNotFoundInfo(string method) + { + Method = method; + } + + /// The method name the request asked for. + public string Method { get; } + + public void WriteTo(IBufferWriter output) + { + Utf8Json.WriteRaw(output, Prefix); + if (Method == null) Utf8Json.WriteNull(output); + else Utf8Json.WriteString(output, Method); + Utf8Json.WriteByte(output, (byte)'}'); + } + + public override string ToString() => "Method not found: " + Method; + } + + /// + /// The data of a -32602 error raised because one argument could not be converted to the parameter's type: + /// {"reason":"conversion","parameter":"name","index":0,"expectedType":"int32"}, plus "message" + /// (the serializer's description of the failure) when is on. The + /// value the client sent is never echoed. Written the same way by every serializer. + /// + public sealed class ParameterErrorInfo + { + private static readonly byte[] Prefix = Encoding.ASCII.GetBytes("{\"reason\":\"conversion\",\"parameter\":"); + private static readonly byte[] IndexKey = Encoding.ASCII.GetBytes(",\"index\":"); + private static readonly byte[] ExpectedTypeKey = Encoding.ASCII.GetBytes(",\"expectedType\":"); + private static readonly byte[] MessageKey = Encoding.ASCII.GetBytes(",\"message\":"); + + public ParameterErrorInfo(string parameter, int index, Type expectedType, Exception cause) + { + Parameter = parameter; + Index = index; + ExpectedType = Describe(expectedType); + Cause = cause; + } + + public ParameterErrorInfo(Invocation.RpcParameter parameter, int index, Exception cause) + : this(parameter.Name, index, parameter.Type, cause) + { + } + + /// Always "conversion" in this release. + public string Reason => "conversion"; + /// The JSON name of the parameter. + public string Parameter { get; } + /// The parameter's position in the method signature (0-based). + public int Index { get; } + /// The parameter's CLR type in a short spelling: int32, string, guid, int32?, string[], List<Order>, Order. + public string ExpectedType { get; } + /// The serializer's exception; its message goes to the client only with . + public Exception Cause { get; } + public string Message => Cause?.Message; + + public void WriteTo(IBufferWriter output) + { + Utf8Json.WriteRaw(output, Prefix); + Utf8Json.WriteString(output, Parameter); + Utf8Json.WriteRaw(output, IndexKey); + Utf8Json.WriteInt64(output, Index); + Utf8Json.WriteRaw(output, ExpectedTypeKey); + Utf8Json.WriteString(output, ExpectedType); + if (Config.IncludeExceptionDetails && Message != null) + { + Utf8Json.WriteRaw(output, MessageKey); + Utf8Json.WriteString(output, Message); + } + Utf8Json.WriteByte(output, (byte)'}'); + } + + public override string ToString() + { + return "Parameter '" + Parameter + "' (" + ExpectedType + ") could not be converted" + (Message != null ? ": " + Message : "."); + } + + /// + /// Whether says a value could not be converted (the client's fault) rather than + /// that the serializer cannot handle the type or failed internally: the serializers' own + /// , the BCL parse failures, and any JsonException family + /// (System.Text.Json's and Json.NET's, matched by base type name so a serializer built on either qualifies). + /// + public static bool IsConversionFailure(Exception cause) + { + if (cause == null) return false; + if (cause is JsonRpcBindException || cause is FormatException || cause is OverflowException || cause is InvalidCastException) return true; + for (var t = cause.GetType(); t != null && t != typeof(Exception); t = t.BaseType) + { + if (t.Name == "JsonException") return true; + } + return false; + } + + /// A short, language-neutral spelling of a CLR type for diagnostics. + public static string Describe(Type type) + { + if (type == null) return null; + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) return Describe(underlying) + "?"; + if (type.IsArray) return Describe(type.GetElementType()) + "[]"; + if (type.IsByRef) return Describe(type.GetElementType()); + switch (Type.GetTypeCode(type)) + { + case TypeCode.String: return "string"; + case TypeCode.Boolean: return "boolean"; + case TypeCode.Char: return "char"; + case TypeCode.SByte: return "int8"; + case TypeCode.Byte: return "uint8"; + case TypeCode.Int16: return "int16"; + case TypeCode.UInt16: return "uint16"; + case TypeCode.Int32: return "int32"; + case TypeCode.UInt32: return "uint32"; + case TypeCode.Int64: return "int64"; + case TypeCode.UInt64: return "uint64"; + case TypeCode.Single: return "single"; + case TypeCode.Double: return "double"; + case TypeCode.Decimal: return "decimal"; + case TypeCode.DateTime: return "datetime"; + } + if (type == typeof(object)) return "object"; + if (type == typeof(Guid)) return "guid"; + if (type == typeof(DateTimeOffset)) return "datetimeoffset"; + if (type == typeof(TimeSpan)) return "timespan"; + if (type == typeof(Uri)) return "uri"; + if (type.IsGenericType) + { + var name = type.Name; + int tick = name.IndexOf('`'); + if (tick > 0) name = name.Substring(0, tick); + var args = type.GetGenericArguments(); + var sb = new StringBuilder(name).Append('<'); + for (int i = 0; i < args.Length; i++) + { + if (i > 0) sb.Append(','); + sb.Append(Describe(args[i])); + } + return sb.Append('>').ToString(); + } + return type.Name; + } + } +} diff --git a/Json-Rpc/Serialization/ExceptionInfo.cs b/Json-Rpc/Serialization/ExceptionInfo.cs new file mode 100644 index 0000000..3aff0be --- /dev/null +++ b/Json-Rpc/Serialization/ExceptionInfo.cs @@ -0,0 +1,52 @@ +using System; + +namespace AustinHarris.JsonRpc.Serialization +{ + /// + /// The serializer-neutral shape used when an is placed in a JSON-RPC error's + /// data member. Every serializer emits the same members in this order. + /// + public sealed class ExceptionInfo + { + public string ClassName { get; set; } + public string Message { get; set; } + public string Source { get; set; } + public string StackTraceString { get; set; } + public int HResult { get; set; } + public ExceptionInfo InnerException { get; set; } + + /// The full description of , diagnostics included. + public static ExceptionInfo From(Exception ex) + { + return From(ex, true); + } + + /// + /// Describes . With false only the type name and + /// message are carried; Source, StackTraceString and the InnerException chain are null and HResult is 0. + /// + public static ExceptionInfo From(Exception ex, bool includeDetails) + { + if (ex == null) return null; + if (!includeDetails) + { + return new ExceptionInfo { ClassName = ex.GetType().FullName, Message = ex.Message }; + } + return new ExceptionInfo + { + ClassName = ex.GetType().FullName, + Message = ex.Message, + Source = ex.Source, + StackTraceString = ex.StackTrace, + HResult = ex.HResult, + InnerException = From(ex.InnerException, true) + }; + } + + /// The description sent to a client in error.data, governed by . + public static ExceptionInfo ForResponse(Exception ex) + { + return From(ex, Config.IncludeExceptionDetails); + } + } +} diff --git a/Json-Rpc/Serialization/JsonFramer.cs b/Json-Rpc/Serialization/JsonFramer.cs new file mode 100644 index 0000000..2f3109b --- /dev/null +++ b/Json-Rpc/Serialization/JsonFramer.cs @@ -0,0 +1,112 @@ +using System; +using System.Buffers; + +namespace AustinHarris.JsonRpc.Serialization +{ + /// + /// Finds complete top-level JSON documents in a byte stream. Intended for transports that deliver + /// JSON-RPC over a raw connection (System.IO.Pipelines / Kestrel ConnectionHandler, sockets) where + /// messages are concatenated or newline-separated and a read may end mid-document. + /// + public static class JsonFramer + { + /// + /// Tries to slice one complete JSON value (object or array) from the start of , + /// skipping leading whitespace. On success holds the value and + /// is advanced past it. Returns false when more bytes are needed. + /// + public static bool TryReadDocument(ref ReadOnlySequence buffer, out ReadOnlySequence document) + { + int depth = 0; + bool inString = false; + bool escaped = false; + bool started = false; + long index = 0; + SequencePosition? startPos = null; + + foreach (var segment in buffer) + { + var span = segment.Span; + for (int i = 0; i < span.Length; i++, index++) + { + byte b = span[i]; + if (!started) + { + if (b == (byte)' ' || b == (byte)'\t' || b == (byte)'\r' || b == (byte)'\n') continue; + if (b != (byte)'{' && b != (byte)'[') + { + // not the start of a structured document; let the caller drop the byte + document = buffer.Slice(0, index + 1); + buffer = buffer.Slice(index + 1); + return true; + } + started = true; + startPos = buffer.GetPosition(index); + } + if (inString) + { + if (escaped) escaped = false; + else if (b == (byte)'\\') escaped = true; + else if (b == (byte)'"') inString = false; + continue; + } + switch (b) + { + case (byte)'"': inString = true; break; + case (byte)'{': + case (byte)'[': depth++; break; + case (byte)'}': + case (byte)']': + depth--; + if (depth == 0) + { + var end = buffer.GetPosition(index + 1); + document = buffer.Slice(startPos.Value, end); + buffer = buffer.Slice(end); + return true; + } + break; + } + } + } + document = default; + return false; + } + + /// Span variant: returns the length of the first complete document (after leading whitespace) or -1. + public static int FindDocumentEnd(ReadOnlySpan buffer) + { + int depth = 0; + bool inString = false, escaped = false, started = false; + for (int i = 0; i < buffer.Length; i++) + { + byte b = buffer[i]; + if (!started) + { + if (b == (byte)' ' || b == (byte)'\t' || b == (byte)'\r' || b == (byte)'\n') continue; + if (b != (byte)'{' && b != (byte)'[') return -1; + started = true; + } + if (inString) + { + if (escaped) escaped = false; + else if (b == (byte)'\\') escaped = true; + else if (b == (byte)'"') inString = false; + continue; + } + switch (b) + { + case (byte)'"': inString = true; break; + case (byte)'{': + case (byte)'[': depth++; break; + case (byte)'}': + case (byte)']': + depth--; + if (depth == 0) return i + 1; + break; + } + } + return -1; + } + } +} diff --git a/Json-Rpc/Serialization/JsonRpcSerializer.cs b/Json-Rpc/Serialization/JsonRpcSerializer.cs new file mode 100644 index 0000000..c6e64eb --- /dev/null +++ b/Json-Rpc/Serialization/JsonRpcSerializer.cs @@ -0,0 +1,181 @@ +using System; +using System.Buffers; +using System.Text; + +namespace AustinHarris.JsonRpc.Serialization +{ + /// + /// The pluggable JSON layer. The core never touches strings: requests arrive as UTF-8 bytes + /// (a from a PipeReader, a span, or a transcoded string) and + /// responses are written to an (a PipeWriter, an HTTP BodyWriter, + /// or a pooled buffer that is transcoded back to a string). + /// + /// A serializer only has to convert values: raw JSON bytes to a CLR value and a CLR value to JSON bytes. + /// Envelope scanning (method / params / id) is done by a ; the default + /// reader is the built-in jsmn tokenizer, which every serializer may reuse or replace. + /// + public abstract class JsonRpcSerializer + { + /// Short name used in diagnostics and benchmarks ("jsmn", "newtonsoft", "stj", ...). + public abstract string Name { get; } + + /// + /// When true the default envelope reader accepts non-strict JSON (single-quoted strings, unquoted keys, + /// trailing commas). Serializers such as Json.NET that are lenient by nature turn this on. + /// + public virtual bool Lenient => false; + + /// + /// Maximum nesting depth (objects and arrays, the root counts as one) accepted by the envelope reader for + /// this serializer. Enforced before any hook or binding runs, so it bounds every recursive reader + /// downstream. Serializers override it to report their own limit; the default is 64, the same as + /// System.Text.Json and Json.NET. + /// + public virtual int MaxDepth => Jsmn.JsmnTokenizer.DefaultMaxDepth; + + /// Creates the envelope reader used for this serializer. Readers are pooled per thread by the processor. + public virtual JsonRpcRequestReader CreateReader() => new Jsmn.JsmnRequestReader(this); + + /// Converts a JSON value (the raw bytes of exactly one value, e.g. "abc", 12, {"a":1}) to . + public abstract T Read(ReadOnlySpan utf8Json); + + /// Converts a JSON value to . Used by the boxed compatibility path (pre/post handlers, Handler.Handle). + public abstract object Read(ReadOnlySpan utf8Json, Type type); + + /// Writes as one JSON value, compact, no trailing whitespace. + public abstract void Write(IBufferWriter output, T value); + + /// Writes (typed as , or its runtime type when null) as one JSON value. + public abstract void Write(IBufferWriter output, object value, Type type); + + // ---- string adapters (transcoding); convenient for callers that still hold strings ---- + + public T Deserialize(string json) + { + var bytes = Encoding.UTF8.GetBytes(json); + return Read(bytes); + } + + public object Deserialize(string json, Type type) + { + var bytes = Encoding.UTF8.GetBytes(json); + return Read(bytes, type); + } + + public string Serialize(T value) + { + using (var w = new PooledByteBufferWriter(256)) + { + Write(w, value); + return w.ToString(); + } + } + + public string Serialize(object value, Type type) + { + using (var w = new PooledByteBufferWriter(256)) + { + Write(w, value, type); + return w.ToString(); + } + } + } + + public enum JsonRpcIdKind : byte + { + /// No id member: the request is a notification. + Absent = 0, + Null, + Integer, + String, + /// Anything else (fraction, bool, object, array): the request is invalid. + Invalid + } + + public enum JsonRpcParamsKind : byte + { + Absent = 0, + Array, + Object, + /// A primitive; JSON-RPC 2.0 requires a structured value. + Invalid + } + + /// What the request's jsonrpc member says. Judged against by the handler. + public enum JsonRpcVersionKind : byte + { + /// No jsonrpc member. + Absent = 0, + /// The string "2.0". + V2, + /// Present but not the string "2.0" (another version, a number, null, ...). + Other + } + + /// + /// Cursor over one JSON-RPC document (a single request or a batch). Implementations keep the parsed + /// structure and hand the core slices of the original bytes, so nothing is materialised until a + /// parameter is bound to a CLR type. + /// + public abstract class JsonRpcRequestReader + { + /// Parses a document. Returns false on a JSON syntax error (the core answers -32700). + public abstract bool TryParse(ReadOnlyMemory utf8Document, out string error); + + /// The document bytes handed to (used only to feed the parse-error handler). + public virtual ReadOnlyMemory Document => default; + + /// True when the document root is an array. + public abstract bool IsBatch { get; } + + /// Number of requests (1 for a single request, N for a batch, 0 for an empty batch). + public abstract int Count { get; } + + /// Positions the cursor on request . False when that element is not a JSON object. + public abstract bool Select(int index); + + public abstract bool HasMethod { get; } + /// The method name as UTF-8 (decoded when the document escaped it); used for the span-keyed lookup. + public abstract ReadOnlySpan MethodUtf8 { get; } + /// The decoded method name. + public abstract string Method { get; } + + /// + /// The request's jsonrpc member. Readers that do not inspect it report , + /// which every accepts. + /// + public virtual JsonRpcVersionKind VersionKind => JsonRpcVersionKind.V2; + + public abstract JsonRpcIdKind IdKind { get; } + /// The raw JSON of the id (including quotes for strings). Empty when absent. + public abstract ReadOnlySpan IdRaw { get; } + /// The id as a CLR value: long, string or null. + public abstract object IdValue { get; } + + public abstract JsonRpcParamsKind ParamsKind { get; } + /// Number of elements (array) or members (object) in params; 0 when absent. + public abstract int ParamCount { get; } + /// Member name of object params as UTF-8 (decoded when escaped). Empty for array params. + public abstract ReadOnlySpan ParamNameUtf8(int i); + /// The raw JSON of parameter . + public abstract ReadOnlySpan ParamRaw(int i); + /// True when parameter is the JSON literal null. + public abstract bool ParamIsNull(int i); + + public abstract T ReadParam(int i); + public abstract object ReadParam(int i, Type type); + + /// The params value materialised with the serializer's own object model (only used for pre/post handlers). + public abstract object ParamsValue { get; } + + /// Releases pooled memory; the reader may be reused via . + public abstract void Release(); + } + + /// Thrown by serializers when a JSON value cannot be converted to the requested type. + public class JsonRpcBindException : Exception + { + public JsonRpcBindException(string message) : base(message) { } + public JsonRpcBindException(string message, Exception inner) : base(message, inner) { } + } +} diff --git a/Json-Rpc/Serialization/PooledByteBufferWriter.cs b/Json-Rpc/Serialization/PooledByteBufferWriter.cs new file mode 100644 index 0000000..90ca4c3 --- /dev/null +++ b/Json-Rpc/Serialization/PooledByteBufferWriter.cs @@ -0,0 +1,117 @@ +using System; +using System.Buffers; +using System.Text; + +namespace AustinHarris.JsonRpc.Serialization +{ + /// + /// A growable UTF-8 output buffer backed by . Supports rewinding so a + /// partially written response can be discarded when a method throws. The processor keeps one per thread. + /// + public sealed class PooledByteBufferWriter : IBufferWriter, IDisposable + { + private byte[] _buffer; + private int _written; + + public PooledByteBufferWriter(int initialCapacity = 4096) + { + _buffer = ArrayPool.Shared.Rent(initialCapacity); + } + + public int WrittenCount => _written; + public ReadOnlySpan WrittenSpan => new ReadOnlySpan(_buffer, 0, _written); + public ReadOnlyMemory WrittenMemory => new ReadOnlyMemory(_buffer, 0, _written); + public ArraySegment WrittenSegment => new ArraySegment(_buffer, 0, _written); + + public void Clear() => _written = 0; + + /// Discards everything written after . + public void Rewind(int position) + { + if (position < 0 || position > _written) throw new ArgumentOutOfRangeException(nameof(position)); + _written = position; + } + + public void Advance(int count) + { + if (count < 0 || _written + count > _buffer.Length) throw new ArgumentOutOfRangeException(nameof(count)); + _written += count; + } + + public Memory GetMemory(int sizeHint = 0) + { + Ensure(sizeHint); + return new Memory(_buffer, _written, _buffer.Length - _written); + } + + public Span GetSpan(int sizeHint = 0) + { + Ensure(sizeHint); + return new Span(_buffer, _written, _buffer.Length - _written); + } + + public void Write(byte b) + { + if (_written == _buffer.Length) Grow(1); + _buffer[_written++] = b; + } + + public void Write(ReadOnlySpan bytes) + { + Ensure(bytes.Length); + bytes.CopyTo(new Span(_buffer, _written, bytes.Length)); + _written += bytes.Length; + } + + /// Removes the byte at , shifting the rest left. + public void RemoveAt(int position) + { + if (position < 0 || position >= _written) throw new ArgumentOutOfRangeException(nameof(position)); + Buffer.BlockCopy(_buffer, position + 1, _buffer, position, _written - position - 1); + _written--; + } + + /// Decodes the written bytes as a UTF-8 string. + public override string ToString() => _written == 0 ? string.Empty : Encoding.UTF8.GetString(_buffer, 0, _written); + + public void CopyTo(IBufferWriter destination) + { + var span = destination.GetSpan(_written); + new ReadOnlySpan(_buffer, 0, _written).CopyTo(span); + destination.Advance(_written); + } + + public byte[] ToArray() + { + var copy = new byte[_written]; + Buffer.BlockCopy(_buffer, 0, copy, 0, _written); + return copy; + } + + private void Ensure(int sizeHint) + { + if (sizeHint < 1) sizeHint = 1; + if (_buffer.Length - _written < sizeHint) Grow(sizeHint); + } + + private void Grow(int sizeHint) + { + int newSize = Math.Max(_buffer.Length * 2, _written + sizeHint); + var next = ArrayPool.Shared.Rent(newSize); + Buffer.BlockCopy(_buffer, 0, next, 0, _written); + ArrayPool.Shared.Return(_buffer); + _buffer = next; + } + + public void Dispose() + { + var b = _buffer; + if (b != null) + { + _buffer = null; + _written = 0; + ArrayPool.Shared.Return(b); + } + } + } +} diff --git a/Json-Rpc/Serialization/Utf8Json.cs b/Json-Rpc/Serialization/Utf8Json.cs new file mode 100644 index 0000000..bd6a69b --- /dev/null +++ b/Json-Rpc/Serialization/Utf8Json.cs @@ -0,0 +1,662 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Buffers.Text; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; + +namespace AustinHarris.JsonRpc.Serialization +{ + /// + /// Wire-format helpers shared by the core and the built-in serializer. They encode the conventions the + /// library has always produced (and its tests assert): compact output, whole float/double/decimal values + /// carry a ".0", DateTime as ISO-8601 the way Json.NET writes it (fraction only when non-zero, trailing zeros + /// trimmed, Z / offset / nothing by Kind), char as a one-character string. + /// + public static class Utf8Json + { + /// Upper bound on the bytes and write (no quotes). + public const int MaxDateTimeLength = 33; + + // ------------------------------------------------------------------ literals + + public static void WriteNull(IBufferWriter w) + { + var s = w.GetSpan(4); + s[0] = (byte)'n'; s[1] = (byte)'u'; s[2] = (byte)'l'; s[3] = (byte)'l'; + w.Advance(4); + } + + // ---- overloads on the concrete pooled writer, used by the compiled built-in invokers so a formatted value + // costs no interface calls. Each shares the formatting code of its IBufferWriter twin: same bytes. + + public static void WriteNull(PooledByteBufferWriter w) + { + var s = w.GetSpan(4); + s[0] = (byte)'n'; s[1] = (byte)'u'; s[2] = (byte)'l'; s[3] = (byte)'l'; + w.Advance(4); + } + + public static void WriteBool(PooledByteBufferWriter w, bool value) + { + var s = w.GetSpan(5); + int n = FormatBool(s, value); + w.Advance(n); + } + + public static void WriteInt64(PooledByteBufferWriter w, long value) + { + var s = w.GetSpan(20); + Utf8Formatter.TryFormat(value, s, out int written); + w.Advance(written); + } + + public static void WriteDouble(PooledByteBufferWriter w, double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) { WriteNonFinite(w, value); return; } + var s = w.GetSpan(40); + w.Advance(FormatDouble(s, value)); + } + + public static void WriteSingle(PooledByteBufferWriter w, float value) + { + if (float.IsNaN(value) || float.IsInfinity(value)) { WriteNonFinite(w, value); return; } + var s = w.GetSpan(40); + w.Advance(FormatSingle(s, value)); + } + + public static void WriteDecimal(PooledByteBufferWriter w, decimal value) + { + var s = w.GetSpan(48); + w.Advance(FormatDecimal(s, value)); + } + + public static void WriteString(PooledByteBufferWriter w, string value) + { + if (value == null) { WriteNull(w); return; } + var v = value.AsSpan(); + if (v.Length > StringChunk) { WriteLongString(w, v); return; } + var s = w.GetSpan(v.Length * 6 + 2); + w.Advance(FormatString(s, v)); + } + + public static void WriteBool(IBufferWriter w, bool value) + { + var s = w.GetSpan(5); + int n = FormatBool(s, value); + w.Advance(n); + } + + private static int FormatBool(Span s, bool value) + { + if (value) + { + s[0] = (byte)'t'; s[1] = (byte)'r'; s[2] = (byte)'u'; s[3] = (byte)'e'; + return 4; + } + s[0] = (byte)'f'; s[1] = (byte)'a'; s[2] = (byte)'l'; s[3] = (byte)'s'; s[4] = (byte)'e'; + return 5; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteByte(IBufferWriter w, byte b) + { + w.GetSpan(1)[0] = b; + w.Advance(1); + } + + public static void WriteRaw(IBufferWriter w, ReadOnlySpan raw) + { + var s = w.GetSpan(raw.Length); + raw.CopyTo(s); + w.Advance(raw.Length); + } + + /// Writes ASCII text (used for literals and formatted numbers/dates). + public static void WriteAscii(IBufferWriter w, ReadOnlySpan chars) + { + var s = w.GetSpan(chars.Length); + for (int i = 0; i < chars.Length; i++) s[i] = (byte)chars[i]; + w.Advance(chars.Length); + } + + // ------------------------------------------------------------------ numbers + + public static void WriteInt64(IBufferWriter w, long value) + { + var s = w.GetSpan(20); + Utf8Formatter.TryFormat(value, s, out int written); + w.Advance(written); + } + + public static void WriteUInt64(IBufferWriter w, ulong value) + { + var s = w.GetSpan(20); + Utf8Formatter.TryFormat(value, s, out int written); + w.Advance(written); + } + + public static void WriteDouble(IBufferWriter w, double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) { WriteNonFinite(w, value); return; } + var s = w.GetSpan(40); + w.Advance(FormatDouble(s, value)); + } + + public static void WriteSingle(IBufferWriter w, float value) + { + if (float.IsNaN(value) || float.IsInfinity(value)) { WriteNonFinite(w, value); return; } + var s = w.GetSpan(40); + w.Advance(FormatSingle(s, value)); + } + + public static void WriteDecimal(IBufferWriter w, decimal value) + { + var s = w.GetSpan(48); + w.Advance(FormatDecimal(s, value)); + } + + /// A finite double into at least 40 bytes: shortest round-trip text, with ".0" when it has no fraction or exponent. + private static int FormatDouble(Span s, double value) + { +#if NETSTANDARD2_0 + int written = WriteAsciiInto(value.ToString("R", CultureInfo.InvariantCulture), s); +#else + Utf8Formatter.TryFormat(value, s, out int written); +#endif + return EnsureDecimalPlace(s, written); + } + + private static int FormatSingle(Span s, float value) + { +#if NETSTANDARD2_0 + int written = WriteAsciiInto(value.ToString("R", CultureInfo.InvariantCulture), s); +#else + Utf8Formatter.TryFormat(value, s, out int written); +#endif + return EnsureDecimalPlace(s, written); + } + + private static int FormatDecimal(Span s, decimal value) + { + Utf8Formatter.TryFormat(value, s, out int written); + return EnsureDecimalPlace(s, written); + } + + /// The JSON text of NaN / +Infinity / -Infinity: the strings "NaN", "Infinity" and "-Infinity" (quoted). + public const string NaNText = "NaN"; + public const string PositiveInfinityText = "Infinity"; + public const string NegativeInfinityText = "-Infinity"; + + private static void WriteNonFinite(IBufferWriter w, double value) + { + // Bare NaN / Infinity are not JSON. Json.NET's default (FloatFormatHandling.String) writes the quoted + // strings "NaN", "Infinity", "-Infinity" and reads them back; every serializer here does the same. + WriteQuotedAscii(w, NonFiniteText(value)); + } + + /// Returns "NaN", "Infinity" or "-Infinity" for a non-finite value. + public static string NonFiniteText(double value) + { + return double.IsNaN(value) ? NaNText : value > 0 ? PositiveInfinityText : NegativeInfinityText; + } + + /// Recognises the non-finite float spellings Json.NET reads back: "NaN", "Infinity", "-Infinity" (case-sensitive, UTF-8 bytes without quotes). + public static bool TryParseNonFinite(ReadOnlySpan text, out double value) + { + switch (text.Length) + { + case 3: + if (text[0] == (byte)'N' && text[1] == (byte)'a' && text[2] == (byte)'N') { value = double.NaN; return true; } + break; + case 8: + if (IsInfinity(text)) { value = double.PositiveInfinity; return true; } + break; + case 9: + if (text[0] == (byte)'-' && IsInfinity(text.Slice(1))) { value = double.NegativeInfinity; return true; } + break; + } + value = 0; + return false; + } + + /// Same as for decoded text. + public static bool TryParseNonFinite(string text, out double value) + { + if (text == NaNText) { value = double.NaN; return true; } + if (text == PositiveInfinityText) { value = double.PositiveInfinity; return true; } + if (text == NegativeInfinityText) { value = double.NegativeInfinity; return true; } + value = 0; + return false; + } + + private static bool IsInfinity(ReadOnlySpan text) + { + return text.Length == 8 + && text[0] == (byte)'I' && text[1] == (byte)'n' && text[2] == (byte)'f' && text[3] == (byte)'i' + && text[4] == (byte)'n' && text[5] == (byte)'i' && text[6] == (byte)'t' && text[7] == (byte)'y'; + } + + private static int WriteAsciiInto(string text, Span s) + { + for (int i = 0; i < text.Length; i++) s[i] = (byte)text[i]; + return text.Length; + } + + /// Appends ".0" when the formatted (finite) number has neither a fraction nor an exponent. + private static int EnsureDecimalPlace(Span s, int written) + { + for (int i = 0; i < written; i++) + { + byte b = s[i]; + if (b == (byte)'.' || b == (byte)'E' || b == (byte)'e') return written; + } + s[written] = (byte)'.'; + s[written + 1] = (byte)'0'; + return written + 2; + } + + // ------------------------------------------------------------------ strings + + public static void WriteString(IBufferWriter w, string value) + { + if (value == null) { WriteNull(w); return; } + WriteString(w, value.AsSpan()); + } + + public static void WriteString(IBufferWriter w, ReadOnlySpan value) + { + if (value.Length > StringChunk) { WriteLongString(w, value); return; } + // worst case: every char becomes \uXXXX (6 bytes); surrogate pairs are 4 bytes for 2 chars + var s = w.GetSpan(value.Length * 6 + 2); + w.Advance(FormatString(s, value)); + } + + /// Strings longer than this go out in chunks, so the worst-case reservation (6 bytes per char) stays bounded. + private const int StringChunk = 512; + + private static void WriteLongString(IBufferWriter w, ReadOnlySpan value) + { + WriteByte(w, (byte)'"'); + while (value.Length > 0) + { + int n = Math.Min(StringChunk, value.Length); + if (n < value.Length && char.IsHighSurrogate(value[n - 1])) n++; // never split a surrogate pair + var s = w.GetSpan(n * 6); + w.Advance(FormatChars(s, value.Slice(0, n))); + value = value.Slice(n); + } + WriteByte(w, (byte)'"'); + } + + /// Quotes and escapes into (at least 6 bytes per char plus 2). Returns the length. + private static int FormatString(Span s, ReadOnlySpan value) + { + s[0] = (byte)'"'; + int p = 1 + FormatChars(s.Slice(1), value); + s[p] = (byte)'"'; + return p + 1; + } + + /// Escapes into without quotes (at least 6 bytes per char). Returns the length. + private static int FormatChars(Span s, ReadOnlySpan value) + { + int p = 0; + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (c < 0x80) + { + if (c >= 0x20 && c != '"' && c != '\\') + { + s[p++] = (byte)c; + continue; + } + s[p++] = (byte)'\\'; + switch (c) + { + case '"': s[p++] = (byte)'"'; break; + case '\\': s[p++] = (byte)'\\'; break; + case '\n': s[p++] = (byte)'n'; break; + case '\r': s[p++] = (byte)'r'; break; + case '\t': s[p++] = (byte)'t'; break; + case '\b': s[p++] = (byte)'b'; break; + case '\f': s[p++] = (byte)'f'; break; + default: + s[p++] = (byte)'u'; s[p++] = (byte)'0'; s[p++] = (byte)'0'; + s[p++] = Hex(c >> 4); s[p++] = Hex(c & 0xF); + break; + } + } + else if (c < 0x800) + { + s[p++] = (byte)(0xC0 | (c >> 6)); + s[p++] = (byte)(0x80 | (c & 0x3F)); + } + else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1])) + { + int cp = char.ConvertToUtf32(c, value[i + 1]); + i++; + s[p++] = (byte)(0xF0 | (cp >> 18)); + s[p++] = (byte)(0x80 | ((cp >> 12) & 0x3F)); + s[p++] = (byte)(0x80 | ((cp >> 6) & 0x3F)); + s[p++] = (byte)(0x80 | (cp & 0x3F)); + } + else if (char.IsSurrogate(c)) + { + // lone surrogate: escape it so the output stays valid UTF-8 + s[p++] = (byte)'\\'; s[p++] = (byte)'u'; + s[p++] = Hex(c >> 12); s[p++] = Hex((c >> 8) & 0xF); s[p++] = Hex((c >> 4) & 0xF); s[p++] = Hex(c & 0xF); + } + else + { + s[p++] = (byte)(0xE0 | (c >> 12)); + s[p++] = (byte)(0x80 | ((c >> 6) & 0x3F)); + s[p++] = (byte)(0x80 | (c & 0x3F)); + } + } + return p; + } + + private static byte Hex(int nibble) => (byte)(nibble < 10 ? '0' + nibble : 'a' + nibble - 10); + + public static void WriteChar(IBufferWriter w, char value) + { + Span one = stackalloc char[1]; + one[0] = value; + WriteString(w, one); + } + + public static void WriteDateTime(IBufferWriter w, DateTime value) + { + var span = w.GetSpan(MaxDateTimeLength + 2); + span[0] = (byte)'"'; + int n = FormatDateTime(span.Slice(1), value); + span[n + 1] = (byte)'"'; + w.Advance(n + 2); + } + + public static void WriteDateTimeOffset(IBufferWriter w, DateTimeOffset value) + { + var span = w.GetSpan(MaxDateTimeLength + 2); + span[0] = (byte)'"'; + int n = FormatDateTimeOffset(span.Slice(1), value); + span[n + 1] = (byte)'"'; + w.Advance(n + 2); + } + + /// + /// Json.NET's DateTime text: yyyy-MM-ddTHH:mm:ss, then the fraction only when it is non-zero with + /// trailing zeros trimmed, then Z for Utc, +HH:mm/-HH:mm for Local, nothing for + /// Unspecified. Returns the number of bytes written (at most ). + /// + public static int FormatDateTime(Span dest, DateTime value) + { + int n = FormatDateTimeCore(dest, value); + switch (value.Kind) + { + case DateTimeKind.Utc: + dest[n++] = (byte)'Z'; + break; + case DateTimeKind.Local: + n += FormatOffset(dest.Slice(n), TimeZoneInfo.Local.GetUtcOffset(value)); + break; + } + return n; + } + + /// Same as for the clock time, and the offset is always written. + public static int FormatDateTimeOffset(Span dest, DateTimeOffset value) + { + int n = FormatDateTimeCore(dest, value.DateTime); + return n + FormatOffset(dest.Slice(n), value.Offset); + } + + private static int FormatDateTimeCore(Span d, DateTime dt) + { + int year = dt.Year; + d[0] = (byte)('0' + year / 1000); d[1] = (byte)('0' + year / 100 % 10); d[2] = (byte)('0' + year / 10 % 10); d[3] = (byte)('0' + year % 10); + d[4] = (byte)'-'; WriteTwoDigits(d, 5, dt.Month); + d[7] = (byte)'-'; WriteTwoDigits(d, 8, dt.Day); + d[10] = (byte)'T'; WriteTwoDigits(d, 11, dt.Hour); + d[13] = (byte)':'; WriteTwoDigits(d, 14, dt.Minute); + d[16] = (byte)':'; WriteTwoDigits(d, 17, dt.Second); + int n = 19; + int fraction = (int)(dt.Ticks % TimeSpan.TicksPerSecond); + if (fraction != 0) + { + int digits = 7; + while (fraction % 10 == 0) { fraction /= 10; digits--; } + d[n++] = (byte)'.'; + for (int i = digits - 1; i >= 0; i--) { d[n + i] = (byte)('0' + fraction % 10); fraction /= 10; } + n += digits; + } + return n; + } + + private static int FormatOffset(Span d, TimeSpan offset) + { + if (offset < TimeSpan.Zero) { d[0] = (byte)'-'; offset = -offset; } + else d[0] = (byte)'+'; + WriteTwoDigits(d, 1, offset.Hours); + d[3] = (byte)':'; + WriteTwoDigits(d, 4, offset.Minutes); + return 6; + } + + private static void WriteTwoDigits(Span d, int at, int value) + { + d[at] = (byte)('0' + value / 10); + d[at + 1] = (byte)('0' + value % 10); + } + + public static void WriteQuotedAscii(IBufferWriter w, string text) + { + var s = w.GetSpan(text.Length + 2); + s[0] = (byte)'"'; + for (int i = 0; i < text.Length; i++) s[i + 1] = (byte)text[i]; + s[text.Length + 1] = (byte)'"'; + w.Advance(text.Length + 2); + } + + /// Writes a property name followed by ':' (the name is written with full escaping). + public static void WritePropertyName(IBufferWriter w, string name) + { + WriteString(w, name); + WriteByte(w, (byte)':'); + } + + // ------------------------------------------------------------------ decoding + + /// Decodes the contents of a JSON string (without the surrounding quotes), resolving escapes. + public static string DecodeString(ReadOnlySpan contents) + { + if (contents.IndexOf((byte)'\\') < 0) + { + return ToStringUtf8(contents); + } + return DecodeEscaped(contents); + } + + /// Unescapes JSON string contents into (UTF-8). Returns bytes written; dest must be at least contents.Length. + public static int Unescape(ReadOnlySpan contents, Span dest) + { + int p = 0; + for (int i = 0; i < contents.Length; i++) + { + byte b = contents[i]; + if (b != (byte)'\\') { dest[p++] = b; continue; } + i++; + if (i >= contents.Length) throw new JsonRpcBindException("Unterminated escape sequence."); + switch (contents[i]) + { + case (byte)'"': dest[p++] = (byte)'"'; break; + case (byte)'\\': dest[p++] = (byte)'\\'; break; + case (byte)'/': dest[p++] = (byte)'/'; break; + case (byte)'\'': dest[p++] = (byte)'\''; break; // lenient single-quoted strings + case (byte)'b': dest[p++] = (byte)'\b'; break; + case (byte)'f': dest[p++] = (byte)'\f'; break; + case (byte)'n': dest[p++] = (byte)'\n'; break; + case (byte)'r': dest[p++] = (byte)'\r'; break; + case (byte)'t': dest[p++] = (byte)'\t'; break; + case (byte)'u': + { + int cp = ParseHex4(contents, i + 1); + i += 4; + if (cp >= 0xD800 && cp <= 0xDBFF && i + 6 < contents.Length && contents[i + 1] == (byte)'\\' && contents[i + 2] == (byte)'u') + { + int low = ParseHex4(contents, i + 3); + if (low >= 0xDC00 && low <= 0xDFFF) + { + cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); + i += 6; + } + } + p += EncodeCodePoint(cp, dest.Slice(p)); + break; + } + default: + throw new JsonRpcBindException("Invalid escape sequence."); + } + } + return p; + } + + private static string DecodeEscaped(ReadOnlySpan contents) + { + byte[] rented = ArrayPool.Shared.Rent(contents.Length); + try + { + int n = Unescape(contents, rented); + return Encoding.UTF8.GetString(rented, 0, n); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private static int EncodeCodePoint(int cp, Span dest) + { + if (cp < 0x80) { dest[0] = (byte)cp; return 1; } + if (cp < 0x800) { dest[0] = (byte)(0xC0 | (cp >> 6)); dest[1] = (byte)(0x80 | (cp & 0x3F)); return 2; } + if (cp < 0x10000) { dest[0] = (byte)(0xE0 | (cp >> 12)); dest[1] = (byte)(0x80 | ((cp >> 6) & 0x3F)); dest[2] = (byte)(0x80 | (cp & 0x3F)); return 3; } + dest[0] = (byte)(0xF0 | (cp >> 18)); dest[1] = (byte)(0x80 | ((cp >> 12) & 0x3F)); dest[2] = (byte)(0x80 | ((cp >> 6) & 0x3F)); dest[3] = (byte)(0x80 | (cp & 0x3F)); + return 4; + } + + private static int ParseHex4(ReadOnlySpan s, int at) + { + if (at + 4 > s.Length) throw new JsonRpcBindException("Truncated \\u escape."); + int v = 0; + for (int k = 0; k < 4; k++) + { + int b = s[at + k]; + int d = b >= '0' && b <= '9' ? b - '0' : b >= 'a' && b <= 'f' ? b - 'a' + 10 : b >= 'A' && b <= 'F' ? b - 'A' + 10 : -1; + if (d < 0) throw new JsonRpcBindException("Invalid \\u escape."); + v = (v << 4) | d; + } + return v; + } + + public static string ToStringUtf8(ReadOnlySpan utf8) + { + if (utf8.Length == 0) return string.Empty; +#if NETSTANDARD2_0 + return Encoding.UTF8.GetString(utf8.ToArray()); +#else + return Encoding.UTF8.GetString(utf8); +#endif + } + + // ------------------------------------------------------------------ comparisons / hashing + + /// Compares raw name bytes ignoring ASCII case. + public static bool EqualsIgnoreAsciiCase(ReadOnlySpan a, ReadOnlySpan b) + { + if (a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) + { + int x = a[i], y = b[i]; + if (x == y) continue; + if ((uint)(x - 'A') <= 'Z' - 'A') x += 32; + if ((uint)(y - 'A') <= 'Z' - 'A') y += 32; + if (x != y) return false; + } + return true; + } + + /// + /// A non-cryptographic hash of the bytes, eight at a time (FNV-style multiply-xor over 64-bit words); + /// used by the span-keyed method table. Only meaningful within one process. + /// + public static int Hash(ReadOnlySpan bytes) + { + ulong h = 0x9E3779B97F4A7C15UL ^ (ulong)bytes.Length; + while (bytes.Length >= 8) + { + h = (h ^ BinaryPrimitives.ReadUInt64LittleEndian(bytes)) * 0x100000001B3UL; + h ^= h >> 29; + bytes = bytes.Slice(8); + } + if (bytes.Length > 0) + { + ulong tail = 0; + for (int i = 0; i < bytes.Length; i++) tail |= (ulong)bytes[i] << (8 * i); + h = (h ^ tail) * 0x100000001B3UL; + h ^= h >> 29; + } + return (int)(h ^ (h >> 32)); + } + + /// Classifies raw id bytes per JSON-RPC 2.0: null, string, or integer are valid. + public static JsonRpcIdKind ClassifyId(ReadOnlySpan raw) + { + if (raw.Length == 0) return JsonRpcIdKind.Absent; + byte b = raw[0]; + if (b == (byte)'"' || b == (byte)'\'') return JsonRpcIdKind.String; + if (b == (byte)'n') return raw.Length == 4 ? JsonRpcIdKind.Null : JsonRpcIdKind.Invalid; + if (b == (byte)'-' || (b >= (byte)'0' && b <= (byte)'9')) + { + for (int i = 1; i < raw.Length; i++) + { + byte c = raw[i]; + if (c < (byte)'0' || c > (byte)'9') return JsonRpcIdKind.Invalid; + } + return JsonRpcIdKind.Integer; + } + return JsonRpcIdKind.Invalid; + } + + public static object IdToObject(ReadOnlySpan raw, JsonRpcIdKind kind) + { + switch (kind) + { + case JsonRpcIdKind.Integer: + if (Utf8Parser.TryParse(raw, out long l, out int consumed) && consumed == raw.Length) return l; + return ToStringUtf8(raw); + case JsonRpcIdKind.String: + return DecodeString(raw.Slice(1, raw.Length - 2)); + default: + return null; + } + } + + /// Serializes an id object (integer/string/null) back to raw JSON bytes. + public static void WriteId(IBufferWriter w, object id) + { + switch (id) + { + case null: WriteNull(w); break; + case string s: WriteString(w, s); break; + case long l: WriteInt64(w, l); break; + case int i: WriteInt64(w, i); break; + case short sh: WriteInt64(w, sh); break; + case ulong ul: WriteUInt64(w, ul); break; + case uint ui: WriteUInt64(w, ui); break; + default: WriteString(w, Convert.ToString(id, CultureInfo.InvariantCulture)); break; + } + } + } +} diff --git a/Json-Rpc/Serialization/Utf8KeyTable.cs b/Json-Rpc/Serialization/Utf8KeyTable.cs new file mode 100644 index 0000000..2e1589f --- /dev/null +++ b/Json-Rpc/Serialization/Utf8KeyTable.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace AustinHarris.JsonRpc.Serialization +{ + /// + /// A lookup keyed by UTF-8 bytes so a method can be resolved from the request span without + /// allocating a string. Copy-on-write: readers are lock-free over one immutable snapshot (bucket heads + /// indexing a contiguous entry array, no per-entry objects); writers build and publish a new snapshot. + /// + internal sealed class Utf8KeyTable where TValue : class + { + private readonly struct Entry + { + public readonly byte[] Key; + public readonly int Hash; + /// Index of the next entry in the same bucket, or -1. + public readonly int Next; + public readonly TValue Value; + + public Entry(byte[] key, int hash, int next, TValue value) + { + Key = key; + Hash = hash; + Next = next; + Value = value; + } + } + + private sealed class Snapshot + { + public readonly int[] Buckets; + public readonly Entry[] Entries; + + public Snapshot(int[] buckets, Entry[] entries) + { + Buckets = buckets; + Entries = entries; + } + } + + private static readonly Snapshot Empty = Build(Array.Empty>()); + + private Snapshot _snapshot = Empty; + private readonly object _writeLock = new object(); + + public TValue Find(ReadOnlySpan key) + { + var snapshot = _snapshot; + var entries = snapshot.Entries; + int hash = Utf8Json.Hash(key); + for (int i = snapshot.Buckets[hash & (snapshot.Buckets.Length - 1)]; i >= 0; i = entries[i].Next) + { + ref readonly var e = ref entries[i]; + if (e.Hash == hash && key.SequenceEqual(e.Key)) return e.Value; + } + return null; + } + + public TValue Find(string key) => Find(Encoding.UTF8.GetBytes(key)); + + public void Set(string key, TValue value) + { + lock (_writeLock) + { + _snapshot = Rebuild(_snapshot, Encoding.UTF8.GetBytes(key), value, true); + } + } + + public bool Remove(string key) + { + lock (_writeLock) + { + var bytes = Encoding.UTF8.GetBytes(key); + if (Find(bytes) == null) return false; + _snapshot = Rebuild(_snapshot, bytes, null, false); + return true; + } + } + + public void Clear() + { + lock (_writeLock) + { + _snapshot = Empty; + } + } + + // The caller holds the collection mutation lock. Build before publishing so a failed batch is invisible. + internal void ReplaceAll(IReadOnlyDictionary values) + { + lock (_writeLock) + { + var items = new List>(values.Count); + foreach (var item in values) + items.Add(new KeyValuePair(Encoding.UTF8.GetBytes(item.Key), item.Value)); + var snapshot = Build(items); + System.Threading.Volatile.Write(ref _snapshot, snapshot); + } + } + + /// The entries of without , plus (key, value) when . + private static Snapshot Rebuild(Snapshot source, byte[] key, TValue value, bool add) + { + var items = new List>(source.Entries.Length + 1); + foreach (var e in source.Entries) + { + if (!new ReadOnlySpan(e.Key).SequenceEqual(key)) items.Add(new KeyValuePair(e.Key, e.Value)); + } + if (add) items.Add(new KeyValuePair(key, value)); + return Build(items); + } + + private static Snapshot Build(IReadOnlyList> items) + { + int size = 16; + while (size < items.Count) size *= 2; // load factor at most 1 + var buckets = new int[size]; + for (int i = 0; i < size; i++) buckets[i] = -1; + var entries = new Entry[items.Count]; + for (int i = 0; i < entries.Length; i++) + { + var kv = items[i]; + int hash = Utf8Json.Hash(kv.Key); + int b = hash & (size - 1); + entries[i] = new Entry(kv.Key, hash, buckets[b], kv.Value); + buckets[b] = i; + } + return new Snapshot(buckets, entries); + } + } +} diff --git a/Json-Rpc/ServiceBinder.Interface.cs b/Json-Rpc/ServiceBinder.Interface.cs new file mode 100644 index 0000000..90c7ec3 --- /dev/null +++ b/Json-Rpc/ServiceBinder.Interface.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using AustinHarris.JsonRpc.Invocation; + +namespace AustinHarris.JsonRpc +{ + public static partial class ServiceBinder + { + /// Registers a closed interface contract and its children on the default session. See the session overload. + public static RpcBinding BindInterface(TInterface implementation, RpcInterfaceBindingOptions options = null) + where TInterface : class + { + return BindInterface(Handler.DefaultSessionId(), implementation, options); + } + + /// + /// Discovers and compiles a closed interface tree, then publishes all methods atomically on the session. + /// Only public instance methods declared by the selected interfaces are exported; names, attributes, + /// and optional defaults come from those declarations, including explicit implementations. + /// Recursive getters run once per mount at registration and may have side effects. A failure publishes + /// nothing; getter side effects cannot be undone. Empty, reserved rpc., duplicate, and occupied + /// names are rejected. Generic methods and default interface bodies are unsupported. + /// The returned handle owns the registrations, not the lifetime of the implementation objects. + /// + public static RpcBinding BindInterface(string sessionId, TInterface implementation, + RpcInterfaceBindingOptions options = null) where TInterface : class + { + if (sessionId == null) throw new ArgumentNullException(nameof(sessionId)); + ValidateInterface(typeof(TInterface)); + if (implementation == null) throw new ArgumentNullException(nameof(implementation)); + var builder = new InterfaceTreeBuilder(options ?? new RpcInterfaceBindingOptions()); + var metadata = Handler.GetSessionHandler(sessionId).MetaData; + builder.Discover(typeof(TInterface), implementation, Array.Empty(), metadata.transport); + var binding = new RpcBinding(sessionId, metadata.Services, builder.Entries); + metadata.Services.AddBatch(builder.Entries); + return binding; + } + + private static void ValidateInterface(Type type) + { + if (!type.IsInterface || type.ContainsGenericParameters) + throw new ArgumentException("Interface binding requires a closed interface type: '" + type + "'.", "TInterface"); + } + + private sealed class InterfaceTreeBuilder + { + private const BindingFlags Declared = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + private readonly string _prefix; + private readonly string _separator; + private readonly RpcNameCasing _casing; + private readonly bool _recursive; + private readonly Func _include; + private readonly Func _nameRule; + private readonly List<(object Target, Type Interface)> _active = new List<(object, Type)>(); + internal readonly Dictionary Entries = new Dictionary(StringComparer.Ordinal); + + internal InterfaceTreeBuilder(RpcInterfaceBindingOptions options) + { + _prefix = options.Prefix ?? throw new ArgumentException("Prefix cannot be null.", nameof(options)); + _separator = options.Separator ?? throw new ArgumentException("Separator cannot be null.", nameof(options)); + _casing = options.Casing; + _recursive = options.Recursive; + _include = options.Include; + _nameRule = options.NameRule; + if (_casing != RpcNameCasing.Preserve && _casing != RpcNameCasing.CamelCase) + throw new ArgumentException("Unknown interface name casing.", nameof(options)); + } + + internal void Discover(Type type, object target, string[] path, string transport) + { + ValidateInterface(type); + if (path.Length > 32) throw new ArgumentException("Interface tree depth exceeds 32 at '" + string.Join(".", path) + "'."); + if (target == null) throw new ArgumentException("Interface child '" + string.Join(".", path) + "' is null."); + if (_active.Any(item => ReferenceEquals(item.Target, target) && item.Interface == type)) + throw new ArgumentException("Interface tree cycle at '" + string.Join(".", path) + "'."); + _active.Add((target, type)); + try + { + // GetInterfaces includes the transitive closure; each closed declaration is visited once per mount. + foreach (var contract in new[] { type }.Concat(type.GetInterfaces()).Distinct()) + { + foreach (var method in contract.GetMethods(Declared)) + { + if (method.IsSpecialName) continue; + var attributes = method.GetCustomAttributes(typeof(JsonRpcMethodAttribute), false) + .Cast().ToArray(); + if (attributes.Length == 0) AddMethod(method, target, path, null, RpcContextFlow.None, transport); + foreach (var attribute in attributes) AddMethod(method, target, path, attribute.JsonMethodName, attribute.ContextFlow, transport); + } + if (!_recursive) continue; + foreach (var property in contract.GetProperties(Declared)) + { + var getter = property.GetGetMethod(); + if (getter == null || getter.IsStatic || !property.PropertyType.IsInterface || property.GetIndexParameters().Length != 0) continue; + var childPath = path.Concat(new[] { property.Name }).ToArray(); + var mapped = MapMethod(getter, target); + object child; + try { child = mapped.Invoke(target, null); } + catch (TargetInvocationException ex) + { + throw new ArgumentException("Interface getter '" + string.Join(".", childPath) + "' threw during registration.", ex.InnerException ?? ex); + } + Discover(property.PropertyType, child, childPath, transport); + } + } + } + finally { _active.RemoveAt(_active.Count - 1); } + } + + private void AddMethod(MethodInfo method, object target, string[] path, string alias, RpcContextFlow contextFlow, string transport) + { + bool literal = !string.IsNullOrEmpty(alias); + string leaf = literal ? alias : method.Name; + string defaultName = _prefix + string.Join(_separator, path.Select(Case).Concat(new[] { literal ? leaf : Case(leaf) })); + var description = new RpcInterfaceMethod(method, path, leaf, defaultName); + if (_include != null && !_include(description)) return; + string name = _nameRule == null ? defaultName : _nameRule(description); + if (string.IsNullOrWhiteSpace(name) || name.StartsWith("rpc.", StringComparison.Ordinal)) + throw new ArgumentException("Invalid or reserved JSON-RPC interface method name: '" + name + "'."); + if (Entries.ContainsKey(name)) throw new ArgumentException("Duplicate JSON-RPC interface method name '" + name + "'."); + if (method.ContainsGenericParameters) + throw new ArgumentException("Generic interface method '" + method.Name + "' is not supported."); + + var mapped = MapMethod(method, target); + var parameters = method.GetParameters(); + var names = parameters.Select(p => + { + var rename = p.GetCustomAttribute()?.JsonParamName; + return string.IsNullOrEmpty(rename) ? p.Name : rename; + }).ToArray(); + var rpc = RpcMethod.FromMappedMethod(name, method, mapped, target, names, contextFlow); + var types = new Dictionary(); + var defaults = new Dictionary(); + foreach (var parameter in rpc.Parameters) + { + if (types.ContainsKey(parameter.Name)) + throw new ArgumentException("JSON-RPC method '" + name + "': duplicate parameter name '" + parameter.Name + "'."); + types.Add(parameter.Name, parameter.Type); + if (parameter.HasDefault) defaults.Add(parameter.Name, parameter.DefaultValue); + } + // The last entry is only a return-type marker, so it need not reserve an application parameter name. + string returnKey = "returns"; + while (types.ContainsKey(returnKey)) returnKey += "_"; + types.Add(returnKey, rpc.ResultType); + Delegate legacy = null; + var shape = parameters.Select(p => p.ParameterType).ToArray(); + Type delegateType; + bool hasShape = method.ReturnType == typeof(void) + ? Expression.TryGetActionType(shape, out delegateType) + : Expression.TryGetFuncType(shape.Concat(new[] { method.ReturnType }).ToArray(), out delegateType); + if (hasShape) legacy = Delegate.CreateDelegate(delegateType, target, mapped); + Entries.Add(name, new SMDService(transport, "JSON-RPC-2.0", types, defaults, legacy, rpc)); + } + + private static MethodInfo MapMethod(MethodInfo method, object target) + { + if (!method.IsAbstract) + throw new ArgumentException("Default interface member '" + method.Name + "' is not supported by interface binding."); + var mapping = target.GetType().GetInterfaceMap(method.DeclaringType); + int index = Array.IndexOf(mapping.InterfaceMethods, method); + if (index < 0 || mapping.TargetMethods[index].DeclaringType.IsInterface) + throw new ArgumentException("Interface member '" + method.Name + "' has no concrete implementation mapping."); + return mapping.TargetMethods[index]; + } + + private string Case(string segment) + { + if (_casing == RpcNameCasing.Preserve || segment.Length == 0 || !char.IsUpper(segment[0])) return segment; + var chars = segment.ToCharArray(); + for (int i = 0; i < chars.Length && char.IsUpper(chars[i]); i++) + { + if (i > 0 && i + 1 < chars.Length && !char.IsUpper(chars[i + 1])) break; + chars[i] = char.ToLowerInvariant(chars[i]); + } + return new string(chars); + } + } + } +} diff --git a/Json-Rpc/ServiceBinder.cs b/Json-Rpc/ServiceBinder.cs index 20423a6..90128bd 100644 --- a/Json-Rpc/ServiceBinder.cs +++ b/Json-Rpc/ServiceBinder.cs @@ -1,13 +1,82 @@ -namespace AustinHarris.JsonRpc +namespace AustinHarris.JsonRpc { using System; using System.Collections.Generic; using System.Linq; using System.Reflection; - using AustinHarris.JsonRpc; + using AustinHarris.JsonRpc.Invocation; - public static class ServiceBinder + public static partial class ServiceBinder { + /// Compatibility overload preserving the original default-session registration signature. + public static void BindMethod(string name, Delegate implementation, string[] parameterNames, IDictionary defaults) + { + BindMethod(name, implementation, parameterNames, defaults, RpcContextFlow.None); + } + + /// Compatibility overload preserving the original session registration signature. + public static void BindMethod(string sessionID, string name, Delegate implementation, string[] parameterNames, IDictionary defaults) + { + BindMethod(sessionID, name, implementation, parameterNames, defaults, RpcContextFlow.None); + } + + /// Registers as method on the default session. See the session overload. + public static void BindMethod(string name, Delegate implementation, string[] parameterNames = null, IDictionary defaults = null, RpcContextFlow contextFlow = RpcContextFlow.None) + { + BindMethod(Handler.DefaultSessionId(), name, implementation, parameterNames, defaults, contextFlow); + } + + /// + /// Registers any delegate (a lambda, a method group, a closed instance method) as JSON-RPC method + /// on session , without attributes or a service class. + /// Parameters bind by the delegate's signature: positional params by order, named params by + /// when given (null entries keep the lambda's own name), else by the + /// lambda's parameter names, else arg1, arg2... for a delegate whose names are not recoverable. + /// (keyed by JSON name) make those parameters optional. The name must be free: + /// re-registering a name is an error, unlike attribute binding; unbind it first with . + /// Task and ValueTask delegates require ProcessAsync; async void is rejected. + /// controls ambient context across awaits. + /// + public static void BindMethod(string sessionID, string name, Delegate implementation, string[] parameterNames = null, IDictionary defaults = null, RpcContextFlow contextFlow = RpcContextFlow.None) + { + if (sessionID == null) throw new ArgumentNullException(nameof(sessionID)); + if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("A JSON-RPC method name is required.", nameof(name)); + if (implementation == null) throw new ArgumentNullException(nameof(implementation)); + + var rpc = RpcMethod.FromDelegate(name, implementation, parameterNames, defaults, contextFlow); + var handler = Handler.GetSessionHandler(sessionID); + if (handler.MetaData.Services.ContainsKey(name)) + { + throw new ArgumentException("JSON-RPC method '" + name + "' is already registered on session '" + sessionID + "'; unbind it first.", nameof(name)); + } + + var paras = new Dictionary(); + var defaultValues = new Dictionary(); + foreach (var p in rpc.Parameters) + { + if (paras.ContainsKey(p.Name)) + { + throw new ArgumentException("JSON-RPC method '" + name + "': parameter name '" + p.Name + "' is used more than once.", nameof(parameterNames)); + } + paras.Add(p.Name, p.Type); + if (p.HasDefault) defaultValues.Add(p.Name, p.DefaultValue); + } + paras.Add("returns", rpc.ResultType); + handler.MetaData.AddService(name, paras, defaultValues, implementation, rpc); + } + + /// Removes method from session ; false when it was not registered. + public static bool UnbindMethod(string sessionID, string name) + { + return Handler.GetSessionHandler(sessionID).MetaData.RemoveService(name); + } + + /// Removes method from the default session; false when it was not registered. + public static bool UnbindMethod(string name) + { + return UnbindMethod(Handler.DefaultSessionId(), name); + } + public static void BindService() where T : new() { BindService(Handler.DefaultSessionId()); @@ -19,53 +88,62 @@ public static class ServiceBinder public static void BindService(string sessionID, Object instance) { - var item = instance.GetType(); // var item = typeof(T); + var item = instance.GetType(); - var methods = item.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Where(m => m.GetCustomAttributes(typeof(JsonRpcMethodAttribute), false).Length > 0); + 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) { Dictionary paras = new Dictionary(); - Dictionary defaultValues = new Dictionary(); // dictionary that holds default values for optional params. + Dictionary defaultValues = new Dictionary(); var paramzs = meth.GetParameters(); + var jsonNames = new string[paramzs.Length]; - List parameterTypeArray = new List(); for (int i = 0; i < paramzs.Length; i++) { - string paramName; - var paramAttrs = paramzs[i].GetCustomAttributes(typeof(JsonRpcParamAttribute), false); - if (paramAttrs.Length > 0) - { + string paramName; + var paramAttrs = paramzs[i].GetCustomAttributes(typeof(JsonRpcParamAttribute), false); + if (paramAttrs.Length > 0) + { paramName = ((JsonRpcParamAttribute)paramAttrs[0]).JsonParamName; if (string.IsNullOrEmpty(paramName)) { - paramName = paramzs[i].Name; + paramName = paramzs[i].Name; } - } - else - { - paramName = paramzs[i].Name; - } - // reflection attribute information for optional parameters - //http://stackoverflow.com/questions/2421994/invoking-methods-with-optional-parameters-through-reflection + } + else + { + paramName = paramzs[i].Name; + } + jsonNames[i] = paramName; paras.Add(paramName, paramzs[i].ParameterType); - if (paramzs[i].IsOptional) // if the parameter is an optional, add the default value to our default values dictionary. + if (paramzs[i].IsOptional) defaultValues.Add(paramName, paramzs[i].DefaultValue); } var resType = meth.ReturnType; - paras.Add("returns", resType); // add the return type to the generic parameters list. + paras.Add("returns", resType); // the return type travels as the last entry, like Func<,> var atdata = meth.GetCustomAttributes(typeof(JsonRpcMethodAttribute), false); foreach (JsonRpcMethodAttribute handlerAttribute in atdata) { - var methodName = handlerAttribute.JsonMethodName == string.Empty ? meth.Name : handlerAttribute.JsonMethodName; - var newDel = Delegate.CreateDelegate(System.Linq.Expressions.Expression.GetDelegateType(paras.Values.ToArray()), instance /*Need to add support for other methods outside of this instance*/, meth); + var methodName = string.IsNullOrEmpty(handlerAttribute.JsonMethodName) ? meth.Name : handlerAttribute.JsonMethodName; + var rpc = 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) + { + // e.g. ref parameters: no Func<> shape exists; the compiled invoker still works + } var handlerSession = Handler.GetSessionHandler(sessionID); - handlerSession.MetaData.AddService(methodName, paras, defaultValues, newDel); + handlerSession.MetaData.AddService(methodName, paras, defaultValues, legacy, rpc); } } } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 7847e23..7e3c50c 100644 --- a/README.md +++ b/README.md @@ -5,57 +5,510 @@ json-rpc.net ============ ![Build Master](https://github.com/Astn/JSON-RPC.NET/workflows/Build%20Master/badge.svg) ![NuGet Badge](https://buildstats.info/nuget/AustinHarris.JsonRpc) -JSON-RPC.Net is a high performance Json-Rpc 2.0 server, leveraging the popular JSON.NET library. Host in ASP.NET, also supports sockets and pipes, oh my! +JSON-RPC.Net is a high performance [JSON-RPC 2.0](https://www.jsonrpc.org/specification) server for .NET. It turns a JSON-RPC request into a JSON-RPC response and stays out of the way of your transport: bytes in, bytes out. Host it in Kestrel, a console app, sockets, pipes, or inside the browser as WebAssembly. -## Performance +Version 2.0 rebuilt the pipeline around UTF-8 bytes and made the JSON serializer pluggable. The core has no JSON library dependency; Json.NET and System.Text.Json ship as separate packages, and a built-in serializer needs neither. On one core it answers a small request in about 250 ns with no allocation; a Kestrel host on an 8-core desktop answers over 14 million requests per second over pipelined TCP. -These are results from running the TestServer_Console project. +- [Packages](#packages) +- [Requirements](#requirements) +- [Installation](#installation) +- [Getting started](#getting-started) +- [Hosting modes](#hosting-modes) +- [Configuration](#configuration) +- [Benchmarks](#benchmarks) +- [Upgrading from 1.x](#upgrading-from-1x) +- [Building](#building) -##### Xeon E-2176M @ 2.70GHz 64.0 GB (Date: Thu Apr 30 17:34:22 2020 -0600) +## Packages + +| Package | What it is | +| --- | --- | +| `AustinHarris.JsonRpc` | The server. Envelope parsing, method dispatch, parameter binding, error mapping, sessions. Ships with a dependency-free serializer (a span port of [jsmn](https://github.com/zserge/jsmn) plus a cached reflection mapper). | +| `AustinHarris.JsonRpc.Newtonsoft` | Json.NET 13 serializer. The compatibility choice: `JsonSerializerSettings`, `[JsonProperty]`, converters, lenient input. | +| `AustinHarris.JsonRpc.SystemTextJson` | System.Text.Json serializer. `JsonSerializerOptions`, `Utf8JsonReader`/`Utf8JsonWriter` straight on the request bytes. | +| `AustinHarris.JsonRpc.AspNetCore` | Kestrel hosting: an HTTP endpoint on `PipeReader`/`BodyWriter`, a `ConnectionHandler` for raw TCP/Unix-socket/named-pipe connections, and DI registration of services. | + +## Requirements + +`AustinHarris.JsonRpc`, `.Newtonsoft` and `.SystemTextJson` target: + +| Target | Covers | +| --- | --- | +| `netstandard2.0` | .NET Framework 4.6.1+, .NET Core 2.0+, Mono 5.4+, Xamarin, Unity 2018.1+ | +| `netstandard2.1` | .NET Core 3.0+, Mono 6.4+, Xamarin | +| `net8.0` | .NET 8 (LTS) | +| `net10.0` | .NET 10 (LTS) | + +`AustinHarris.JsonRpc.AspNetCore` targets `net8.0` and `net10.0`. + +Core dependencies: `NonBlocking` 2.1.2 (lock-free dictionary for the session registry) and, on `netstandard` only, `System.Memory`; `netstandard2.0` also references `System.Threading.Tasks.Extensions` for `ValueTask`. The core uses no reflection emit, so it runs under the WebAssembly interpreter, AOT and trimmed builds. + +## Installation ``` -Starting benchmark -processed 50 rpc in 137ms for 364.96 rpc/sec -processed 100 rpc in 0ms for ∞ rpc/sec -processed 300 rpc in 1ms for 300,000.00 rpc/sec -processed 1,200 rpc in 7ms for 171,428.57 rpc/sec -processed 6,000 rpc in 26ms for 230,769.23 rpc/sec -processed 36,000 rpc in 166ms for 216,867.47 rpc/sec -processed 252,000 rpc in 1,121ms for 224,799.29 rpc/sec -Finished benchmark... +dotnet add package AustinHarris.JsonRpc ``` -## Do you like this? +Add `AustinHarris.JsonRpc.Newtonsoft` or `AustinHarris.JsonRpc.SystemTextJson` if you want that serializer, and `AustinHarris.JsonRpc.AspNetCore` to host in Kestrel. -[![https://www.buymeacoffee.com/Ekati](https://cdn.buymeacoffee.com/buttons/default-blue.png)](https://www.buymeacoffee.com/Ekati) +To host inside classic ASP.NET (System.Web) there is also `AustinHarris.JsonRpc.AspNet`, which targets .NET Framework 4.0 only and is built from its own legacy project. +## Getting started -##### Requirements -* dotnet-standard (dotnet core | mono | .net framework) +### 1. Declare a service -##### License -JSON-RPC.net is licensed under The MIT License (MIT), check the [LICENSE](https://github.com/CoiniumServ/JSON-RPC.NET/blob/master/LICENSE) file for details. +Derive from `JsonRpcService` and mark the methods you want to expose with `[JsonRpcMethod]`. Constructing the service registers it with the default session, so you only need to keep the instance alive. + +```csharp +using AustinHarris.JsonRpc; + +public class CalculatorService : JsonRpcService +{ + [JsonRpcMethod] // exposed as "add" + private double add(double l, double r) => l + r; + + [JsonRpcMethod("multiply")] // exposed under an explicit name + public int Multiply(int l, int r) => l * r; + + [JsonRpcMethod] + public string StringMe(string x) => x; +} +``` + +Methods can be `private`; parameters may be positional (`"params":[1,2]`) or named (`"params":{"l":1,"r":2}`). Optional parameters with default values are honoured, and a parameter's JSON name can be overridden with `[JsonRpcParam("name")]`. Any class works, not only `JsonRpcService` subclasses: bind an instance with `ServiceBinder.BindService(sessionId, instance)`. + +A method does not need a class at all. Any delegate becomes a method with `ServiceBinder.BindMethod`; a lambda keeps its parameter names for named params: + +```csharp +ServiceBinder.BindMethod("add", (double l, double r) => l + r); +ServiceBinder.BindMethod("greet", (string who) => "hello " + who); // {"method":"greet","params":{"who":"you"}} +ServiceBinder.BindMethod(sessionId, "scale", (double v, double f) => v * f, + parameterNames: new[] { "value", null }, defaults: new Dictionary { ["f"] = 10 }); +ServiceBinder.UnbindMethod("add"); +``` + +A name already registered on the session is an error (unbind it first); a delegate whose parameter names cannot be recovered (a closed delegate created with `Delegate.CreateDelegate`) gets `arg1`, `arg2`, ... unless you pass names. Task and ValueTask delegates are supported through `ProcessAsync`, see [Asynchronous methods](#asynchronous-methods). + +An interface can define the exposed contract, including a tree of interface-typed properties. Implementation-only methods stay private to the host; parameter names, `[JsonRpcParam]`, aliases and optional defaults come from the interface: + +```csharp +public interface IWorld { ICharacter Character { get; } IAdmin Admin { get; } } +public interface ICharacter { void MoveAndRotate(float distance, float roll = 0, int ticks = 1); } +public interface IAdmin { ICharacter Character { get; } } + +RpcBinding binding = ServiceBinder.BindInterface(sessionId, world); +// Character.MoveAndRotate and Admin.Character.MoveAndRotate +binding.Dispose(); // unbinds this tree, leaving later replacements alone + +using var characterOnly = ServiceBinder.BindInterface(sessionId, world, + new RpcInterfaceBindingOptions { Include = m => m.Path.Length == 1 }); +// Include can also inspect m.Method for the host's own interface attributes. +``` + +Recursion defaults to on. Readable, non-indexed interface properties are evaluated **once per mount at registration**, so getters may have side effects; changing a property afterwards does not replace the captured child. The tree is flattened and compiled at registration, adding no tree traversal or binding cost per request. `Recursive = false` binds only root methods. `Prefix`, `Separator` and invariant `Casing = RpcNameCasing.CamelCase` control generated names; explicit `[JsonRpcMethod("alias")]` leaves remain literal. `NameRule` can return the complete wire name instead. Inherited and explicit implementations and closed generic interfaces work; generic methods and default interface bodies are rejected. Task and ValueTask members are asynchronous registrations served by `ProcessAsync`; `[JsonRpcMethod(ContextFlow = RpcContextFlow.Flow)]` on the interface member opts into context flow across awaits (see [Asynchronous methods](#asynchronous-methods)). + +Registration publishes the whole tree at once or throws without exposing any of it. Empty names, reserved `rpc.` names, duplicates and names already in the session are errors, as are null children, throwing getters, cycles and paths deeper than 32 properties. Getter side effects cannot be rolled back. Keep the handle for unbinding: `Dispose` is idempotent and does not dispose your objects; calls already resolved can finish on their captured implementation. + +### 2. Process requests + +```csharp +using AustinHarris.JsonRpc; + +var service = new CalculatorService(); + +// Strings, asynchronous invocation. +string response = await JsonRpcProcessor.ProcessAsync("{\"jsonrpc\":\"2.0\",\"method\":\"add\",\"params\":[1,2],\"id\":1}"); +// {"jsonrpc":"2.0","result":3.0,"id":1} + +// Strings, synchronous, on the calling thread. +string sync = JsonRpcProcessor.ProcessSync("{\"method\":\"multiply\",\"params\":{\"l\":6,\"r\":7},\"id\":2}"); +// {"jsonrpc":"2.0","result":42,"id":2} + +// Bytes. This is the native path; the string overloads transcode into it. +var output = new ArrayBufferWriter(); +JsonRpcProcessor.Process(Handler.DefaultSessionId(), requestBytes /* ReadOnlySpan, ReadOnlyMemory or ReadOnlySequence */, output); +``` + +Batches (`[{...},{...}]`) and notifications (requests without an `id`) are handled per the spec: a batch answers with an array, a notification produces nothing. + +## Hosting modes + +The core is transport-agnostic. Pick whichever of these fits, or build your own on the byte entry point. + +### In-process (strings or bytes) -##### Installation +The calls above. The byte overloads take what a `PipeReader` gives you (`ReadOnlySequence`) and write to what a `PipeWriter`, a socket buffer or `HttpResponse.BodyWriter` is (`IBufferWriter`). Nothing is written for a notification, so check `output.WrittenCount` before sending. For transports that carry several documents per connection, `JsonFramer.TryReadDocument` slices complete documents out of a byte stream without parsing them. -You can start using JSON-RPC.Net with our [nuget](https://www.nuget.org/packages/AustinHarris.JsonRpc/) package. +### Kestrel HTTP endpoint -To install JSON-RPC.NET Core, run the following command in the Package Manager Console; +```csharp +using AustinHarris.JsonRpc.AspNetCore; +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddJsonRpc(); +builder.Services.AddJsonRpcService(); // built by DI; any class with [JsonRpcMethod] works, controllers included + +var app = builder.Build(); +app.MapJsonRpc("/rpc"); // POST /rpc; compose with RequireAuthorization() etc. +app.Run(); +``` + +The endpoint reads the body from `PipeReader` and writes the response into `BodyWriter`; nothing becomes a string on the way through. A request or batch answers `200 application/json`, a notification `204`. Inside a method `JsonRpcContext.Current().Value` is the `HttpContext`. Enable async service methods with `builder.Services.AddJsonRpc(o => o.EnableAsyncMethods = true)` (default false). HTTP processing passes `RequestAborted` to `ProcessAsync` and keeps the body reader leased until completion. Options (session selection per request, serializer, body size limit, content type) are on `AddJsonRpc(o => ...)`; see the [package README](AustinHarris.JsonRpc.AspNetCore/README.md). + +### Kestrel raw connections (TCP, Unix socket, named pipe) + +```csharp +builder.WebHost.ConfigureKestrel(k => +{ + k.ListenAnyIP(9000, l => l.UseConnectionHandler()); + // k.ListenUnixSocket("/tmp/rpc.sock", l => l.UseConnectionHandler()); + // k.ListenNamedPipe("rpc", l => l.UseConnectionHandler()); +}); +``` + +Clients write JSON documents back to back on the connection (whitespace or a newline between them is fine) and read responses in order; notifications produce nothing. The framer that splits the stream into documents only understands strict JSON: over a raw connection, single-quoted strings and other lenient syntax are not supported even with the Json.NET serializer. With `EnableAsyncMethods = true`, each framed document finishes before the next begins, and completed replies are flushed before waiting for a suspended document. The read buffer stays leased throughout invocation. See [Benchmarks](#benchmarks). + +### Blazor WebAssembly + +The core runs inside the browser. [samples/WasmHost](samples/WasmHost) is a Blazor WebAssembly app where JavaScript hands a request document to a `[JSExport]`/`[JSInvokable]` method that calls `JsonRpcProcessor.ProcessSync` and returns the response, with no HTTP involved. The same service class then serves both the browser and the server. The sample page also benchmarks JSON-RPC against plain Blazor interop; the numbers are in its README. + +### Classic ASP.NET + +`AustinHarris.JsonRpc.AspNet` hosts the 1.x-style `JsonRpcHandler` in System.Web on .NET Framework 4.0. It is built from its own project and unchanged in this release. + +## Configuration + +Everything is on `Config` (process-wide) with per-session overrides. Resolution is per call, then per session, then global. + +### Serializer + +```csharp +// Global +Config.SetSerializer(new SystemTextJsonRpcSerializer(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + +// Per session +Config.SetSerializer("legacy-clients", new NewtonsoftJsonRpcSerializer(new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore })); + +// Per call +string json = JsonRpcProcessor.ProcessSync(sessionId, request, context, serializer); +``` + +The built-in serializer is the default and reproduces Json.NET's wire conventions (member order, `.0` on whole floats, ISO dates, nulls written), so switching is invisible to clients. Library options go into the serializer's constructor and nowhere else: + +| Serializer | Constructor | Notes | +| --- | --- | --- | +| built-in | `new JsmnSerializer(lenient: false, maxDepth: 64)` | `lenient` accepts `'single quotes'`, unquoted keys and trailing commas | +| Json.NET | `new NewtonsoftJsonRpcSerializer(settings)` | one `JsonSerializer` is built from the settings and reused; input is always lenient | +| System.Text.Json | `new SystemTextJsonRpcSerializer(options)` | the package adds its wire-format converters to a copy of your options when they are missing | + +The full contract, what the core fixes versus what a serializer decides, is in [docs/serializers.md](docs/serializers.md). + +### Nesting depth + +Every serializer exposes `MaxDepth` (default 64). A request nested deeper is answered `-32700` before any hook or binding runs, so recursive parameter conversion is bounded by the same number the JSON library itself enforces: the built-in serializer's constructor argument, `JsonSerializerOptions.MaxDepth`, or `JsonSerializerSettings.MaxDepth`. + +### The `jsonrpc` member + +```csharp +Config.VersionPolicy = JsonRpcVersionPolicy.Lenient; // process default +Config.SetVersionPolicy("strict-clients", JsonRpcVersionPolicy.Strict); // per session; null follows the global ``` -PM> Install-Package AustinHarris.JsonRpc + +| Policy | Missing member | `"2.0"` | Anything else | +| --- | --- | --- | --- | +| `Lenient` (default) | accepted | accepted | `-32600 Invalid Request` | +| `Ignore` | accepted | accepted | accepted | +| `Strict` | `-32600 Invalid Request` | accepted | `-32600 Invalid Request` | + +The default keeps tool harnesses that omit the member working while a client speaking another version is told so. `Ignore` is for talking to anything at all. + +### Exception details + +An ordinary exception thrown by a method reaches the client as `-32603` with `error.data = {ClassName, Message}`. Set `Config.IncludeExceptionDetails = true` to also send `Source`, `StackTraceString`, `HResult` and the `InnerException` chain. A `JsonRpcException` thrown by the application always keeps the `data` it was given. + +### Sessions and context + +Sessions let you host independent sets of services (for example one per connected client or tenant): + +```csharp +ServiceBinder.BindService("client-42", new CalculatorService()); // any object with [JsonRpcMethod] members +string response = await JsonRpcProcessor.Process("client-42", request, context); +Handler.DestroySession("client-42"); ``` -To install JSON-RPC.NET AspNet, run the following command in the Package Manager Console +Pass an arbitrary context object through to your methods and read it with `Handler.RpcContext()` or `JsonRpcContext.Current().Value` (the Kestrel package passes the `HttpContext` or `ConnectionContext`): +```csharp +await JsonRpcProcessor.Process(request, context: httpContext); + +[JsonRpcMethod] +private string WhoAmI() => ((HttpContext)Handler.RpcContext()).User.Identity.Name; ``` -PM> Install-Package AustinHarris.JsonRpc.AspNet + +The request's `id` is available the same way, read on demand from the request bytes, so a method that never asks pays nothing: + +```csharp +[JsonRpcMethod] +private string Track() +{ + JsonRpcRequestId id = JsonRpcContext.CurrentRequestId(); // or Handler.RpcRequestId(): an owned snapshot, keep it anywhere + if (id.TryGetInt64(out long n)) { /* integer id */ } + string text = id.GetString(); // string ids (decoded); null otherwise + string digits = id.GetIntegerText(); // integers, including ones wider than Int64 + JsonRpcIdKind kind = Handler.RpcRequestIdKind(); // Integer, String, Null, or Absent for a notification + ReadOnlySpan raw = Handler.RpcRequestIdRaw(); // the id's JSON as sent (`12`, `"abc"`, `null`); a borrow, use it before returning + return id.ToString(); +} ``` +The snapshot is a small struct: an integer id allocates nothing, a string id allocates its decoded string, and the raw span never allocates. Synchronous dispatch keeps context per invocation and per thread; async Flow registrations carry it across sequential awaits (see [Asynchronous methods](#asynchronous-methods)). Take snapshots before parallel work. Nested dispatch sees its own id and restores the parent. A pre-process handler that replaces `JsonRequest.Id` changes what the method sees. A parameter named `id` is an ordinary parameter and binds from `params` only. + +### Errors and hooks +Return a spec-compliant error by throwing `JsonRpcException(code, message, data)`, or shape errors globally or per session: + +```csharp +Config.SetErrorHandler((request, exception) => new JsonRpcException(-32000, "Server error", exception.data)); +Config.SetParseErrorHandler((rawJson, exception) => exception); +Config.SetPreProcessHandler((request, context) => null); // return a JsonRpcException to reject; may replace Method/Params/Id +Config.SetPostProcessHandler((request, response, context) => null); // return a JsonRpcException to replace the result +``` +Registering a pre- or post-process handler switches the affected session onto a slower path that materialises `JsonRequest`/`JsonResponse` objects for the handler; leave them unset when you do not need them. +The errors the library raises itself carry structured `data`, identical for every serializer, and the error handler receives the same object: +| Code | `error.data` | Object seen by the error handler | +| --- | --- | --- | +| `-32601` Method not found | `{"method":""}` | `MethodNotFoundInfo` | +| `-32602` Invalid params: count, missing, unknown or repeated named parameter | a sentence, e.g. `"Named parameter 'b' was not present."` | `string` | +| `-32602` Invalid params: a value the serializer could not convert | `{"reason":"conversion","parameter":"b","index":1,"expectedType":"int32"}` plus `"message"` when `Config.IncludeExceptionDetails` is on; the value sent is never echoed | `ParameterErrorInfo` (with the serializer's exception in `Cause`) | +| `-32603` Internal error: the method threw, or a parameter's type is one the serializer cannot handle | `{ClassName, Message, ...}`, see [Exception details](#exception-details) | `Exception` | + +"Could not convert" means the serializer refused the value (`JsonRpcBindException`, `FormatException`, `OverflowException`, `InvalidCastException`, or any `JsonException` from System.Text.Json or Json.NET); what each serializer accepts (say `"7"` for an `int`) is its own decision, see [docs/serializers.md](docs/serializers.md). + +### Asynchronous methods + +Methods may return `Task`, `Task`, `ValueTask` or `ValueTask`. Call `JsonRpcProcessor.ProcessAsync` to await the operation and serialize its eventual result; the non-generic forms answer JSON `null`. Completed operations run inline. The byte overloads return `Task.CompletedTask` when the whole document completes successfully inline. Batches execute sequentially, and notifications are awaited too. + +```csharp +[JsonRpcMethod("lookup")] +public async Task Lookup(int id, [JsonRpcCancellation] CancellationToken cancellationToken) + => await repository.FindAsync(id, cancellationToken).ConfigureAwait(false); + +await JsonRpcProcessor.ProcessAsync(sessionId, requestMemory, output, + context: requestContext, cancellationToken: cancellationToken); +string response = await JsonRpcProcessor.ProcessAsync(sessionId, requestJson, + context: requestContext, cancellationToken: cancellationToken); +``` + +The byte APIs accept `ReadOnlyMemory`, `ReadOnlySequence` (by value), or `ReadOnlySpan`. Keep borrowed request bytes immutable and valid and the output writer exclusive until the task completes. The span overload copies before returning; segmented sequences are copied too. No output spans are held across awaits. The task covers response writing, not transport flushing. + +The processor token is injected only into a `[JsonRpcCancellation] CancellationToken` parameter; it is excluded from JSON parameters and SMD. An unmarked token parameter is rejected at registration. Synchronous methods may also request injection through `ProcessAsync`; ordinary synchronous processing passes the default token. Cancellation is checked before invocation, between batch elements, and before the staged document is committed. It discards the entire staged response, observes an in-flight operation to completion, releases its resources, and returns a canceled task. A method that ignores the token can therefore delay cancellation. Cancellation cannot undo service side effects. A method's own `OperationCanceledException` follows ordinary error mapping when the processor token has not been canceled. + +Async methods default to `RpcContextFlow.None`: the ambient accessors (`Handler.RpcContext()`, `Handler.RpcRequestId()`, `JsonRpcContext.Current().Value`, `Handler.RpcSetException()`) are valid in the synchronous part of the method, before its first suspension, and an operation that completes inline costs the same as a synchronous call and allocates nothing. Opt a method into `RpcContextFlow.Flow` when it needs the ambient context after an await; then the accessors work across sequential awaits and nested dispatch, each invocation owns a frame that is cleared at terminal completion, and every invocation pays an allocation for the execution-context bridge, completed tasks included: + +```csharp +[JsonRpcMethod(ContextFlow = RpcContextFlow.Flow)] +public async Task Lookup(int id) +{ + var item = await repository.FindAsync(id); + if (item == null) Handler.RpcSetException(new JsonRpcException(-32000, "Not found", null)); + return item; +} + +ServiceBinder.BindMethod(sessionId, "lookup", new Func>(LookupAsync), + contextFlow: RpcContextFlow.Flow); +``` + +In `None` mode the initial synchronous portion can capture the context and an owned `JsonRpcRequestId` snapshot; ambient state does not flow after suspension. Throw authored errors instead of setting ambient error state after an await. For parallel child branches in either mode, capture snapshots and avoid sharing the mutable ambient frame. Background work must retain snapshots because the invocation frame is cleared when the RPC finishes. Use raw ID spans only during the immediate call that reads them; reacquire them after awaiting. + +`async void`, custom awaitables, nested awaitables and asynchronous streams are rejected at registration. Async registrations cannot have by-ref parameters, including the legacy trailing `ref JsonRpcException`. A null returned `Task` is an internal error. Synchronous `Process`/`ProcessSync` reject an async registration at call time with `-32603` and an instruction to use `ProcessAsync`, without invoking it. The older `Task Process(...)` family retains its scheduled synchronous execution through `Task.Factory.StartNew`; it does not await async service methods. + +A job ticket remains useful for work that should outlive the request: + +```csharp +[JsonRpcMethod] private string startExport(string filter) { var job = Jobs.Start(() => ExportAsync(filter)); return job.Id; } +[JsonRpcMethod] private ExportStatus exportStatus(string jobId) => Jobs.Status(jobId); +``` + +The client gets a ticket immediately and polls, or the transport pushes a notification when the job finishes. + +## Benchmarks + +`TestServer_Console` is the benchmark harness. It binds one service with five small methods (`add`, `addInt`, `NullableFloatToNullableFloat`, `Test2`, `StringMe`), drives the same five requests through the server, checks every response is a `result` rather than an error, and ends each mode with a bar chart of RPC/s. + +``` +dotnet run -c Release --project TestServer_Console -- --async 3 1 # real async invocation, Flow and None separately +dotnet run -c Release --project TestServer_Console -- --sync 3 # library only, 1..N threads (add a thread count, e.g. --sync 3 1, for one row) +dotnet run -c Release --project TestServer_Console -- --kestrel 3 # through the AspNetCore package, HTTP and TCP +dotnet run -c Release --project TestServer_Console -- --compare 3 # the same calls through StreamJsonRpc and gRPC for .NET, side by side +dotnet run -c Release --project TestServer_Console -- --sweep 2 benchmarks/charts/sweep.json # every library and transport at 1, 2, 4, 8, 16 connections; one file per run +dotnet run -c Release --project TestServer_Console # menu: Enter = Task mode, s = sync, k = Kestrel, x = compare, q = quit +dotnet run --project samples/WasmHost # browser: "Run benchmark" on the page +``` + +All numbers below are from an AMD Ryzen 7 7800X3D (8 cores / 16 threads, 4.2 GHz), 64 GB, Windows 11, .NET 10, Release, Server GC, with the built-in serializer, measured 2026-09-23. Where a row gives two figures they are the spread over that day's runs on an otherwise idle machine; the WSL virtual machine, which takes 15 to 25 % of the box when idle, was shut down for the Kestrel and comparison runs. A single benchmark thread on this machine varies with whatever else lands on its core's SMT sibling, so the 1-thread rows are from runs on an idle core. + +### Sync: the library alone + +`--sync` calls the byte-level `JsonRpcProcessor.Process` in a loop from 1, 2, 4, ... threads up to the core count, so it measures parsing, dispatch, binding and response writing with no scheduler in the way. + + + + JSON-RPC.Net alone, by worker threads: aggregate requests per second as a low-to-high band, and the reported ns per request per thread + + +| Threads | RPC/s | ns per request per thread | Allocations per request | +| ---: | ---: | ---: | --- | +| 1 | 4.5 M to 4.6 M | 217 | 0 bytes for numeric shapes, one string for `StringMe` | +| 2 | 7.6 M to 9.5 M | 222 | | +| 4 | 16.4 M to 17.4 M | 230 | | +| 8 | 25.1 M to 26.8 M | 298 | | +| 16 | 30.6 M to 35.8 M | 446 | | + +Per-thread cost rises with thread count because the 16 threads share 8 physical cores. + +The 2026-09-23 performance pass (compiled invokers that read the tokens and write the pooled buffer through direct calls instead of virtual, delegate and interface calls; a tokenizer that keeps its scanner state in locals; a last-session cache; envelope keys matched by length; a flat method table) was measured A/B in one session: the same seven runs of `--sync 2 1` went from 3.2 M to 4.1 M (median 3.6 M) before to 4.0 M to 4.8 M (median 4.4 M) after, about 20 to 25 % more on one thread. The transport rows below are bound by the loopback round trips rather than by the library and moved less. + +### Asynchronous invocation + +`--async [seconds] [workers]` uses `ProcessAsync` with separate sessions and the same five wire names as `--sync`. It reports synchronous returns through the new API, completed `Task`, and inline `ValueTask`, with Flow and None registrations reported separately. A separate `yieldsOnce` shape awaits `Task.Yield()`. Responses and IDs are validated before timing. Inline allocation totals use current-thread accounting; the timed runs also report process-wide allocations to include suspended continuations. These totals include allocations made by the service methods. No throughput figures are published here yet. + +### Task: scheduled synchronous execution + +The default mode submits batches through the `Task`-returning `Process` overload from every core at once, the way an async host would, so it pays for the thread-pool hop, a `Task`, a result string and a continuation per request. Each batch size is repeated for at least half a second after a one-second warm-up. Throughput peaks once a batch is large enough to keep every core busy and falls off again when hundreds of thousands of requests are queued at once: + +| Batch size | RPC/s | +| ---: | ---: | +| 50 | 1.5 M | +| 300 | 7.8 M | +| 6,000 | 10.9 M | +| 36,000 | 12.0 M | +| 252,000 | 7.6 M | +| 2,016,000 | 7.2 M | + +Sync beats Task mode because Task mode measures the .NET thread pool and per-request garbage as much as the library; Kestrel awaits transport reads and flushes without dedicating a thread to each connection. + +### Kestrel: through the AspNetCore package + +`--kestrel` starts a real Kestrel on loopback with `MapJsonRpc` and `JsonRpcConnectionHandler`, then drives it with 16 clients on the same machine, so every figure is an upper bound for one box talking to itself. The in-process rows are the same requests through the byte entry point with no transport at all, for scale. + + + + JSON-RPC.Net by transport: HTTP single, HTTP batch of 100 and TCP pipelined, as low-to-high intervals on a log axis, with the in-process figure for scale + + +| Transport | RPC/s | Note | +| --- | ---: | --- | +| in-process, 16 threads | 30.8 M to 31.3 M | | +| HTTP, 1 request per POST | 128 k to 168 k | 95 to 125 µs per round trip per client depending on the run; HTTP/1.1 request-response is the cost, not the server | +| HTTP, batch of 100 per POST | 12.7 M to 13.7 M | | +| TCP, 256 pipelined | 15.2 M to 15.5 M | ring-buffer clients, one thread each, streaming framer | + +The TCP client keeps 256 requests in flight per connection and refills from a precomputed ring of request bytes with one `Send` per refill; the server side is the same `Process` call the HTTP endpoint makes, fed by `JsonFramer`. + +### Versus StreamJsonRpc and gRPC + +[StreamJsonRpc](https://www.nuget.org/packages/StreamJsonRpc) is Microsoft's JSON-RPC library, the one behind Visual Studio and the language-server stack. `--compare` hosts both libraries on the same Kestrel TCP listener and drives them with the same pipelining client (16 connections, 256 requests in flight each), so the only variable is the library answering. StreamJsonRpc requires the `jsonrpc` member, so every request in this mode carries `"jsonrpc":"2.0"`, which is why the JSON-RPC.Net rows are a little below the other tables. The same mode also hosts [gRPC for .NET](https://learn.microsoft.com/aspnet/core/grpc/) (HTTP/2, protobuf) on the same Kestrel, answering the same five calls from [calculator.proto](TestServer_Console/Protos/calculator.proto), driven by its own client with the same shape: 16 channels, 256 calls in flight each. Each row was run twice for 3 s; both results are shown. + + + + JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET at 16 connections: low-to-high intervals on a log axis, grouped by library, with the in-process paths under a rule + + +| Library and path | RPC/s | +| --- | ---: | +| JSON-RPC.Net over Kestrel TCP, raw documents | 13.7 M to 14.6 M | +| StreamJsonRpc over Kestrel TCP, newline framing, System.Text.Json formatter | 1.38 M to 1.44 M | +| StreamJsonRpc over Kestrel TCP, `Content-Length` framing, System.Text.Json formatter | 1.41 M to 1.45 M | +| StreamJsonRpc over Kestrel TCP, `Content-Length` framing, Json.NET formatter (its default) | 625 k | +| gRPC for .NET, unary calls over HTTP/2 (Grpc.Net.Client, 16 channels × 256 in flight) | 192 k to 198 k | +| gRPC for .NET, one bidirectional stream per channel, 256 in flight, batched writes | 200 k to 209 k | + +StreamJsonRpc 2.25.29, defaults apart from the formatter and framing named in each row. It is a full bidirectional RPC framework (client proxies, cancellation, progress, marshaled objects, events). The comparison is of the server side answering the same five requests; on that measure JSON-RPC.Net is about 10× faster on the same connections with the same JSON library underneath. + +
+In-process paths: a direct call, a Pipe pair and a typed proxy (different boundaries, not comparable with the rows above) + + + + In-process paths: JSON-RPC.Net direct call, StreamJsonRpc Pipe pair and StreamJsonRpc typed proxy, as low-to-high intervals on a log axis + + +| Path | RPC/s | +| --- | ---: | +| JSON-RPC.Net in-process, 1 thread (direct call, bytes in, bytes out) | 2.6 M to 3.6 M | +| StreamJsonRpc in-process, 1 client over a `Pipe` pair, newline framing, System.Text.Json formatter, 256 pipelined | 117 k to 142 k | +| StreamJsonRpc typed proxy, sequential `await` per call, in-process pipes | 96 k to 97 k (10 µs per round trip) | + +StreamJsonRpc's server side has no "document in, document out" call, so its in-process row is a pair of `System.IO.Pipelines` pipes, the closest it has to a direct call; the proxy row is one call at a time, so it measures a round trip, not throughput. The direct-call row is from the comparison session and sits below the sync table's newer 1-thread figure. + +
+ +gRPC for .NET 2.84.0 with default settings apart from Kestrel's `MaxStreamsPerConnection` (raised to 256 so the pipeline depth is not capped at 100). protobuf has no `decimal`, so `Test2` carries the units/nanos `DecimalValue` message the gRPC docs recommend; nullable values use proto3 `optional`. The gRPC rows are a different kind of measurement from the rows above them: there is no cheap raw client for HTTP/2 + protobuf, so the client is Grpc.Net.Client on the same 8 cores as the server, and the figure is what a .NET caller and a .NET service get end to end. One channel alone reaches about 130 k unary calls per second; sixteen channels do not scale much further because client and server compete for the same cores. The streaming row batches its writes the way the TCP client does (BufferHint on every message but the last of a refill), and the server flushes only when its input runs dry, the same once-per-read-group flush `JsonRpcConnectionHandler` does. + + + + Every library and transport by client connections, 1 to 16: three panels on a shared log axis, one per library, with a marker shape and dash per setting and whiskers spanning two runs + + +`--sweep` runs every one of those paths at 1, 2, 4, 8 and 16 client connections (gRPC: channels) and writes one JSON file per run; the chart above is five 2 s runs per point, the marker at the median and the whisker from the lowest to the highest run. It is a separate session from the tables: the WSL virtual machine was running and other work was active, so its absolute figures sit below the table rows (JSON-RPC.Net over TCP 10.9 M to 13.0 M at 16 connections against 13.7 M to 14.6 M in the table), and its gRPC unary figure runs higher (360 k to 404 k against 192 k to 198 k; the cause is not pinned down, and the table keeps the `--compare` figure). What the sweep adds is the shape: JSON-RPC.Net over TCP and batched HTTP climb almost linearly with connections, StreamJsonRpc gains 8 to 10× from one connection to sixteen, and gRPC's .NET client is flat from two channels on because it competes with the server for the same eight cores. + +The [benchmark explorer](https://astn.github.io/JSON-RPC.NET/) is the same data as an interactive page: toggle series, hover or tab to a point for the exact low, median, high and every run, switch the axis between log and linear, and download the data. It is one self-contained HTML file, [benchmarks/charts/explorer.html](benchmarks/charts/explorer.html), so it also works saved to disk. + +### WebAssembly: in the browser + +The [WasmHost sample](samples/WasmHost/README.md) compares JSON-RPC through JS interop with plain Blazor interop for the same `add(1, 2)` in Chrome, under the .NET 10 interpreter and AOT-compiled; its README has the table and a chart. Interpreted, a plain `DotNet.invokeMethod` add costs about 64 µs (the JSON marshalling Blazor does), a JSON-RPC document written as UTF-8 straight into WebAssembly memory and run through a `[JSExport]` costs 53 µs, and a batch of 100 that way reaches 27 k RPC/s. AOT-compiled (`dotnet publish` with the `wasm-tools` workload) the same three are 15 µs, 7 µs and 210 k RPC/s; a typed `[JSExport]` add takes 0.3 µs either way. + +### simdjson + +simdjson was evaluated as a fourth parser and not adopted: through the only maintained .NET binding its parse alone costs more than the whole built-in envelope read, and walking the result is 5 to 6 times slower with 350 bytes or more of garbage per request. The harness and numbers are in [benchmarks/SimdJsonEval/RESULTS.md](benchmarks/SimdJsonEval/RESULTS.md). + +The charts, the explorer page and the figures in this file come from one data file, [benchmarks/charts/benchmarks.json](benchmarks/charts/benchmarks.json): the tables above transcribed with their published precision and conditions, plus the `--sweep` run files. `python benchmarks/charts/render.py` (plain Python, no packages) renders every chart in a light and a dark variant, which the README picks between with a `` element, and `render.py --check` fails if a committed chart is stale or a figure in this file no longer matches the data; the pull-request build runs it. GitHub serves README images through a proxy as plain ``, so the SVGs carry no scripts, hover text or links, and every range is drawn as an interval with its figures beside it; the interactive parts live on the explorer page. + +For comparison, under the previous harness (one pass per batch, workstation GC) the two-million batch ran at about 525,000 RPC/s on 1.3 and 1,584,906 RPC/s on 2.0 on the same machine. (The 1.x figure published earlier in this README was measured while the benchmark service was not bound, so every request took the "method not found" path; the benchmark now prints the responses so that cannot go unnoticed.) + +## Upgrading from 1.x + +- `JsonRpcProcessor.Process(…, JsonSerializerSettings)` is gone from the core. Use `Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings))` from the Newtonsoft package. +- The default-session string overloads that take a serializer take it first: `Process(serializer, json, context)` / `ProcessSync(serializer, json, context)`. The session overloads keep `(sessionId, json, context, serializer)` with `context` required, so `Process(json, null)` still means the default session. +- `JsonRequest`, `JsonResponse` and `JsonRpcException` are plain DTOs without Json.NET attributes. `JsonRequest.Params` is the active serializer's object model, so cast to `JObject`/`JArray` only when the Json.NET serializer is active. +- The `jsonrpc` member is checked (`Config.VersionPolicy`, default `Lenient`): a missing member is still accepted, but `"jsonrpc":"1.0"` or a non-string value is now `-32600`. Set `Ignore` for the 1.x behaviour. +- Requests nested deeper than 64 levels are `-32700` (configurable per serializer, see [Nesting depth](#nesting-depth)). Invalid UTF-8 and non-strict JSON (unless the serializer is lenient) are `-32700` as well. +- The empty-batch error code is the spec's `-32600` (it was `3200`). Batches made only of notifications produce an empty response instead of `[]` with a dangling comma. +- A batch always answers with a JSON array when it produces at least one response; a one-request batch is no longer unwrapped to a bare response object. +- A notification (a request without an `id`) never gets a wire response, whatever its outcome: method not found, binding failure or an exception in the method produce nothing on the wire (the error handler still runs server-side). An invalid request object is not a notification and still gets `-32600` with `"id":null`. +- Exception details are redacted by default; see [Exception details](#exception-details). +- Task-returning methods are supported again through `ProcessAsync`, together with `ValueTask` and `ValueTask`. Synchronous `Process`/`ProcessSync` reject them at call time without invoking them. `async void` remains rejected at registration. See [Asynchronous methods](#asynchronous-methods). +- A parameter value the serializer cannot convert (`"abc"` for an `int`, `"not-a-guid"` for a `Guid`) is `-32602` with `data = {"reason":"conversion","parameter":…,"index":…,"expectedType":…}`; it was `-32603` with the exception. An exception of the same type thrown inside the method is still `-32603`. A type the built-in serializer cannot handle at all stays `-32603` (now a `NotSupportedException`). +- `-32601`'s `data` is `{"method":""}` instead of the fixed sentence, and a method-not-found error for a notification now reaches the error handler (the wire still gets nothing). +- The invocation frame also carries the request id: `Handler.RpcRequestId()` / `JsonRpcContext.CurrentRequestId()`, `Handler.RpcRequestIdKind()` and `Handler.RpcRequestIdRaw()`, see [Sessions and context](#sessions-and-context). +- `ServiceBinder.BindMethod(sessionId, name, delegate)` registers any delegate; it refuses a name that is already registered, unlike `Handler.RegisterFuction`, which keeps replacing silently. +- Named parameters are checked against the method's parameter list: a supplied name that matches no parameter, or a name supplied twice, is `-32602` (it used to be ignored, so `optional(int a = 9)` called with `{"typo":4}` returned 9). Defaults fill only the names that are absent. +- `SMD.Services` is an `SMDServiceCollection` (an `IDictionary`) instead of a `Dictionary`, and its setter is gone. Every mutation through it updates the dispatch table at once, so a removed method is unreachable immediately. +- `SMD.Types` is a process-wide registry (it was reset whenever a session was created). +- A pre-process handler may replace `JsonRequest.Method`, `Params` or `Id`; the replaced request is what gets dispatched (as in 1.x). Assign a new `Params` value rather than editing the serializer's object model in place: a request the handler leaves untouched is dispatched straight from the request bytes. +- `JsonRpcContext.Current()` / `Handler.RpcContext()` and `JsonRpcContext.SetException` are per invocation: a method that synchronously processes another request through `JsonRpcProcessor` gets its own context and exception state back afterwards. +- `DateTime` and `DateTimeOffset` are written the way Json.NET writes them by every serializer (fraction only when non-zero, `Z`/offset/nothing by `Kind`); `NaN` and the infinities are written as the quoted strings `"NaN"`, `"Infinity"`, `"-Infinity"` and read back from them. + +## Building + +Requires the .NET 10 SDK (pinned in `global.json`) and the .NET 8 runtime for the `net8.0` test target. + +``` +dotnet build AustinHarris.JsonRpc.sln +dotnet test AustinHarris.JsonRpcTestN +``` + +The test suite runs its protocol cases once per serializer (built-in, Json.NET, System.Text.Json) plus the parser, dispatch, version-policy and Kestrel integration tests, on both `net8.0` and `net10.0`. Building a package project in Release produces its NuGet package in `bin/Release/`. The WebAssembly sample builds without the `wasm-tools` workload; add it for AOT. + +## Do you like this? + +[![https://www.buymeacoffee.com/Ekati](https://cdn.buymeacoffee.com/buttons/default-blue.png)](https://www.buymeacoffee.com/Ekati) + +##### License +JSON-RPC.net is licensed under The MIT License (MIT), check the [LICENSE](https://github.com/Astn/JSON-RPC.NET/blob/master/LICENSE) file for details. ##### Getting Started & Documentation diff --git a/TestServer_Console/AsyncBenchmark.cs b/TestServer_Console/AsyncBenchmark.cs new file mode 100644 index 0000000..7046e73 --- /dev/null +++ b/TestServer_Console/AsyncBenchmark.cs @@ -0,0 +1,103 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; + +namespace TestServer_Console; + +internal static class AsyncBenchmark +{ + internal static async Task RunAsync(Action print, double seconds, int workers) + { + if (seconds <= 0 || workers <= 0) throw new ArgumentOutOfRangeException(nameof(seconds)); + var inputs = BenchmarkRunner.taskInputs.Select(t => (ReadOnlyMemory)Encoding.UTF8.GetBytes(t)).ToArray(); + var expected = BenchmarkRunner.taskInputs.Select(t => JsonRpcProcessor.ProcessSync(t)).ToArray(); + foreach (var shape in new[] { "sync", "Task", "ValueTask", "yield" }) + foreach (var flow in shape == "sync" ? new[] { RpcContextFlow.None } : new[] { RpcContextFlow.Flow, RpcContextFlow.None }) + { + string session = "async-benchmark-" + shape + "-" + flow; + try + { + Register(session, shape, flow); + var rowInputs = shape == "yield" ? new[] { (ReadOnlyMemory)Encoding.UTF8.GetBytes("{\"method\":\"yieldsOnce\",\"id\":6}") } : inputs; + var rowExpected = shape == "yield" ? new[] { "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":6}" } : expected; + using (var output = new PooledByteBufferWriter()) + { + for (int i = 0; i < rowInputs.Length; i++) + { + output.Clear(); + await JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); + if (output.ToString() != rowExpected[i]) throw new InvalidOperationException("Unexpected response: " + output); + for (int warm = 0; warm < 500; warm++) { output.Clear(); await JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); } + if (shape != "yield") + { + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int n = 0; n < 2000; n++) + { + output.Clear(); + var task = JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); + if (!task.IsCompleted) throw new InvalidOperationException("Expected inline completion."); + task.GetAwaiter().GetResult(); + } + long totalBytes = GC.GetAllocatedBytesForCurrentThread() - before; + print($"{shape} / {flow} / shape {i + 1}: {totalBytes} B total over 2000 requests ({totalBytes / 2000.0:F1} B/RPC), including method allocations."); + } + } + } + var start = Stopwatch.GetTimestamp(); + long beforeProcess = GC.GetTotalAllocatedBytes(true); + var counts = await Task.WhenAll(Enumerable.Range(0, workers).Select(_ => Task.Run(async () => + { + long count = 0; + using var output = new PooledByteBufferWriter(); + while ((Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency < seconds) + { + output.Clear(); + await JsonRpcProcessor.ProcessAsync(session, rowInputs[count % rowInputs.Length], output).ConfigureAwait(false); + count++; + } + return count; + }))); + double elapsed = (Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency; + long allocations = GC.GetTotalAllocatedBytes(true) - beforeProcess; + print($"{shape} / {flow}: {counts.Sum():N0} RPCs, {workers} workers, {elapsed:F3} s, {counts.Sum() / elapsed:N0} RPC/s; process allocation total {allocations:N0} B."); + } + finally { Handler.DestroySession(session); } + } + } + + private static void Register(string session, string shape, RpcContextFlow flow) + { + void Bind(string name, Delegate method) => ServiceBinder.BindMethod(session, name, method, contextFlow: flow); + if (shape == "yield") { Bind("yieldsOnce", new Func>(YieldOnce)); return; } + if (shape == "sync") + { + Bind("add", new Func((l, r) => l + r)); + Bind("addInt", new Func((l, r) => l + r)); + Bind("NullableFloatToNullableFloat", new Func(a => a)); + Bind("Test2", new Func(x => x)); + Bind("StringMe", new Func(x => x)); + } + else if (shape == "Task") + { + Bind("add", new Func>((l, r) => Task.FromResult(l + r))); + Bind("addInt", new Func>((l, r) => Task.FromResult(l + r))); + Bind("NullableFloatToNullableFloat", new Func>(a => Task.FromResult(a))); + Bind("Test2", new Func>(x => Task.FromResult(x))); + Bind("StringMe", new Func>(x => Task.FromResult(x))); + } + else + { + Bind("add", new Func>((l, r) => new ValueTask(l + r))); + Bind("addInt", new Func>((l, r) => new ValueTask(l + r))); + Bind("NullableFloatToNullableFloat", new Func>(a => new ValueTask(a))); + Bind("Test2", new Func>(x => new ValueTask(x))); + Bind("StringMe", new Func>(x => new ValueTask(x))); + } + } + + private static async Task YieldOnce() { await Task.Yield(); return 7; } +} diff --git a/TestServer_Console/Benchmark.cs b/TestServer_Console/Benchmark.cs new file mode 100644 index 0000000..c332361 --- /dev/null +++ b/TestServer_Console/Benchmark.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; + +namespace TestServer_Console; + +public class BenchmarkRunner +{ + private static readonly StringBuilder sb = new StringBuilder(4096); + + // Serialises every render of the Task benchmark. The progress timer fires on pool threads every 250 ms + // whether or not the previous render (a console clear-and-redraw) has finished, and the main thread + // renders the final box itself; without this they interleave on `sb` and on the console cursor. + private static readonly object renderLock = new object(); + + private static int completed; // RPCs finished in the current iteration (Interlocked / Volatile) + private static int currentBatches; // batches finished in the current iteration + private static volatile bool benchmarkRunning; + private static System.Threading.Timer updateTimer; + + private static int currentIteration; + private static int currentBatchSize; + private static Stopwatch currentStopwatch; + private static long currentPerTaskBytesIn; + private static long currentPerTaskBytesOut; + private static Action _print = Console.WriteLine; + internal static readonly string[] taskInputs = + [ + "{\"method\":\"add\",\"params\":[1,2],\"id\":1}", + "{\"method\":\"addInt\",\"params\":[1,7],\"id\":2}", + "{\"method\":\"NullableFloatToNullableFloat\",\"params\":[1.23],\"id\":3}", + "{\"method\":\"Test2\",\"params\":[3.456],\"id\":4}", + "{\"method\":\"StringMe\",\"params\":[\"Foo\"],\"id\":5}" + ]; + + /// + /// Direct synchronous throughput: N threads each call the byte-in/byte-out processor in a tight loop for + /// . This measures the library itself (parse, bind, invoke, serialize) with no + /// Task scheduling in the way, which is the number the 2.0 design is tuned for. + /// + internal static void BenchmarkSync(Action print = null, JsonRpcSerializer serializer = null, int threads = 0, double seconds = 3) + { + _print = print ??= Console.WriteLine; + serializer ??= Config.Serializer; + if (threads <= 0) threads = Environment.ProcessorCount; + + var session = Handler.DefaultSessionId(); + var inputs = taskInputs.Select(t => (ReadOnlyMemory)Encoding.UTF8.GetBytes(t)).ToArray(); + long bytesIn = inputs.Sum(i => (long)i.Length); + long bytesOut = 0; + foreach (var input in inputs) + { + using var w = new PooledByteBufferWriter(256); + JsonRpcProcessor.Process(session, input, w, null, serializer); + bytesOut += w.WrittenCount; + } + + // warm up: JIT, type plans, compiled invokers + RunSync(session, inputs, serializer, Math.Min(threads, 2), 0.5, out _); + + // allocation profile per request shape (a zero here means the request never touches the GC) + { + using var w = new PooledByteBufferWriter(1024); + sb.Clear(); + sb.Append("Allocated bytes per RPC (").Append(serializer.Name).Append("):\n"); + for (int k = 0; k < inputs.Length; k++) + { + const int reps = 10000; + for (int r = 0; r < 100; r++) { w.Clear(); JsonRpcProcessor.Process(session, inputs[k], w, null, serializer); } + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int r = 0; r < reps; r++) { w.Clear(); JsonRpcProcessor.Process(session, inputs[k], w, null, serializer); } + long per = (GC.GetAllocatedBytesForCurrentThread() - before) / reps; + w.Clear(); JsonRpcProcessor.Process(session, inputs[k], w, null, serializer); + sb.Append(" ").Append(per.ToString().PadLeft(6)).Append(" B ").Append(taskInputs[k]).Append(" -> ").Append(w.ToString()).Append('\n'); + } + _print(sb.ToString()); + } + + // Sweep 1, 2, 4, ... up to the requested thread count (the count itself is always included). + var threadCounts = new List(); + for (int t = 1; t < threads; t *= 2) threadCounts.Add(t); + threadCounts.Add(threads); + var rows = new List(threadCounts.Count); + + foreach (var t in threadCounts) + { + var elapsed = RunSync(session, inputs, serializer, t, seconds, out long total); + double rps = total / elapsed; + double mbIn = rps * bytesIn / inputs.Length / (1024.0 * 1024.0); + double mbOut = rps * bytesOut / inputs.Length / (1024.0 * 1024.0); + rows.Add(new ChartRow($"{t} thread{(t == 1 ? "" : "s")}", rps, $"{1e9 / rps * t:N0} ns/RPC per thread")); + + // The detailed boxes are only printed for the two documented points: one thread and all threads. + if (t != 1 && t != threads) continue; + + sb.Clear(); + const string Reset = "\u001b[0m"; + const string Cyan = "\u001b[36m"; + const string Blue = "\u001b[34m"; + const string Green = "\u001b[32m"; + int boxWidth = Console.IsOutputRedirected ? 100 : Math.Max(60, Console.WindowWidth); + string header = $"Sync benchmark - {serializer.Name} - {t} thread{(t == 1 ? "" : "s")}"; + sb.Append(Cyan).Append("┌── ").Append(header).Append(" ─") + .Append(new string('─', Math.Max(0, boxWidth - header.Length - 6))).Append(Reset).Append('\n'); + AppendField(sb, "Total RPCs", $"{total:N0}", Blue); + AppendField(sb, "Elapsed", $"{elapsed:F3} s", Blue); + AppendField(sb, "RPC/s", $"{rps:N0}", Green); + AppendField(sb, "ns / RPC", $"{1e9 / rps * t:N0} (per thread)", Green); + AppendField(sb, "In MBps", $"{mbIn:F2}", Green); + AppendField(sb, "Out MBps", $"{mbOut:F2}", Green); + sb.Append(Cyan).Append("└").Append(new string('─', boxWidth - 2)).Append(Reset).Append('\n'); + _print(sb.ToString()); + } + + PrintBarChart($"Sync benchmark - {serializer.Name} - RPC/s by thread count", "Threads", rows); + } + + internal static double RunSync(string session, ReadOnlyMemory[] inputs, JsonRpcSerializer serializer, int threads, double seconds, out long total) + { + var counts = new long[threads * 16]; // padded to avoid false sharing + var stop = false; + var ready = new Barrier(threads + 1); + var workers = new Thread[threads]; + for (int t = 0; t < threads; t++) + { + int slot = t * 16; + workers[t] = new Thread(() => + { + var output = new PooledByteBufferWriter(1024); + ready.SignalAndWait(); + long n = 0; + int i = 0; + while (!Volatile.Read(ref stop)) + { + output.Clear(); + JsonRpcProcessor.Process(session, inputs[i], output, null, serializer); + if (++i == inputs.Length) i = 0; + n++; + } + counts[slot] = n; + }) { IsBackground = true }; + workers[t].Start(); + } + ready.SignalAndWait(); + var sw = Stopwatch.StartNew(); + Thread.Sleep(TimeSpan.FromSeconds(seconds)); + Volatile.Write(ref stop, true); + foreach (var w in workers) w.Join(); + sw.Stop(); + total = 0; + for (int t = 0; t < threads; t++) total += counts[t * 16]; + return sw.Elapsed.TotalSeconds; + } + + internal static void Benchmark(Action print = null) + { + // get current console position + _print = print ??= Console.WriteLine; + + long batchInputSize = taskInputs.Sum(t => Encoding.Default.GetByteCount(t)); + long batchResultSize = + taskInputs.Sum(input => Encoding.Default.GetByteCount(JsonRpcProcessor.Process(input).Result)); + + long perTaskBytesIn = batchInputSize / 5; + long perTaskBytesOut = batchResultSize / 5; + + var iterations = 8; + var cnt = 50; + var results = new List(iterations); + var minIterationTime = TimeSpan.FromMilliseconds(500); + + // Warm up on the exact path the iterations use, for long enough that tiered compilation has replaced + // the tier-0 code with optimised (PGO-instrumented) code. Without this the small early iterations + // finish in microseconds and measure JIT and tier-0 code, so their RPC/s jump around by 2x per run. + var warm = Stopwatch.StartNew(); + while (warm.Elapsed < TimeSpan.FromSeconds(1)) RunBatch(5000); + + for (int iteration = 1; iteration <= iterations; iteration++) + { + cnt *= iteration; + completed = 0; + currentBatches = 0; + benchmarkRunning = true; + + currentIteration = iteration; + currentBatchSize = cnt; + currentPerTaskBytesIn = perTaskBytesIn; + currentPerTaskBytesOut = perTaskBytesOut; + currentStopwatch = Stopwatch.StartNew(); + // Live updates every 250 ms. StopProgressTimer() below guarantees no tick is still running here. + updateTimer = new System.Threading.Timer(OnProgressTick, null, 250, 250); + + // A batch of 50 completes in microseconds, well under the stopwatch's and the thread pool's noise + // floor. Repeat the batch until the iteration has run for at least minIterationTime so every row + // of the chart is a real measurement; RPC/s is total requests over total time. + do + { + RunBatch(cnt); + Interlocked.Increment(ref currentBatches); + } while (currentStopwatch.Elapsed < minIterationTime); + currentStopwatch.Stop(); + + // Wait for any in-flight tick before printing the final box, then close the iteration under the + // lock so a tick that was queued but not yet started sees benchmarkRunning == false and skips. + StopProgressTimer(); + lock (renderLock) + { + PrintBenchmarkProgress(); + benchmarkRunning = false; + } + + double seconds = currentStopwatch.Elapsed.TotalSeconds; + long total = (long)cnt * currentBatches; + results.Add(new ChartRow($"#{iteration} {cnt,11:N0}", total / seconds, $"{total,11:N0} RPCs in {seconds:F3} s")); + } + + lock (renderLock) + { + PrintBarChart("Benchmark Summary - RPC/s by batch size", " # Batch size", results); + } + } + + /// + /// Submits requests through the Task-returning overload from all cores and waits + /// for the last one to finish. Nothing is retained: the old harness kept every Task and its result string + /// in an array until the batch ended, which for the two-million batch meant hundreds of megabytes of live + /// objects being promoted through the GC generations, and the big batches came out slower than the small + /// ones for that reason alone. + /// + private static void RunBatch(int count) + { + int pending = count; + using var done = new ManualResetEventSlim(false); + Action> onDone = _ => + { + Interlocked.Increment(ref completed); + if (Interlocked.Decrement(ref pending) == 0) done.Set(); + }; + + Parallel.For(0, count, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount * 2 }, i => + { + JsonRpcProcessor.Process(Handler.DefaultSessionId(), taskInputs[i % 5]) + .ContinueWith(onDone, TaskContinuationOptions.ExecuteSynchronously); + }); + + done.Wait(); + } + + /// One bar of a chart: a left label, the RPC/s the bar is proportional to, and trailing text. + internal readonly record struct ChartRow(string Label, double RpcPerSec, string Trailing); + + /// + /// A horizontal bar per row, scaled to the fastest row, with the RPC/s figure and the row's trailing text + /// (elapsed time, ns per request, ...) on the right. Fits the console width; plain text when redirected. + /// + internal static void PrintBarChart(string header, string labelHeader, List rows) + { + if (rows.Count == 0) return; + + const string Reset = "\u001b[0m"; + const string Cyan = "\u001b[36m"; + const string Blue = "\u001b[34m"; + const string Green = "\u001b[32m"; + const string Dim = "\u001b[2m"; + + int boxWidth = Console.IsOutputRedirected ? 100 : Math.Max(60, Console.WindowWidth); + double max = rows.Max(r => r.RpcPerSec); + int labelWidth = Math.Max(labelHeader.Length, rows.Max(r => r.Label.Length)); + int trailingWidth = rows.Max(r => r.Trailing.Length); + + // "│ " + label + " │" + bar + "│ " + rps(10) + " " + trailing + int fixedWidth = 2 + labelWidth + 2 + 2 + 10 + 2 + trailingWidth; + int barWidth = Math.Max(10, boxWidth - fixedWidth); + + sb.Clear(); + sb.Append(Cyan).Append("┌── ").Append(header).Append(" ─") + .Append(new string('─', Math.Max(0, boxWidth - header.Length - 6))).Append(Reset).Append('\n'); + sb.Append("│ ").Append(Blue).Append(labelHeader.PadRight(labelWidth)).Append(Reset).Append(" │") + .Append(Dim).Append(Truncate("RPC/s, scaled to the fastest row", barWidth).PadRight(barWidth)).Append(Reset) + .Append("│ ").Append(Blue).Append("RPC/s".PadLeft(10)).Append(Reset).Append('\n'); + + foreach (var r in rows) + { + int filled = max > 0 ? (int)Math.Round(barWidth * r.RpcPerSec / max) : 0; + sb.Append("│ ").Append(Blue).Append(r.Label.PadRight(labelWidth)).Append(Reset).Append(" │") + .Append(Green).Append(new string('█', filled)).Append(Reset) + .Append(Dim).Append(new string('░', barWidth - filled)).Append(Reset) + .Append("│ ").Append(r.RpcPerSec.ToString("N0").PadLeft(10)) + .Append(Dim).Append(" ").Append(r.Trailing).Append(Reset).Append('\n'); + } + + sb.Append(Cyan).Append("└").Append(new string('─', boxWidth - 2)).Append(Reset).Append('\n'); + _print(sb.ToString()); + } + + private static string Truncate(string s, int width) => s.Length <= width ? s : s.Substring(0, width); + + private static void OnProgressTick(object _) + { + // If the previous render is still on screen, drop this tick instead of queueing behind it: the + // console redraw can take longer than the timer period, and stacked ticks would only garble output. + if (!Monitor.TryEnter(renderLock)) return; + try + { + if (benchmarkRunning) PrintBenchmarkProgress(); + } + finally + { + Monitor.Exit(renderLock); + } + } + + private static void StopProgressTimer() + { + var timer = updateTimer; + updateTimer = null; + if (timer == null) return; + using var drained = new ManualResetEvent(false); + // Dispose(WaitHandle) signals once every callback that has started has completed. + if (timer.Dispose(drained)) drained.WaitOne(); + } + + private static void PrintBenchmarkProgress() + { + int comp = Volatile.Read(ref completed); + int batches = Volatile.Read(ref currentBatches); + + double elapsedSec = currentStopwatch.Elapsed.TotalSeconds; + double rpcPerSec = elapsedSec > 0 ? comp * 1000.0 / currentStopwatch.ElapsedMilliseconds : 0; + + long bytesInProcessed = (long)comp * currentPerTaskBytesIn; + long bytesOutProcessed = (long)comp * currentPerTaskBytesOut; + + double mbpsIn = elapsedSec > 0 ? bytesInProcessed / (1024.0 * 1024.0) / elapsedSec : 0; + double mbpsOut = elapsedSec > 0 ? bytesOutProcessed / (1024.0 * 1024.0) / elapsedSec : 0; + double mbpsTotal = mbpsIn + mbpsOut; + + sb.Clear(); + + const string Reset = "\u001b[0m"; + const string Cyan = "\u001b[36m"; + const string Blue = "\u001b[34m"; + const string Green = "\u001b[32m"; + + int boxWidth = Console.IsOutputRedirected ? 100 : Math.Max(60, Console.WindowWidth); + + string header = $"Benchmark Progress - Iteration {currentIteration}"; + sb.Append(Cyan).Append("┌── ").Append(header).Append(" ─") + .Append(new string('─', boxWidth - header.Length - 6)).Append(Reset).Append('\n'); + + AppendField(sb, "Batch size", $"{currentBatchSize:N0}", Blue); + AppendField(sb, "Total RPCs", $"{comp:N0} ({batches:N0} batches done)", Blue); + AppendField(sb, "Elapsed", $"{elapsedSec:F3} s", Blue); + AppendField(sb, "RPC/s", $"{rpcPerSec:N0}", Green); + AppendField(sb, "In MBps", $"{mbpsIn:F2}", Green); + AppendField(sb, "Out MBps", $"{mbpsOut:F2}", Green); + AppendField(sb, "Total MBps", $"{mbpsTotal:F2}", Green); + + sb.Append(Cyan).Append("└").Append(new string('─', boxWidth - 2)).Append(Reset).Append('\n'); + + _print(sb.ToString()); + } + + private static void AppendField(StringBuilder sb, string label, string value, string color) + { + const string Reset = "\u001b[0m"; + sb.Append("│ ").Append(color).Append(label.PadRight(12)).Append(Reset).Append(": ").Append(value) + .Append('\n'); + } + +} \ No newline at end of file diff --git a/TestServer_Console/CompareBenchmark.cs b/TestServer_Console/CompareBenchmark.cs new file mode 100644 index 0000000..803eae8 --- /dev/null +++ b/TestServer_Console/CompareBenchmark.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipelines; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +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.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StreamJsonRpc; +using SjrMethod = StreamJsonRpc.JsonRpcMethodAttribute; + +namespace TestServer_Console; + +/// +/// The same five requests through JSON-RPC.Net and through StreamJsonRpc (Microsoft's JSON-RPC library, the one +/// behind Visual Studio and the language-server stack), on the same Kestrel TCP listener with the same +/// pipelining client, so the only variable is the RPC library. Every request carries "jsonrpc":"2.0" +/// because StreamJsonRpc requires it. StreamJsonRpc has no "document in, document out" call, so its in-process +/// row runs over a pair of s, the closest thing it has to a direct call; a sequential +/// proxy row shows what a typical await proxy.AddAsync(1, 2) costs end to end. gRPC for .NET answers the +/// same five calls on an HTTP/2 listener of the same Kestrel (), unary and streamed. +/// +internal static class CompareBenchmark +{ + private enum Framing { NewLine, Header } + private enum Formatter { SystemTextJson, Newtonsoft } + + private static readonly string[] Requests = BenchmarkRunner.taskInputs.Select(t => "{\"jsonrpc\":\"2.0\"," + t.Substring(1)).ToArray(); + private static readonly byte[] JsonRpcPrefix = Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\","); + + /// Warm-up per row before the timed run. At 0.5 s the StreamJsonRpc rows read 15 to 50 % low (tiered JIT); 2 s settles them. The gRPC rows did not move between 0.5 and 2 s. + private const double Warm = 2; + + /// StreamJsonRpc target with the same five methods as . + public class Target + { + [SjrMethod("add")] public double Add(double l, double r) => l + r; + [SjrMethod("addInt")] public int AddInt(int l, int r) => l + r; + [SjrMethod("NullableFloatToNullableFloat")] public float? NullableFloatToNullableFloat(float? a) => a; + [SjrMethod("Test2")] public decimal? Test2(decimal x) => x; + [SjrMethod("StringMe")] public string StringMe(string x) => x; + } + + /// Client proxy for the sequential round-trip row. + public interface ICalculator + { + [SjrMethod("add")] Task AddAsync(double l, double r); + [SjrMethod("addInt")] Task AddIntAsync(int l, int r); + [SjrMethod("NullableFloatToNullableFloat")] Task NullableFloatToNullableFloatAsync(float? a); + [SjrMethod("Test2")] Task Test2Async(decimal x); + [SjrMethod("StringMe")] Task StringMeAsync(string x); + } + + internal static async Task RunAsync(Action print, double seconds = 3, int clients = 0, int pipeline = 256) + { + print ??= Console.WriteLine; + if (clients <= 0) clients = Environment.ProcessorCount; + + var raw = Requests.Select(r => Encoding.UTF8.GetBytes(r)).ToArray(); + var newLine = Requests.Select(r => Encoding.UTF8.GetBytes(r + "\n")).ToArray(); + var header = Requests.Select(r => Encoding.UTF8.GetBytes("Content-Length: " + Encoding.UTF8.GetByteCount(r) + "\r\n\r\n" + r)).ToArray(); + + await using var h = await StartAsync(pipeline); + int oursPort = h.OursPort, sjrNewLineStjPort = h.SjrNewLineStjPort, sjrHeaderStjPort = h.SjrHeaderStjPort, sjrHeaderNewtonsoftPort = h.SjrHeaderNewtonsoftPort, grpcPort = h.GrpcPort; + + { + print($"{Versions()}; Kestrel on loopback, {clients} clients, pipeline {pipeline}, {seconds:0.#} s per row\n"); + var rows = new List(); + var session = Handler.DefaultSessionId(); + + // ---- in-process floors + var memInputs = raw.Select(i => (ReadOnlyMemory)i).ToArray(); + BenchmarkRunner.RunSync(session, memInputs, Config.Serializer, 1, Warm, out _); + var elapsed = BenchmarkRunner.RunSync(session, memInputs, Config.Serializer, 1, seconds, out long total); + rows.Add(new BenchmarkRunner.ChartRow("JSON-RPC.Net in-process, 1 thread", total / elapsed, $"{total,12:N0} RPCs direct call, bytes in, bytes out")); + print($" JSON-RPC.Net in-process done ({rows[^1].RpcPerSec:N0} RPC/s)"); + + PipeRun(newLine, Warm, pipeline); + var (count, secs) = PipeRun(newLine, seconds, pipeline); + rows.Add(new BenchmarkRunner.ChartRow("StreamJsonRpc in-process, 1 client", count / secs, $"{count,12:N0} RPCs Pipe pair, newline framing, STJ formatter, {pipeline} pipelined")); + print($" StreamJsonRpc in-process done ({count / secs:N0} RPC/s)"); + + (count, secs) = await ProxyRun(Warm); + (count, secs) = await ProxyRun(seconds); + rows.Add(new BenchmarkRunner.ChartRow("StreamJsonRpc proxy, sequential await", count / secs, $"{count,12:N0} RPCs {secs / count * 1e6:N1} us per round trip")); + print($" StreamJsonRpc proxy done ({count / secs:N0} RPC/s)"); + + // ---- Kestrel TCP, same client for every row + foreach (var (label, port, inputs, prefix) in new[] + { + ("JSON-RPC.Net TCP, raw documents", oursPort, raw, JsonRpcPrefix), + ("StreamJsonRpc TCP, newline + STJ", sjrNewLineStjPort, newLine, JsonRpcPrefix), + ("StreamJsonRpc TCP, Content-Length + STJ", sjrHeaderStjPort, header, (byte[])null), + ("StreamJsonRpc TCP, Content-Length + Json.NET", sjrHeaderNewtonsoftPort, header, (byte[])null), + }) + { + Probe(port, inputs, label); + KestrelBenchmark.TcpRun(port, inputs, clients, Warm, pipeline, prefix); + (count, secs) = KestrelBenchmark.TcpRun(port, inputs, clients, seconds, pipeline, prefix); + rows.Add(new BenchmarkRunner.ChartRow(label, count / secs, $"{count,12:N0} RPCs")); + print($" {label} done ({count / secs:N0} RPC/s)"); + } + + // ---- gRPC for .NET on the same Kestrel: HTTP/2 + protobuf, one channel per client + await GrpcCompare.UnaryRun(grpcPort, clients, pipeline, Warm); + (count, secs) = await GrpcCompare.UnaryRun(grpcPort, clients, pipeline, seconds); + rows.Add(new BenchmarkRunner.ChartRow("gRPC for .NET unary, HTTP/2", count / secs, $"{count,12:N0} RPCs {clients} channels, {pipeline} calls in flight each")); + print($" gRPC unary done ({count / secs:N0} RPC/s)"); + + await GrpcCompare.StreamRun(grpcPort, clients, pipeline, Warm); + (count, secs) = await GrpcCompare.StreamRun(grpcPort, clients, pipeline, seconds); + rows.Add(new BenchmarkRunner.ChartRow("gRPC for .NET bidirectional stream", count / secs, $"{count,12:N0} RPCs {clients} streams, {pipeline} calls in flight each")); + print($" gRPC stream done ({count / secs:N0} RPC/s)"); + + BenchmarkRunner.PrintBarChart("JSON-RPC.Net vs StreamJsonRpc vs gRPC - RPC/s", "Library / transport", rows); + } + } + + /// + /// Every library and transport at 1, 2, 4, 8 and 16 client connections: the data for the README's + /// multi-series chart. Same host, same five calls, same pipeline depth per connection as + /// . Prints a table and, when is given, writes the + /// numbers as JSON for benchmarks/charts/render.py. + /// + internal static async Task SweepAsync(Action print, double seconds = 2, string outputPath = null, int pipeline = 256) + { + print ??= Console.WriteLine; + var raw = Requests.Select(r => Encoding.UTF8.GetBytes(r)).ToArray(); + var newLine = Requests.Select(r => Encoding.UTF8.GetBytes(r + "\n")).ToArray(); + var header = Requests.Select(r => Encoding.UTF8.GetBytes("Content-Length: " + Encoding.UTF8.GetByteCount(r) + "\r\n\r\n" + r)).ToArray(); + var batch = new[] { KestrelBenchmark.BuildBatch(raw, 100) }; + + await using var h = await StartAsync(pipeline); + int[] connections = { 1, 2, 4, 8, 16 }; + (string name, string kind, Func> run)[] series = + { + ("JSON-RPC.Net, TCP", "ours", (c, s) => Task.FromResult(KestrelBenchmark.TcpRun(h.OursPort, raw, c, s, pipeline, JsonRpcPrefix))), + ("JSON-RPC.Net, HTTP, batch of 100 per POST", "ours", (c, s) => KestrelBenchmark.HttpRun(h.HttpUrl, batch, 100, c, s)), + ("JSON-RPC.Net, HTTP, 1 request per POST", "ours", (c, s) => KestrelBenchmark.HttpRun(h.HttpUrl, raw, 1, c, s)), + ("StreamJsonRpc, TCP, newline + System.Text.Json", "theirs", (c, s) => Task.FromResult(KestrelBenchmark.TcpRun(h.SjrNewLineStjPort, newLine, c, s, pipeline, JsonRpcPrefix))), + ("StreamJsonRpc, TCP, Content-Length + System.Text.Json", "theirs", (c, s) => Task.FromResult(KestrelBenchmark.TcpRun(h.SjrHeaderStjPort, header, c, s, pipeline, null))), + ("StreamJsonRpc, TCP, Content-Length + Json.NET", "theirs", (c, s) => Task.FromResult(KestrelBenchmark.TcpRun(h.SjrHeaderNewtonsoftPort, header, c, s, pipeline, null))), + ("gRPC for .NET, unary", "grpc", (c, s) => GrpcCompare.UnaryRun(h.GrpcPort, c, pipeline, s)), + ("gRPC for .NET, bidirectional stream", "grpc", (c, s) => GrpcCompare.StreamRun(h.GrpcPort, c, pipeline, s)), + }; + Probe(h.OursPort, raw, series[0].name); + Probe(h.SjrNewLineStjPort, newLine, series[3].name); + Probe(h.SjrHeaderStjPort, header, series[4].name); + Probe(h.SjrHeaderNewtonsoftPort, header, series[5].name); + + print($"{Versions()}; Kestrel on loopback, pipeline {pipeline}, {seconds:0.#} s per cell\n"); + var results = new double[series.Length, connections.Length]; + for (int ci = 0; ci < connections.Length; ci++) + { + for (int si = 0; si < series.Length; si++) + { + await series[si].run(connections[ci], Warm); + var (count, secs) = await series[si].run(connections[ci], seconds); + results[si, ci] = count / secs; + print($" {connections[ci],2} connections {series[si].name,-55} {results[si, ci],14:N0} RPC/s"); + } + } + + print("\nRPC/s by connections:"); + print(" " + "series".PadRight(56) + string.Join("", connections.Select(c => $"{c,14}"))); + for (int si = 0; si < series.Length; si++) + print(" " + series[si].name.PadRight(56) + string.Join("", connections.Select((_, ci) => $"{results[si, ci],14:N0}"))); + + if (outputPath != null) + { + var doc = new + { + machine = "AMD Ryzen 7 7800X3D, 8 cores / 16 threads, Windows 11, .NET 10, Release, Server GC", + date = DateTime.Now.ToString("yyyy-MM-dd"), + secondsPerCell = seconds, + pipeline, + connections, + series = series.Select((s, si) => new { s.name, s.kind, rpcPerSec = connections.Select((_, ci) => Math.Round(results[si, ci])).ToArray() }).ToArray(), + }; + File.WriteAllText(outputPath, JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true }) + "\n"); + print($"\nwrote {outputPath}"); + } + } + + private static string Versions() => + $"JSON-RPC.Net {typeof(JsonRpcProcessor).Assembly.GetName().Version} vs StreamJsonRpc {PackageVersion(typeof(JsonRpc))} vs gRPC for .NET {PackageVersion(typeof(Grpc.Net.Client.GrpcChannel))}"; + + /// One Kestrel with a listener per library and setting; disposing it stops the host. + private sealed class Hosted : IAsyncDisposable + { + public WebApplication App; + public int OursPort, SjrNewLineStjPort, SjrHeaderStjPort, SjrHeaderNewtonsoftPort, GrpcPort, HttpPort; + public string HttpUrl => $"http://127.0.0.1:{HttpPort}/rpc"; + public async ValueTask DisposeAsync() { await App.StopAsync(); await App.DisposeAsync(); } + } + + private static async Task StartAsync(int pipeline) + { + var h = new Hosted + { + OursPort = KestrelBenchmark.FreePort(), SjrNewLineStjPort = KestrelBenchmark.FreePort(), SjrHeaderStjPort = KestrelBenchmark.FreePort(), + SjrHeaderNewtonsoftPort = KestrelBenchmark.FreePort(), GrpcPort = KestrelBenchmark.FreePort(), HttpPort = KestrelBenchmark.FreePort(), + }; + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(k => + { + k.Listen(IPAddress.Loopback, h.HttpPort); + k.Listen(IPAddress.Loopback, h.OursPort, l => l.UseConnectionHandler()); + k.Listen(IPAddress.Loopback, h.SjrNewLineStjPort, l => l.Run(c => ServeStreamJsonRpc(c, Framing.NewLine, Formatter.SystemTextJson))); + k.Listen(IPAddress.Loopback, h.SjrHeaderStjPort, l => l.Run(c => ServeStreamJsonRpc(c, Framing.Header, Formatter.SystemTextJson))); + k.Listen(IPAddress.Loopback, h.SjrHeaderNewtonsoftPort, l => l.Run(c => ServeStreamJsonRpc(c, Framing.Header, Formatter.Newtonsoft))); + k.Listen(IPAddress.Loopback, h.GrpcPort, l => l.Protocols = HttpProtocols.Http2); + k.Limits.Http2.MaxStreamsPerConnection = pipeline; // Kestrel's default of 100 would cap the gRPC rows below the pipeline depth + }); + builder.Services.AddJsonRpc(); + builder.Services.AddGrpc(); + var app = builder.Build(); + app.MapJsonRpc("/rpc"); + app.MapGrpcService(); + await app.StartAsync(); + h.App = app; + return h; + } + + /// The NuGet package version of an assembly (its informational version without the commit suffix). + private static string PackageVersion(Type t) + { + var info = t.Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false); + var text = info.Length > 0 ? ((System.Reflection.AssemblyInformationalVersionAttribute)info[0]).InformationalVersion : t.Assembly.GetName().Version.ToString(); + int plus = text.IndexOf('+'); + return plus > 0 ? text.Substring(0, plus) : text; + } + + private static IJsonRpcMessageHandler CreateHandler(PipeWriter writer, PipeReader reader, Framing framing, Formatter formatter) + { + IJsonRpcMessageTextFormatter f = formatter == Formatter.SystemTextJson ? new SystemTextJsonFormatter() : new JsonMessageFormatter(); + return framing == Framing.NewLine + ? new NewLineDelimitedMessageHandler(writer, reader, f) + : new HeaderDelimitedMessageHandler(writer, reader, f); + } + + private static async Task ServeStreamJsonRpc(ConnectionContext connection, Framing framing, Formatter formatter) + { + using var rpc = new JsonRpc(CreateHandler(connection.Transport.Output, connection.Transport.Input, framing, formatter)); + rpc.AddLocalRpcTarget(new Target()); + rpc.StartListening(); + try { await rpc.Completion; } + catch (Exception) { /* the client hung up */ } + } + + /// Sends each request once and checks every response is a result; a wrong method name or a formatter quirk would otherwise inflate a row. + private static void Probe(int port, byte[][] inputs, string label) + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true, ReceiveTimeout = 5000 }; + socket.Connect(IPAddress.Loopback, port); + foreach (var doc in inputs) socket.Send(doc); + var counter = new KestrelBenchmark.ResponseCounter(null); + var received = new List(); + var buffer = new byte[16 * 1024]; + int docs = 0; + while (docs < inputs.Length) + { + int n = socket.Receive(buffer); + if (n == 0) break; + received.AddRange(buffer.AsSpan(0, n).ToArray()); + docs += counter.Count(buffer.AsSpan(0, n)); + } + var text = Encoding.UTF8.GetString(received.ToArray()); + if (docs != inputs.Length || text.Contains("\"error\"") || !text.Contains("\"result\"")) + throw new InvalidOperationException($"{label}: unexpected responses:\n{text}"); + } + + /// + /// StreamJsonRpc served over an in-process pair, driven exactly like the TCP client: a ring + /// of newline-framed requests, a cursor, and refills whenever the pipeline has room. + /// + private static (long count, double seconds) PipeRun(byte[][] inputs, double seconds, int pipeline) + { + var toServer = new Pipe(); + var toClient = new Pipe(); + using var rpc = new JsonRpc(CreateHandler(toClient.Writer, toServer.Reader, Framing.NewLine, Formatter.SystemTextJson)); + rpc.AddLocalRpcTarget(new Target()); + rpc.StartListening(); + + const int ringCount = 4096; + var offsets = new int[ringCount + pipeline + 1]; + var ring = KestrelBenchmark.BuildRing(inputs, ringCount, pipeline, offsets); + var counter = new KestrelBenchmark.ResponseCounter(JsonRpcPrefix); + int cursor = 0, inFlight = 0; + long received = 0; + var sw = Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < seconds) + { + int free = pipeline - inFlight; + if (free > 0) + { + toServer.Writer.WriteAsync(new ReadOnlyMemory(ring, offsets[cursor], offsets[cursor + free] - offsets[cursor])).AsTask().GetAwaiter().GetResult(); + cursor += free; + if (cursor >= ringCount) cursor -= ringCount; + inFlight += free; + } + var result = toClient.Reader.ReadAsync().AsTask().GetAwaiter().GetResult(); + foreach (var segment in result.Buffer) + { + int docs = counter.Count(segment.Span); + received += docs; + inFlight -= docs; + } + toClient.Reader.AdvanceTo(result.Buffer.End); + if (result.IsCompleted) break; + } + // Drain so the server is idle before the next row. + while (inFlight > 0) + { + var result = toClient.Reader.ReadAsync().AsTask().GetAwaiter().GetResult(); + foreach (var segment in result.Buffer) + { + int docs = counter.Count(segment.Span); + received += docs; + inFlight -= docs; + } + toClient.Reader.AdvanceTo(result.Buffer.End); + if (result.IsCompleted) break; + } + sw.Stop(); + toServer.Writer.Complete(); + return (received, sw.Elapsed.TotalSeconds); + } + + /// A typed StreamJsonRpc proxy awaiting one call at a time over an in-process pipe pair: the usual way the library is used. + private static async Task<(long count, double seconds)> ProxyRun(double seconds) + { + var toServer = new Pipe(); + var toClient = new Pipe(); + using var server = new JsonRpc(CreateHandler(toClient.Writer, toServer.Reader, Framing.Header, Formatter.SystemTextJson)); + server.AddLocalRpcTarget(new Target()); + server.StartListening(); + using var client = new JsonRpc(CreateHandler(toServer.Writer, toClient.Reader, Framing.Header, Formatter.SystemTextJson)); + var proxy = client.Attach(); + client.StartListening(); + + long n = 0; + var sw = Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < seconds) + { + if (await proxy.AddAsync(1, 2) != 3) throw new InvalidOperationException("add"); + if (await proxy.AddIntAsync(1, 7) != 8) throw new InvalidOperationException("addInt"); + if (await proxy.NullableFloatToNullableFloatAsync(1.23f) != 1.23f) throw new InvalidOperationException("NullableFloatToNullableFloat"); + if (await proxy.Test2Async(3.456m) != 3.456m) throw new InvalidOperationException("Test2"); + if (await proxy.StringMeAsync("Foo") != "Foo") throw new InvalidOperationException("StringMe"); + n += 5; + } + sw.Stop(); + toServer.Writer.Complete(); + toClient.Writer.Complete(); + return (n, sw.Elapsed.TotalSeconds); + } +} diff --git a/TestServer_Console/GrpcCompare.cs b/TestServer_Console/GrpcCompare.cs new file mode 100644 index 0000000..e658b92 --- /dev/null +++ b/TestServer_Console/GrpcCompare.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Grpc.Core; +using Grpc.Net.Client; +using TestServer_Console.Proto; + +namespace TestServer_Console; + +/// +/// gRPC for .NET answering the benchmark's five calls (see Protos/calculator.proto), for the compare mode. The +/// service is hosted on an HTTP/2 listener of the same Kestrel that serves the JSON-RPC rows; the client is +/// Grpc.Net.Client with one channel (one HTTP/2 connection) per client and inFlight calls outstanding on +/// each, the same shape as the pipelined TCP client. Two rows: unary calls, the way gRPC is normally used, and one +/// bidirectional stream per channel carrying the same five calls, the closest gRPC has to a pipelined connection. +/// +internal static class GrpcCompare +{ + private static readonly WriteOptions Buffered = new WriteOptions(WriteFlags.BufferHint); + private static readonly WriteOptions Flushed = new WriteOptions(); + + public sealed class CalculatorGrpc : Calculator.CalculatorBase + { + public override Task Add(AddRequest r, ServerCallContext c) => Task.FromResult(new DoubleReply { Value = r.L + r.R }); + public override Task AddInt(AddIntRequest r, ServerCallContext c) => Task.FromResult(new IntReply { Value = r.L + r.R }); + public override Task NullableFloatToNullableFloat(NullableFloatRequest r, ServerCallContext c) => Task.FromResult(NullableFloat(r)); + public override Task Test2(DecimalRequest r, ServerCallContext c) => Task.FromResult(new NullableDecimalReply { Value = r.X }); + public override Task StringMe(StringRequest r, ServerCallContext c) => Task.FromResult(new StringReply { Value = r.X }); + + public override async Task Stream(IAsyncStreamReader requests, IServerStreamWriter responses, ServerCallContext c) + { + // Flush when the input runs dry, buffer while more calls are already queued: the same once-per-read-group + // flush the JSON-RPC connection handler does, expressed with gRPC's BufferHint. + var next = requests.MoveNext(c.CancellationToken); + while (await next) + { + var call = requests.Current; + next = requests.MoveNext(c.CancellationToken); + responses.WriteOptions = next.IsCompleted ? Buffered : Flushed; + await responses.WriteAsync(Answer(call)); + } + } + + private static NullableFloatReply NullableFloat(NullableFloatRequest r) + { + var reply = new NullableFloatReply(); + if (r.HasA) reply.Value = r.A; + return reply; + } + + private static Reply Answer(Call call) + { + switch (call.CallCase) + { + case Call.CallOneofCase.Add: return new Reply { Add = new DoubleReply { Value = call.Add.L + call.Add.R } }; + case Call.CallOneofCase.AddInt: return new Reply { AddInt = new IntReply { Value = call.AddInt.L + call.AddInt.R } }; + case Call.CallOneofCase.NullableFloat: return new Reply { NullableFloat = NullableFloat(call.NullableFloat) }; + case Call.CallOneofCase.Test2: return new Reply { Test2 = new NullableDecimalReply { Value = call.Test2.X } }; + case Call.CallOneofCase.StringMe: return new Reply { StringMe = new StringReply { Value = call.StringMe.X } }; + default: throw new RpcException(new Status(StatusCode.InvalidArgument, "empty call")); + } + } + } + + // ---- the five calls, as messages (built once; protobuf messages are not mutated after construction) + + private static DecimalValue ToDecimalValue(decimal d) + { + long units = (long)decimal.Truncate(d); + int nanos = (int)((d - units) * 1_000_000_000m); + return new DecimalValue { Units = units, Nanos = nanos }; + } + + private static decimal FromDecimalValue(DecimalValue v) => v.Units + v.Nanos / 1_000_000_000m; + + private static readonly AddRequest AddReq = new AddRequest { L = 1, R = 2 }; + private static readonly AddIntRequest AddIntReq = new AddIntRequest { L = 1, R = 7 }; + private static readonly NullableFloatRequest NullableFloatReq = new NullableFloatRequest { A = 1.23f }; + private static readonly DecimalRequest Test2Req = new DecimalRequest { X = ToDecimalValue(3.456m) }; + private static readonly StringRequest StringMeReq = new StringRequest { X = "Foo" }; + + private static readonly Call[] StreamCalls = + { + new Call { Add = AddReq }, + new Call { AddInt = AddIntReq }, + new Call { NullableFloat = NullableFloatReq }, + new Call { Test2 = Test2Req }, + new Call { StringMe = StringMeReq }, + }; + + /// The five unary calls in sequence, every reply checked (a wrong answer would otherwise inflate a row). + private static async Task Cycle(Calculator.CalculatorClient client) + { + if ((await client.AddAsync(AddReq)).Value != 3) throw new InvalidOperationException("gRPC add"); + if ((await client.AddIntAsync(AddIntReq)).Value != 8) throw new InvalidOperationException("gRPC addInt"); + var f = await client.NullableFloatToNullableFloatAsync(NullableFloatReq); + if (!f.HasValue || f.Value != 1.23f) throw new InvalidOperationException("gRPC NullableFloatToNullableFloat"); + var d = await client.Test2Async(Test2Req); + if (d.Value == null || FromDecimalValue(d.Value) != 3.456m) throw new InvalidOperationException("gRPC Test2"); + if ((await client.StringMeAsync(StringMeReq)).Value != "Foo") throw new InvalidOperationException("gRPC StringMe"); + } + + private static void Check(Reply reply) + { + switch (reply.ReplyCase) + { + case Reply.ReplyOneofCase.Add: if (reply.Add.Value != 3) throw new InvalidOperationException("gRPC stream add"); break; + case Reply.ReplyOneofCase.AddInt: if (reply.AddInt.Value != 8) throw new InvalidOperationException("gRPC stream addInt"); break; + case Reply.ReplyOneofCase.NullableFloat: if (!reply.NullableFloat.HasValue || reply.NullableFloat.Value != 1.23f) throw new InvalidOperationException("gRPC stream NullableFloatToNullableFloat"); break; + case Reply.ReplyOneofCase.Test2: if (reply.Test2.Value == null || FromDecimalValue(reply.Test2.Value) != 3.456m) throw new InvalidOperationException("gRPC stream Test2"); break; + case Reply.ReplyOneofCase.StringMe: if (reply.StringMe.Value != "Foo") throw new InvalidOperationException("gRPC stream StringMe"); break; + default: throw new InvalidOperationException("gRPC stream: empty reply"); + } + } + + private static GrpcChannel[] OpenChannels(int port, int count) + { + return Enumerable.Range(0, count).Select(_ => GrpcChannel.ForAddress("http://127.0.0.1:" + port)).ToArray(); + } + + /// + /// Unary calls: HTTP/2 connections, each with workers + /// that await one call at a time, so at most channels × inFlight calls are outstanding. + /// + internal static async Task<(long count, double seconds)> UnaryRun(int port, int channels, int inFlight, double seconds) + { + var chans = OpenChannels(port, channels); + try + { + var clients = chans.Select(c => new Calculator.CalculatorClient(c)).ToArray(); + await Cycle(clients[0]); // probe: the service answers, and answers correctly + + var counts = new long[channels * inFlight]; + using var stop = new CancellationTokenSource(); + var sw = Stopwatch.StartNew(); + var workers = new Task[counts.Length]; + for (int i = 0; i < workers.Length; i++) + { + var client = clients[i / inFlight]; + int slot = i; + workers[i] = Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + await Cycle(client); + counts[slot] += 5; + } + }); + } + await Task.Delay(TimeSpan.FromSeconds(seconds)); + stop.Cancel(); + await Task.WhenAll(workers); + sw.Stop(); + return (counts.Sum(), sw.Elapsed.TotalSeconds); + } + finally + { + foreach (var c in chans) c.Dispose(); + } + } + + /// + /// One bidirectional stream per channel: a writer keeps calls outstanding, a reader + /// counts and checks the replies. The same five calls, in the same rotation, as every other row. Like the TCP + /// client, the writer sends whatever has room as one batch: BufferHint on every message but the last, which + /// flushes. The server answers each message as it arrives (gRPC's default write, one flush per reply). + /// + internal static async Task<(long count, double seconds)> StreamRun(int port, int channels, int inFlight, double seconds) + { + var chans = OpenChannels(port, channels); + try + { + var counts = new long[channels]; + using var stop = new CancellationTokenSource(); + var sw = Stopwatch.StartNew(); + var streams = new Task[channels]; + for (int i = 0; i < channels; i++) + { + var client = new Calculator.CalculatorClient(chans[i]); + int slot = i; + streams[i] = Task.Run(async () => + { + using var call = client.Stream(); + using var room = new SemaphoreSlim(inFlight, inFlight); + var reader = Task.Run(async () => + { + long n = 0; + await foreach (var reply in call.ResponseStream.ReadAllAsync()) + { + if ((n & 1023) == 0) Check(reply); // every reply is a reply; spot-check the values + n++; + room.Release(); + } + counts[slot] = n; + }); + int next = 0; + while (!stop.IsCancellationRequested) + { + await room.WaitAsync(); + int batch = 1; + while (room.Wait(0)) batch++; // everything the reader has freed since the last refill + for (int b = 0; b < batch; b++) + { + call.RequestStream.WriteOptions = b == batch - 1 ? Flushed : Buffered; + await call.RequestStream.WriteAsync(StreamCalls[next]); + if (++next == StreamCalls.Length) next = 0; + } + } + await call.RequestStream.CompleteAsync(); + await reader; + }); + } + await Task.Delay(TimeSpan.FromSeconds(seconds)); + stop.Cancel(); + await Task.WhenAll(streams); + sw.Stop(); + return (counts.Sum(), sw.Elapsed.TotalSeconds); + } + finally + { + foreach (var c in chans) c.Dispose(); + } + } +} diff --git a/TestServer_Console/KestrelBenchmark.cs b/TestServer_Console/KestrelBenchmark.cs new file mode 100644 index 0000000..b833417 --- /dev/null +++ b/TestServer_Console/KestrelBenchmark.cs @@ -0,0 +1,339 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +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.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace TestServer_Console; + +/// +/// End-to-end throughput through the AustinHarris.JsonRpc.AspNetCore package: a real Kestrel on loopback +/// answering the same five requests over HTTP (one request per POST, and a batch per POST) and over a raw +/// TCP connection with pipelined requests. Clients and server share the machine, so each figure is an +/// upper bound on what one box can do talking to itself; the in-process rows are the same requests through +/// the byte entry point with no transport at all, for scale. +/// +internal static class KestrelBenchmark +{ + private static readonly byte[] ResultPrefix = Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"result\":"); + private static readonly byte[] BatchResultPrefix = Encoding.UTF8.GetBytes("[{\"jsonrpc\":\"2.0\",\"result\":"); + + internal static async Task RunAsync(Action print, double seconds = 3, int clients = 0, int pipeline = 256) + { + print ??= Console.WriteLine; + if (clients <= 0) clients = Environment.ProcessorCount; + + var inputs = BenchmarkRunner.taskInputs.Select(t => Encoding.UTF8.GetBytes(t)).ToArray(); + const int batchSize = 100; + var batch = BuildBatch(inputs, batchSize); + + int 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.AddJsonRpc(); + var app = builder.Build(); + app.MapJsonRpc("/rpc"); + await app.StartAsync(); + + try + { + var addresses = app.Services.GetRequiredService().Features.Get().Addresses; + var httpUrl = addresses.First(a => !a.EndsWith(":" + tcpPort)) + "/rpc"; + + print($"Kestrel on {httpUrl} (HTTP) and 127.0.0.1:{tcpPort} (TCP), {clients} clients, pipeline {pipeline}, {seconds:0.#} s per row\n"); + + var rows = new List(); + var session = Handler.DefaultSessionId(); + var memInputs = inputs.Select(i => (ReadOnlyMemory)i).ToArray(); + + // Scale rows: the same requests with no transport at all. + BenchmarkRunner.RunSync(session, memInputs, Config.Serializer, 1, 0.5, out _); // warm-up + var elapsed = BenchmarkRunner.RunSync(session, memInputs, Config.Serializer, 1, seconds, out long total); + rows.Add(new BenchmarkRunner.ChartRow("in-process, 1 thread", total / elapsed, $"{total,12:N0} RPCs")); + elapsed = BenchmarkRunner.RunSync(session, memInputs, Config.Serializer, clients, seconds, out total); + rows.Add(new BenchmarkRunner.ChartRow($"in-process, {clients} threads", total / elapsed, $"{total,12:N0} RPCs")); + print($" in-process rows done ({rows[0].RpcPerSec:N0} / {rows[1].RpcPerSec:N0} RPC/s)"); + + // HTTP, one request per POST. + await HttpRun(httpUrl, inputs, 1, clients, 0.5); // warm-up + var (count, secs) = await HttpRun(httpUrl, inputs, 1, clients, seconds); + rows.Add(new BenchmarkRunner.ChartRow("HTTP, 1 request/POST", count / secs, $"{count,12:N0} RPCs {secs / count * 1e6 * clients:N1} us/request per client")); + print($" HTTP single done ({count / secs:N0} RPC/s)"); + + // HTTP, a batch per POST. + await HttpRun(httpUrl, new[] { batch }, batchSize, clients, 0.5); + (count, secs) = await HttpRun(httpUrl, new[] { batch }, batchSize, clients, seconds); + rows.Add(new BenchmarkRunner.ChartRow($"HTTP, batch of {batchSize}/POST", count / secs, $"{count,12:N0} RPCs")); + print($" HTTP batch done ({count / secs:N0} RPC/s)"); + + // TCP, pipelined. + TcpRun(tcpPort, inputs, clients, 0.5, pipeline, ResultPrefix); + (count, secs) = TcpRun(tcpPort, inputs, clients, seconds, pipeline, ResultPrefix); + rows.Add(new BenchmarkRunner.ChartRow($"TCP, {pipeline} pipelined", count / secs, $"{count,12:N0} RPCs")); + print($" TCP done ({count / secs:N0} RPC/s)"); + + BenchmarkRunner.PrintBarChart($"Kestrel benchmark - {Config.Serializer.Name} - RPC/s by transport", "Transport", rows); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + + internal static byte[] BuildBatch(byte[][] inputs, int size) + { + var sb = new StringBuilder("["); + for (int i = 0; i < size; i++) + { + if (i > 0) sb.Append(','); + sb.Append(Encoding.UTF8.GetString(inputs[i % inputs.Length])); + } + return Encoding.UTF8.GetBytes(sb.Append(']').ToString()); + } + + /// Each client POSTs its next document, reads the body, checks it is a result, and repeats. + internal static async Task<(long count, double seconds)> HttpRun(string url, byte[][] documents, int rpcsPerDocument, int clients, double seconds) + { + using var handler = new SocketsHttpHandler { MaxConnectionsPerServer = clients * 2, PooledConnectionLifetime = TimeSpan.FromMinutes(5) }; + using var http = new HttpClient(handler); + var prefix = rpcsPerDocument > 1 ? BatchResultPrefix : ResultPrefix; + using var cts = new CancellationTokenSource(); + var counts = new long[clients]; + var sw = Stopwatch.StartNew(); + + var tasks = new Task[clients]; + for (int c = 0; c < clients; c++) + { + int slot = c; + tasks[c] = Task.Run(async () => + { + int i = slot; + long n = 0; + while (!cts.IsCancellationRequested) + { + var content = new ByteArrayContent(documents[i % documents.Length]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + using var response = await http.PostAsync(url, content); + var body = await response.Content.ReadAsByteArrayAsync(); + if (!body.AsSpan().StartsWith(prefix)) + throw new InvalidOperationException("Unexpected response: " + Encoding.UTF8.GetString(body)); + n += rpcsPerDocument; + i++; + } + counts[slot] = n; + }); + } + + await Task.Delay(TimeSpan.FromSeconds(seconds)); + cts.Cancel(); + await Task.WhenAll(tasks); + sw.Stop(); + return (counts.Sum(), sw.Elapsed.TotalSeconds); + } + + /// + /// One synchronous thread per client, one connection each. The requests live in a precomputed ring + /// (); a cursor walks it, and whenever the responses drain the pipeline below + /// the free slots are refilled with a single send of the next contiguous + /// slice of the ring. Responses are counted (and checked) by a small streaming framer, so the client does + /// no allocation and no parsing beyond bracket depth. + /// + /// Bytes every response must start with, or null to skip the check (framings that put headers first). + internal static (long count, double seconds) TcpRun(int port, byte[][] inputs, int clients, double seconds, int pipeline, byte[] expectedPrefix) + { + const int ringCount = 4096; + var offsets = new int[ringCount + pipeline + 1]; + var ring = BuildRing(inputs, ringCount, pipeline, offsets); + + var counts = new long[clients]; + int stop = 0; + Exception failure = null; + var ready = new Barrier(clients + 1); + var threads = new Thread[clients]; + + for (int c = 0; c < clients; c++) + { + int slot = c; + threads[c] = new Thread(() => + { + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + socket.Connect(IPAddress.Loopback, port); + var receive = new byte[64 * 1024]; + var framer = new ResponseCounter(expectedPrefix); + int cursor = slot % ringCount; + int inFlight = 0; + long received = 0; + + ready.SignalAndWait(); + while (Volatile.Read(ref stop) == 0) + { + int free = pipeline - inFlight; + if (free > 0) + { + // The ring has `pipeline` documents duplicated past its end, so this slice never wraps. + int start = offsets[cursor]; + int end = offsets[cursor + free]; + socket.Send(ring, start, end - start, SocketFlags.None); // blocking: sends everything + cursor += free; + if (cursor >= ringCount) cursor -= ringCount; + inFlight += free; + } + + int n = socket.Receive(receive); + if (n == 0) break; + int docs = framer.Count(receive.AsSpan(0, n)); + received += docs; + inFlight -= docs; + } + + // Drain what is still in flight so the server is idle before the next row starts. + socket.ReceiveTimeout = 2000; + try + { + while (inFlight > 0) + { + int n = socket.Receive(receive); + if (n == 0) break; + int docs = framer.Count(receive.AsSpan(0, n)); + received += docs; + inFlight -= docs; + } + } + catch (SocketException) { } + + counts[slot] = received; + } + catch (Exception e) + { + Interlocked.CompareExchange(ref failure, e, null); + Volatile.Write(ref stop, 1); + } + }) { IsBackground = true, Name = "tcp-client-" + c }; + threads[c].Start(); + } + + ready.SignalAndWait(); + var sw = Stopwatch.StartNew(); + Thread.Sleep(TimeSpan.FromSeconds(seconds)); + Volatile.Write(ref stop, 1); + foreach (var t in threads) t.Join(); + sw.Stop(); + + if (failure != null) throw new InvalidOperationException("TCP client failed", failure); + return (counts.Sum(), sw.Elapsed.TotalSeconds); + } + + /// + /// documents back to back, followed by a copy of the first + /// of them, so that any window of up to requests starting anywhere in the ring is one + /// contiguous slice. [i] is where document i starts; the last entry is the total length. + /// + internal static byte[] BuildRing(byte[][] inputs, int ringCount, int pipeline, int[] offsets) + { + int total = 0; + for (int i = 0; i < ringCount + pipeline; i++) + { + offsets[i] = total; + total += inputs[i % inputs.Length].Length; + } + offsets[ringCount + pipeline] = total; + + var ring = new byte[total]; + for (int i = 0; i < ringCount + pipeline; i++) + { + var doc = inputs[i % inputs.Length]; + Buffer.BlockCopy(doc, 0, ring, offsets[i], doc.Length); + } + return ring; + } + + /// + /// Counts complete top-level JSON documents in a byte stream across reads (bracket depth, string and escape + /// state carried over) and checks that each one starts with the expected result prefix. + /// + internal sealed class ResponseCounter + { + private readonly byte[] _prefix; + private readonly byte[] _capture; + private int _captured; + private int _depth; + private bool _inString; + private bool _escape; + + public ResponseCounter(byte[] prefix) + { + _prefix = prefix ?? Array.Empty(); + _capture = new byte[_prefix.Length]; + } + + public int Count(ReadOnlySpan bytes) + { + int docs = 0; + for (int i = 0; i < bytes.Length; i++) + { + byte b = bytes[i]; + + // Capture the prefix from the first byte of a document; whitespace or headers between documents are skipped. + if (_captured < _prefix.Length && (_depth > 0 || b == (byte)'{' || b == (byte)'[')) + { + _capture[_captured++] = b; + if (_captured == _prefix.Length && !_capture.AsSpan().SequenceEqual(_prefix)) + throw new InvalidOperationException("Unexpected response: " + Encoding.UTF8.GetString(_capture)); + } + + if (_inString) + { + if (_escape) _escape = false; + else if (b == (byte)'\\') _escape = true; + else if (b == (byte)'"') _inString = false; + continue; + } + + switch (b) + { + case (byte)'"': _inString = true; break; + case (byte)'{': + case (byte)'[': _depth++; break; + case (byte)'}': + case (byte)']': + if (--_depth == 0) { docs++; _captured = 0; } + break; + } + } + return docs; + } + } + + internal static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/TestServer_Console/PrintHardware.cs b/TestServer_Console/PrintHardware.cs new file mode 100644 index 0000000..5653293 --- /dev/null +++ b/TestServer_Console/PrintHardware.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; +using Hardware.Info; + +public class HardwarePrinter +{ + + + private static readonly StringBuilder sb = new StringBuilder(8192); // Preallocate generous capacity for reuse + private static readonly StringBuilder lineSb = new StringBuilder(1024); // Preallocate for lines + + private static readonly string[] keys = new string[] + { + "Name", + "Manufacturer", + "Description", + "Cores", + "Logical Processors", + "Current Clock", + "Max Clock", + "L1 Instr Cache", + "L1 Data Cache", + "L2 Cache", + "L3 Cache", + "Socket", + "Usage" + }; + + internal static void PrintHardware(IHardwareInfo hardwareInfo) + { + // Enable ANSI escape sequences on Windows for colors (call once, but safe to repeat) + if (OperatingSystem.IsWindows()) + { + var handle = GetStdHandle(-11); + if (GetConsoleMode(handle, out uint mode)) + { + SetConsoleMode(handle, mode | 0x0004); // ENABLE_VIRTUAL_TERMINAL_PROCESSING + } + } + + + // Reuse builders by clearing + sb.Clear(); + + const string Reset = "\u001b[0m"; + const string Cyan = "\u001b[36m"; + const string Blue = "\u001b[34m"; + const string Gray = "\u001b[90m"; + + int boxWidth = Console.IsOutputRedirected ? 100 : Console.WindowWidth; + int contentWidth = boxWidth - 4; // For "│ " and " │" + + int cpuIndex = 1; + foreach (var cpu in hardwareInfo.CpuList) + { + // Header with dynamic width + string headerStart = $"┌── CPU {cpuIndex} ──"; + int remainingDashes = boxWidth - headerStart.Length - 1; + sb.Append(Cyan).Append(headerStart).Append(new string('─', remainingDashes)).Append(Reset).Append('\n'); + + // Get values as array to avoid list allocation + string[] values = + [ + cpu.Name, + cpu.Manufacturer, + cpu.Description, + cpu.NumberOfCores.ToString(), + cpu.NumberOfLogicalProcessors.ToString(), + $"{cpu.CurrentClockSpeed} MHz", + $"{cpu.MaxClockSpeed} MHz", + $"{cpu.L1InstructionCacheSize / 1024} KB", + $"{cpu.L1DataCacheSize / 1024} KB", + $"{cpu.L2CacheSize / 1024 / 1024} MB", + $"{cpu.L3CacheSize / 1024 / 1024} MB", + cpu.SocketDesignation, + $"{cpu.PercentProcessorTime}%" + ]; + + // Flow layout + lineSb.Clear(); + lineSb.Append("│ "); + int currentPosition = 2; // Visible chars after start + int maxPosition = boxWidth - 2; // Before closing " │" + + bool firstInLine = true; + for (int i = 0; i < keys.Length; i++) + { + string key = keys[i]; + string value = values[i]; + + int separatorVisible = firstInLine ? 0 : 3; // " | " + int entryVisible = key.Length + 2 + value.Length; // ": " + int totalAdded = separatorVisible + entryVisible; + + if (currentPosition + totalAdded > maxPosition) + { + // Pad and close current line + int padNeeded = maxPosition - currentPosition + 1; + lineSb.Append(new string(' ', padNeeded)).Append("│"); + sb.Append(lineSb).Append('\n'); + + // Start new line + lineSb.Clear(); + lineSb.Append("│ "); + currentPosition = 2; + firstInLine = true; + } + + if (!firstInLine) + { + lineSb.Append(Gray).Append(" | ").Append(Reset); + currentPosition += 3; + } + + lineSb.Append(Blue).Append(key).Append(Reset).Append(": "); + currentPosition += key.Length + 2; + + string valueColor = ""; + if (key == "Usage") + { + valueColor = GetUsageAnsi(cpu.PercentProcessorTime); + } + + lineSb.Append(valueColor).Append(value).Append(Reset); + currentPosition += value.Length; + + firstInLine = false; + } + + // Close last line if content present + if (lineSb.Length > 2) + { + int padNeeded = maxPosition - currentPosition + 1; + lineSb.Append(new string(' ', padNeeded)).Append("│"); + sb.Append(lineSb).Append('\n'); + } + + // Bottom border + string bottomStart = "└──"; + int bottomDashes = boxWidth - bottomStart.Length - 1; + sb.Append(Cyan).Append(bottomStart).Append(new string('─', bottomDashes)).Append(Reset).Append('\n'); + + // Space between CPUs + sb.Append('\n'); + + cpuIndex++; + } + + // Single write to console + if (!Console.IsOutputRedirected) + { + Console.SetCursorPosition(0, 0); + } + Console.Write(sb.ToString()); + } + +// Helper for usage color ANSI + static string GetUsageAnsi(double percent) + { + if (percent < 30) return "\u001b[32m"; // Green + if (percent < 70) return "\u001b[33m"; // Yellow + return "\u001b[31m"; // Red + } + +// DLL imports for enabling ANSI on Windows + [SupportedOSPlatform("windows")] + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int nStdHandle); + + [SupportedOSPlatform("windows")] + [DllImport("kernel32.dll")] + private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + + [SupportedOSPlatform("windows")] + [DllImport("kernel32.dll")] + private static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); +} \ No newline at end of file diff --git a/TestServer_Console/Program.cs b/TestServer_Console/Program.cs index df3152f..1a3adb6 100644 --- a/TestServer_Console/Program.cs +++ b/TestServer_Console/Program.cs @@ -1,27 +1,109 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using AustinHarris.JsonRpc; -using System.Threading; -using System.Diagnostics; -using System.Threading.Tasks; +using Hardware.Info; namespace TestServer_Console { class Program { - static object[] services = new object[] { - new CalculatorService() - }; + // Bound explicitly in Main: a static field initializer (beforefieldinit) is not guaranteed to run, + // and without it every benchmark request answered "Method not found". + static object[] services; static void Main(string[] args) { + // When stdout is a pipe (CI, `dotnet run | tee`) there is no console buffer to clear or reposition. + services = new object[] { new CalculatorService() }; + // `dotnet run -- --async [seconds] [workers]` exercises real asynchronous invocation. + if (args.Length > 0 && args[0] == "--async") + { + double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 3; + int workers = args.Length > 2 && int.TryParse(args[2], out var t) ? t : 1; + AsyncBenchmark.RunAsync(Console.WriteLine, seconds, workers).GetAwaiter().GetResult(); + return; + } + + var interactive = !Console.IsOutputRedirected; + if (interactive) Console.Clear(); + IHardwareInfo hardwareInfo = new HardwareInfo(); + hardwareInfo.RefreshAll(); + HardwarePrinter.PrintHardware(hardwareInfo); + System.Threading.ThreadPool.SetMinThreads(Environment.ProcessorCount * 3, Environment.ProcessorCount * 3); + Console.WriteLine("Setting task pool size to {0}", Environment.ProcessorCount * 4); + + // `dotnet run -- --sync [seconds] [threads]` runs the direct synchronous benchmark and exits (CI / scripted runs). + if (args.Length > 0 && args[0] == "--sync") + { + double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 3; + int threads = args.Length > 2 && int.TryParse(args[2], out var t) ? t : 0; + BenchmarkRunner.BenchmarkSync(Console.WriteLine, null, threads, seconds); + return; + } + + // `dotnet run -- --kestrel [seconds]` hosts the AspNetCore package in-process and drives it over HTTP and TCP. + if (args.Length > 0 && args[0] == "--kestrel") + { + double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 3; + KestrelBenchmark.RunAsync(Console.WriteLine, seconds).GetAwaiter().GetResult(); + return; + } + + // `dotnet run -- --sweep [seconds] [output.json]` measures every library and transport at 1, 2, 4, 8 and 16 connections. + if (args.Length > 0 && args[0] == "--sweep") + { + double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 2; + CompareBenchmark.SweepAsync(Console.WriteLine, seconds, args.Length > 2 ? args[2] : null).GetAwaiter().GetResult(); + return; + } + + // `dotnet run -- --compare [seconds]` benchmarks the same requests through JSON-RPC.Net, StreamJsonRpc and gRPC for .NET. + if (args.Length > 0 && args[0] == "--compare") + { + double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 3; + CompareBenchmark.RunAsync(Console.WriteLine, seconds).GetAwaiter().GetResult(); + return; + } + PrintOptions(); - for (string line = Console.ReadLine(); line != "q"; line = Console.ReadLine()) + for (string line = Console.ReadLine(); line != null && line != "q"; line = Console.ReadLine()) { - if (string.IsNullOrWhiteSpace(line)) - Benchmark(); + if (!interactive && string.IsNullOrWhiteSpace(line)) + { + BenchmarkRunner.Benchmark(Console.WriteLine); + } + else if (line.StartsWith("s", StringComparison.CurrentCultureIgnoreCase)) + { + BenchmarkRunner.BenchmarkSync(Console.WriteLine); + } + else if (line.StartsWith("k", StringComparison.CurrentCultureIgnoreCase)) + { + KestrelBenchmark.RunAsync(Console.WriteLine).GetAwaiter().GetResult(); + } + else if (line.StartsWith("x", StringComparison.CurrentCultureIgnoreCase)) + { + CompareBenchmark.RunAsync(Console.WriteLine).GetAwaiter().GetResult(); + } + else if (string.IsNullOrWhiteSpace(line)) + { + Console.CursorVisible = false; + HardwarePrinter.PrintHardware(hardwareInfo); + var pos = Console.CursorTop; + BenchmarkRunner.Benchmark((update) => + { + // Clear the console from pos to current position first + var currentline = Console.CursorTop; + var fullLine = new string(' ', Console.WindowWidth); + Console.SetCursorPosition(0, pos); + for (int i = 0; i < currentline-pos; i++) + { + Console.WriteLine( fullLine); + } + + Console.SetCursorPosition(0, pos); + Console.WriteLine(update); + }); + Console.CursorVisible = true; + } else if (line.StartsWith("c", StringComparison.CurrentCultureIgnoreCase)) ConsoleInput(); PrintOptions(); @@ -30,7 +112,10 @@ static void Main(string[] args) private static void PrintOptions() { - Console.WriteLine("Hit Enter to run benchmark"); + Console.WriteLine("Hit Enter to run the Task-based benchmark"); + Console.WriteLine("'s' to run the direct synchronous benchmark (bytes in, bytes out)"); + Console.WriteLine("'k' to run the Kestrel benchmark (AspNetCore package over HTTP and TCP)"); + Console.WriteLine("'x' to compare against StreamJsonRpc and gRPC for .NET (same calls, same Kestrel)"); Console.WriteLine("'c' to start reading console input"); Console.WriteLine("'q' to quit"); } @@ -43,37 +128,6 @@ private static void ConsoleInput() } } - private static volatile int ctr; - private static void Benchmark() - { - Console.WriteLine("Starting benchmark"); - - var cnt = 50; - var iterations = 7; - for (int iteration = 1; iteration <= iterations; iteration++) - { - cnt *= iteration; - ctr = 0; - Task[] tasks = new Task[cnt]; - var sw = Stopwatch.StartNew(); - - var sessionid = Handler.DefaultSessionId(); - for (int i = 0; i < cnt; i+=5) - { - tasks[i] = JsonRpcProcessor.Process(sessionid, "{'method':'add','params':[1,2],'id':1}"); - tasks[i+1] = JsonRpcProcessor.Process(sessionid, "{'method':'addInt','params':[1,7],'id':2}"); - tasks[i+2] = JsonRpcProcessor.Process(sessionid, "{'method':'NullableFloatToNullableFloat','params':[1.23],'id':3}"); - tasks[i+3] = JsonRpcProcessor.Process(sessionid, "{'method':'Test2','params':[3.456],'id':4}"); - tasks[i+4] = JsonRpcProcessor.Process(sessionid, "{'method':'StringMe','params':['Foo'],'id':5}"); - } - Task.WaitAll(tasks); - sw.Stop(); - Console.WriteLine("processed {0:N0} rpc in \t {1:N0}ms for \t {2:N} rpc/sec", cnt, sw.ElapsedMilliseconds, (double)cnt * 1000d / sw.ElapsedMilliseconds); - } - - - Console.WriteLine("Finished benchmark..."); - } } diff --git a/TestServer_Console/Protos/calculator.proto b/TestServer_Console/Protos/calculator.proto new file mode 100644 index 0000000..83fc571 --- /dev/null +++ b/TestServer_Console/Protos/calculator.proto @@ -0,0 +1,54 @@ +// The benchmark's five methods as a gRPC service, so the compare mode can drive gRPC for .NET (HTTP/2, protobuf) +// with the same calls it sends as JSON-RPC. protobuf has no decimal: DecimalValue is the units/nanos shape the +// gRPC for .NET documentation recommends. Nullable values use proto3 `optional` / message presence. +syntax = "proto3"; + +option csharp_namespace = "TestServer_Console.Proto"; + +package calculator; + +service Calculator { + rpc Add (AddRequest) returns (DoubleReply); + rpc AddInt (AddIntRequest) returns (IntReply); + rpc NullableFloatToNullableFloat (NullableFloatRequest) returns (NullableFloatReply); + rpc Test2 (DecimalRequest) returns (NullableDecimalReply); + rpc StringMe (StringRequest) returns (StringReply); + // The five calls multiplexed on one bidirectional stream: the gRPC analogue of a pipelined connection. + rpc Stream (stream Call) returns (stream Reply); +} + +message AddRequest { double l = 1; double r = 2; } +message DoubleReply { double value = 1; } + +message AddIntRequest { int32 l = 1; int32 r = 2; } +message IntReply { int32 value = 1; } + +message NullableFloatRequest { optional float a = 1; } +message NullableFloatReply { optional float value = 1; } + +message DecimalValue { int64 units = 1; sfixed32 nanos = 2; } +message DecimalRequest { DecimalValue x = 1; } +message NullableDecimalReply { DecimalValue value = 1; } + +message StringRequest { string x = 1; } +message StringReply { string value = 1; } + +message Call { + oneof call { + AddRequest add = 1; + AddIntRequest add_int = 2; + NullableFloatRequest nullable_float = 3; + DecimalRequest test2 = 4; + StringRequest string_me = 5; + } +} + +message Reply { + oneof reply { + DoubleReply add = 1; + IntReply add_int = 2; + NullableFloatReply nullable_float = 3; + NullableDecimalReply test2 = 4; + StringReply string_me = 5; + } +} diff --git a/TestServer_Console/TestServer_Console.csproj b/TestServer_Console/TestServer_Console.csproj index ba5f427..c5bd86a 100644 --- a/TestServer_Console/TestServer_Console.csproj +++ b/TestServer_Console/TestServer_Console.csproj @@ -1,24 +1,36 @@ - + Austin Harris Exe - netcoreapp3.1 + net10.0 + false + + true + false - - + + + + + - - + + + + - + + - \ No newline at end of file + diff --git a/benchmarks/Micro/AsyncDispatchBenchmarks.cs b/benchmarks/Micro/AsyncDispatchBenchmarks.cs new file mode 100644 index 0000000..6f8f67a --- /dev/null +++ b/benchmarks/Micro/AsyncDispatchBenchmarks.cs @@ -0,0 +1,91 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using AustinHarris.JsonRpc.Serialization; +using BenchmarkDotNet.Attributes; + +namespace AustinHarris.JsonRpc.Micro +{ + /// + /// The asynchronous entry point: a synchronous method through ProcessAsync (the cost of the async + /// dispatcher itself), a Task<int> and a ValueTask<int> method that complete inline, + /// each with the ambient frame flowing (default) and without, and a method that yields once (the price of a + /// real suspension: continuation objects and a thread hop). Compare the inline rows with + /// . + /// + [MemoryDiagnoser(displayGenColumns: false)] + public class AsyncDispatchBenchmarks + { + private const string Session = "micro-async"; + + private PooledByteBufferWriter _out; + private ReadOnlyMemory _sync, _taskFlow, _task, _valueTaskFlow, _valueTask, _yieldFlow, _yield; + + public sealed class Service + { + [JsonRpcMethod("addInt")] public int AddInt(int l, int r) => l + r; + [JsonRpcMethod("addTaskFlow", ContextFlow = RpcContextFlow.Flow)] public Task AddTaskFlow(int l, int r) => Task.FromResult(l + r); + [JsonRpcMethod("addTask")] public Task AddTask(int l, int r) => Task.FromResult(l + r); + [JsonRpcMethod("addValueTaskFlow", ContextFlow = RpcContextFlow.Flow)] public ValueTask AddValueTaskFlow(int l, int r) => new ValueTask(l + r); + [JsonRpcMethod("addValueTask")] public ValueTask AddValueTask(int l, int r) => new ValueTask(l + r); + [JsonRpcMethod("addYieldFlow", ContextFlow = RpcContextFlow.Flow)] public async Task AddYieldFlow(int l, int r) { await Task.Yield(); return l + r; } + [JsonRpcMethod("addYield")] public async Task AddYield(int l, int r) { await Task.Yield(); return l + r; } + } + + [GlobalSetup] + public void Setup() + { + ServiceBinder.BindService(Session, new Service()); + _out = new PooledByteBufferWriter(1024); + _sync = Utf8("{\"method\":\"addInt\",\"params\":[1,7],\"id\":2}"); + _taskFlow = Utf8("{\"method\":\"addTaskFlow\",\"params\":[1,7],\"id\":2}"); + _task = Utf8("{\"method\":\"addTask\",\"params\":[1,7],\"id\":2}"); + _valueTaskFlow = Utf8("{\"method\":\"addValueTaskFlow\",\"params\":[1,7],\"id\":2}"); + _valueTask = Utf8("{\"method\":\"addValueTask\",\"params\":[1,7],\"id\":2}"); + _yieldFlow = Utf8("{\"method\":\"addYieldFlow\",\"params\":[1,7],\"id\":2}"); + _yield = Utf8("{\"method\":\"addYield\",\"params\":[1,7],\"id\":2}"); + + const string expected = "{\"jsonrpc\":\"2.0\",\"result\":8,\"id\":2}"; + Expect(_sync, expected); + Expect(_task, expected); + Expect(_taskFlow, expected); + Expect(_valueTask, expected); + Expect(_valueTaskFlow, expected); + Expect(_yield, expected); + Expect(_yieldFlow, expected); + } + + [GlobalCleanup] + public void Cleanup() => _out.Dispose(); + + [Benchmark(Baseline = true)] public int SyncViaProcess() { var w = _out; w.Clear(); JsonRpcProcessor.Process(Session, _sync, w); return w.WrittenCount; } + [Benchmark] public ValueTask SyncViaProcessAsync() => Run(_sync); + [Benchmark] public ValueTask TaskInlineFlow() => Run(_taskFlow); + [Benchmark] public ValueTask TaskInline() => Run(_task); + [Benchmark] public ValueTask ValueTaskInlineFlow() => Run(_valueTaskFlow); + [Benchmark] public ValueTask ValueTaskInline() => Run(_valueTask); + [Benchmark] public ValueTask YieldOnceFlow() => Run(_yieldFlow); + [Benchmark] public ValueTask YieldOnce() => Run(_yield); + + // ValueTask so the driver itself allocates nothing when the document completes inline; an async Task + // driver would charge every row a Task for the (uncached) byte count. + private async ValueTask Run(ReadOnlyMemory input) + { + var w = _out; + w.Clear(); + var t = JsonRpcProcessor.ProcessAsync(Session, input, w); + if (!t.IsCompleted) await t.ConfigureAwait(false); + return w.WrittenCount; + } + + private void Expect(ReadOnlyMemory input, string expected) + { + _out.Clear(); + JsonRpcProcessor.ProcessAsync(Session, input, _out).GetAwaiter().GetResult(); + var actual = _out.ToString(); + if (actual != expected) throw new InvalidOperationException("unexpected response: " + actual); + } + + private static ReadOnlyMemory Utf8(string s) => Encoding.UTF8.GetBytes(s); + } +} diff --git a/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj b/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj new file mode 100644 index 0000000..3c84503 --- /dev/null +++ b/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net10.0 + disable + disable + false + true + true + false + false + true + + + + + + + + + + + diff --git a/benchmarks/Micro/BindingComparisonBenchmarks.cs b/benchmarks/Micro/BindingComparisonBenchmarks.cs new file mode 100644 index 0000000..8914b57 --- /dev/null +++ b/benchmarks/Micro/BindingComparisonBenchmarks.cs @@ -0,0 +1,81 @@ +using System; +using System.Text; +using AustinHarris.JsonRpc.Serialization; +using BenchmarkDotNet.Attributes; + +namespace AustinHarris.JsonRpc.Micro +{ + /// + /// The same method registered two ways in one process, class-bound with [JsonRpcMethod] and + /// interface-bound with ServiceBinder.BindInterface, measured back to back so a run-to-run drift of the + /// machine cannot show up as a binding difference. Each interface row must match its class row within noise. + /// + [MemoryDiagnoser(displayGenColumns: false)] + public class BindingComparisonBenchmarks + { + private const string ClassSession = "micro-cmp-class"; + private const string InterfaceSession = "micro-cmp-iface"; + + private PooledByteBufferWriter _out; + private ReadOnlyMemory _addInt, _decimal, _string; + + public interface ICalculator + { + int addInt(int l, int r); + decimal? Test2(decimal x); + string StringMe(string x); + } + + public sealed class Calculator : ICalculator + { + [JsonRpcMethod] public int addInt(int l, int r) => l + r; + [JsonRpcMethod] public decimal? Test2(decimal x) => x; + [JsonRpcMethod] public string StringMe(string x) => x; + } + + [GlobalSetup] + public void Setup() + { + ServiceBinder.BindService(ClassSession, new Calculator()); + ServiceBinder.BindInterface(InterfaceSession, new Calculator()); + _out = new PooledByteBufferWriter(1024); + _addInt = Utf8("{\"method\":\"addInt\",\"params\":[1,7],\"id\":2}"); + _decimal = Utf8("{\"method\":\"Test2\",\"params\":[3.456],\"id\":4}"); + _string = Utf8("{\"method\":\"StringMe\",\"params\":[\"Foo\"],\"id\":5}"); + foreach (var session in new[] { ClassSession, InterfaceSession }) + { + Expect(session, _addInt, "{\"jsonrpc\":\"2.0\",\"result\":8,\"id\":2}"); + Expect(session, _decimal, "{\"jsonrpc\":\"2.0\",\"result\":3.456,\"id\":4}"); + Expect(session, _string, "{\"jsonrpc\":\"2.0\",\"result\":\"Foo\",\"id\":5}"); + } + } + + [GlobalCleanup] + public void Cleanup() => _out.Dispose(); + + [Benchmark(Baseline = true)] public int ClassAddInt() => Run(ClassSession, _addInt); + [Benchmark] public int InterfaceAddInt() => Run(InterfaceSession, _addInt); + [Benchmark] public int ClassDecimal() => Run(ClassSession, _decimal); + [Benchmark] public int InterfaceDecimal() => Run(InterfaceSession, _decimal); + [Benchmark] public int ClassString() => Run(ClassSession, _string); + [Benchmark] public int InterfaceString() => Run(InterfaceSession, _string); + + private int Run(string session, ReadOnlyMemory input) + { + var w = _out; + w.Clear(); + JsonRpcProcessor.Process(session, input, w); + return w.WrittenCount; + } + + private void Expect(string session, ReadOnlyMemory input, string expected) + { + _out.Clear(); + JsonRpcProcessor.Process(session, input, _out); + var actual = _out.ToString(); + if (actual != expected) throw new InvalidOperationException("unexpected response: " + actual); + } + + private static ReadOnlyMemory Utf8(string s) => Encoding.UTF8.GetBytes(s); + } +} diff --git a/benchmarks/Micro/DispatchBenchmarks.cs b/benchmarks/Micro/DispatchBenchmarks.cs new file mode 100644 index 0000000..53e686a --- /dev/null +++ b/benchmarks/Micro/DispatchBenchmarks.cs @@ -0,0 +1,76 @@ +using System; +using System.Text; +using AustinHarris.JsonRpc.Serialization; +using BenchmarkDotNet.Attributes; + +namespace AustinHarris.JsonRpc.Micro +{ + /// + /// The five request shapes of TestServer_Console's sync benchmark, one at a time, through the byte-first + /// processor with the built-in serializer. Same wire names as the console harness so the rows compare. + /// + [MemoryDiagnoser(displayGenColumns: false)] + public class DispatchBenchmarks + { + private const string Session = "micro-sync"; + + private PooledByteBufferWriter _out; + private ReadOnlyMemory _add, _addInt, _nullableFloat, _decimal, _string, _batch, _notification; + + public sealed class Service + { + [JsonRpcMethod] private double add(double l, double r) => l + r; + [JsonRpcMethod] private int addInt(int l, int r) => l + r; + [JsonRpcMethod] public float? NullableFloatToNullableFloat(float? a) => a; + [JsonRpcMethod] public decimal? Test2(decimal x) => x; + [JsonRpcMethod] public string StringMe(string x) => x; + } + + [GlobalSetup] + public void Setup() + { + ServiceBinder.BindService(Session, new Service()); + _out = new PooledByteBufferWriter(1024); + _add = Utf8("{\"method\":\"add\",\"params\":[1,2],\"id\":1}"); + _addInt = Utf8("{\"method\":\"addInt\",\"params\":[1,7],\"id\":2}"); + _nullableFloat = Utf8("{\"method\":\"NullableFloatToNullableFloat\",\"params\":[1.23],\"id\":3}"); + _decimal = Utf8("{\"method\":\"Test2\",\"params\":[3.456],\"id\":4}"); + _string = Utf8("{\"method\":\"StringMe\",\"params\":[\"Foo\"],\"id\":5}"); + _batch = Utf8("[{\"method\":\"add\",\"params\":[1,2],\"id\":1},{\"method\":\"addInt\",\"params\":[1,7],\"id\":2},{\"method\":\"NullableFloatToNullableFloat\",\"params\":[1.23],\"id\":3},{\"method\":\"Test2\",\"params\":[3.456],\"id\":4},{\"method\":\"StringMe\",\"params\":[\"Foo\"],\"id\":5}]"); + _notification = Utf8("{\"method\":\"addInt\",\"params\":[1,7]}"); + + // fail loudly if a shape does not answer what the console harness expects + Expect(_addInt, "{\"jsonrpc\":\"2.0\",\"result\":8,\"id\":2}"); + Expect(_string, "{\"jsonrpc\":\"2.0\",\"result\":\"Foo\",\"id\":5}"); + } + + [GlobalCleanup] + public void Cleanup() => _out.Dispose(); + + [Benchmark(Baseline = true)] public int Add() => Run(_add); + [Benchmark] public int AddInt() => Run(_addInt); + [Benchmark] public int NullableFloat() => Run(_nullableFloat); + [Benchmark] public int Decimal() => Run(_decimal); + [Benchmark] public int String() => Run(_string); + [Benchmark] public int Batch5() => Run(_batch); + [Benchmark] public int Notification() => Run(_notification); + + private int Run(ReadOnlyMemory input) + { + var w = _out; + w.Clear(); + JsonRpcProcessor.Process(Session, input, w); + return w.WrittenCount; + } + + private void Expect(ReadOnlyMemory input, string expected) + { + _out.Clear(); + JsonRpcProcessor.Process(Session, input, _out); + var actual = _out.ToString(); + if (actual != expected) throw new InvalidOperationException("unexpected response: " + actual); + } + + private static ReadOnlyMemory Utf8(string s) => Encoding.UTF8.GetBytes(s); + } +} diff --git a/benchmarks/Micro/InterfaceBindingBenchmarks.cs b/benchmarks/Micro/InterfaceBindingBenchmarks.cs new file mode 100644 index 0000000..594794a --- /dev/null +++ b/benchmarks/Micro/InterfaceBindingBenchmarks.cs @@ -0,0 +1,106 @@ +using System; +using System.Text; +using AustinHarris.JsonRpc.Serialization; +using BenchmarkDotNet.Attributes; + +namespace AustinHarris.JsonRpc.Micro +{ + /// + /// The same five shapes as , bound through an interface tree with + /// ServiceBinder.BindInterface under the same wire names in another session, plus the dotted names an + /// interface tree produces (a long name costs hashing and comparison proportional to its length; that is the + /// name, not the binding). Interface-bound rows must match the class-bound rows within noise. + /// + [MemoryDiagnoser(displayGenColumns: false)] + public class InterfaceBindingBenchmarks + { + private const string Flat = "micro-iface-flat"; + private const string Tree = "micro-iface-tree"; + + private PooledByteBufferWriter _out; + private ReadOnlyMemory _add, _addInt, _nullableFloat, _decimal, _string, _treeAddInt, _treeDeep; + + public interface ICalculator + { + double add(double l, double r); + int addInt(int l, int r); + float? NullableFloatToNullableFloat(float? a); + decimal? Test2(decimal x); + string StringMe(string x); + } + + public interface IRoot + { + ICalculator Calc { get; } + IAdmin Admin { get; } + } + + public interface IAdmin + { + ICalculator Calc { get; } + } + + public sealed class Calculator : ICalculator + { + public double add(double l, double r) => l + r; + public int addInt(int l, int r) => l + r; + public float? NullableFloatToNullableFloat(float? a) => a; + public decimal? Test2(decimal x) => x; + public string StringMe(string x) => x; + } + + public sealed class Root : IRoot, IAdmin + { + public ICalculator Calc { get; } = new Calculator(); + public IAdmin Admin => this; + } + + [GlobalSetup] + public void Setup() + { + ServiceBinder.BindInterface(Flat, new Calculator()); + ServiceBinder.BindInterface(Tree, new Root()); + _out = new PooledByteBufferWriter(1024); + _add = Utf8("{\"method\":\"add\",\"params\":[1,2],\"id\":1}"); + _addInt = Utf8("{\"method\":\"addInt\",\"params\":[1,7],\"id\":2}"); + _nullableFloat = Utf8("{\"method\":\"NullableFloatToNullableFloat\",\"params\":[1.23],\"id\":3}"); + _decimal = Utf8("{\"method\":\"Test2\",\"params\":[3.456],\"id\":4}"); + _string = Utf8("{\"method\":\"StringMe\",\"params\":[\"Foo\"],\"id\":5}"); + _treeAddInt = Utf8("{\"method\":\"Calc.addInt\",\"params\":[1,7],\"id\":2}"); + _treeDeep = Utf8("{\"method\":\"Admin.Calc.addInt\",\"params\":[1,7],\"id\":2}"); + + Expect(Flat, _addInt, "{\"jsonrpc\":\"2.0\",\"result\":8,\"id\":2}"); + Expect(Tree, _treeAddInt, "{\"jsonrpc\":\"2.0\",\"result\":8,\"id\":2}"); + Expect(Tree, _treeDeep, "{\"jsonrpc\":\"2.0\",\"result\":8,\"id\":2}"); + } + + [GlobalCleanup] + public void Cleanup() => _out.Dispose(); + + [Benchmark(Baseline = true)] public int Add() => Run(Flat, _add); + [Benchmark] public int AddInt() => Run(Flat, _addInt); + [Benchmark] public int NullableFloat() => Run(Flat, _nullableFloat); + [Benchmark] public int Decimal() => Run(Flat, _decimal); + [Benchmark] public int String() => Run(Flat, _string); + [Benchmark] public int TreeAddInt() => Run(Tree, _treeAddInt); + [Benchmark] public int TreeDeepAddInt() => Run(Tree, _treeDeep); + + private int Run(string session, ReadOnlyMemory input) + { + var w = _out; + w.Clear(); + JsonRpcProcessor.Process(session, input, w); + return w.WrittenCount; + } + + private void Expect(string session, ReadOnlyMemory input, string expected) + { + _out.Clear(); + JsonRpcProcessor.Process(session, input, _out); + var actual = _out.ToString(); + if (actual != expected) throw new InvalidOperationException("unexpected response: " + actual); + } + + private static ReadOnlyMemory Utf8(string s) => Encoding.UTF8.GetBytes(s); + } +} diff --git a/benchmarks/Micro/Program.cs b/benchmarks/Micro/Program.cs new file mode 100644 index 0000000..f733168 --- /dev/null +++ b/benchmarks/Micro/Program.cs @@ -0,0 +1,15 @@ +using BenchmarkDotNet.Running; + +namespace AustinHarris.JsonRpc.Micro +{ + public static class Program + { + // `dotnet run -c Release -- --filter '*'` runs everything; `--filter '*Sync*' --job short` is a quick look. + // `--disasm` adds the JIT disassembly of each benchmark (with the compiled invokers inlined where the JIT did so). + public static int Main(string[] args) + { + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + return 0; + } + } +} diff --git a/benchmarks/Micro/README.md b/benchmarks/Micro/README.md new file mode 100644 index 0000000..a8670e0 --- /dev/null +++ b/benchmarks/Micro/README.md @@ -0,0 +1,18 @@ +# Micro-benchmarks + +BenchmarkDotNet timings of one request on one core, per request shape, with an allocation column. Use these to judge a change to the dispatch path; use `TestServer_Console --sync` for throughput under load and the README tables. + +```bash +dotnet run -c Release --project benchmarks/Micro -- --filter '*' +``` + +Useful arguments: `--filter '*Dispatch*'` for one class, `--job short` for a quick look (3 warm-up and 3 measured iterations), `--disasm` to print the JIT disassembly of each benchmark. + +Rows: + +- `DispatchBenchmarks`: the five shapes of the console harness (`add`, `addInt`, nullable float, decimal, string), a batch of the five, and a notification, through `JsonRpcProcessor.Process` with the built-in serializer and a class registered with `[JsonRpcMethod]`. +- `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. +- `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` between two builds run under the same load; on a busy machine the ratio is meaningful, the absolute number is not. diff --git a/benchmarks/SimdJsonEval/Program.cs b/benchmarks/SimdJsonEval/Program.cs new file mode 100644 index 0000000..0056ac1 --- /dev/null +++ b/benchmarks/SimdJsonEval/Program.cs @@ -0,0 +1,419 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using AustinHarris.JsonRpc.Jsmn; +using AustinHarris.JsonRpc.Serialization; +using SimdJson; +using JsonDocument = SimdJson.JsonDocument; +using JsonValueKind = SimdJson.JsonValueKind; + +namespace SimdJsonEval +{ + /// + /// Hand-rolled Stopwatch harness (warm-up, calibrated trial length, several trials, min/median) so the + /// whole matrix runs in a couple of minutes. Allocation is measured with GC.GetAllocatedBytesForCurrentThread. + /// Every measured function returns an int checksum that is accumulated and printed so nothing is dead code. + /// + internal static class Program + { + private const string S1 = "{\"method\":\"add\",\"params\":[1,2],\"id\":1}"; + private const string S2 = "{\"method\":\"NullableFloatToNullableFloat\",\"params\":[1.23],\"id\":3}"; + private const string S3 = "{\"method\":\"StringMe\",\"params\":[\"Foo\"],\"id\":5}"; + + private static readonly byte[] KeyMethod = Encoding.ASCII.GetBytes("method"); + private static readonly byte[] KeyParams = Encoding.ASCII.GetBytes("params"); + private static readonly byte[] KeyId = Encoding.ASCII.GetBytes("id"); + + private static int Main(string[] args) + { + bool quick = args.Contains("--quick"); + + var inputs = new List<(string name, byte[] bytes)> + { + ("add [1,2]", Encoding.UTF8.GetBytes(S1)), + ("NullableFloat [1.23]", Encoding.UTF8.GetBytes(S2)), + ("StringMe [\"Foo\"]", Encoding.UTF8.GetBytes(S3)), + ("batch x40 (~2 KB)", Encoding.UTF8.GetBytes(BuildBatch(40))), + ("object params x30 (~2 KB)", Encoding.UTF8.GetBytes(BuildBigObject(30))), + // a notification: no id. The jsmn reader reports IdKind.Absent for free; the simdjson binding + // signals a missing field by throwing SimdJsonException (TryGetField is a catch wrapper). + ("notification (no id)", Encoding.UTF8.GetBytes("{\"method\":\"add\",\"params\":[1,2]}")), + }; + + Console.WriteLine("SimdJsonEval: jsmn vs Utf8JsonReader vs SimdJson.Net (simdjson " + SimdJsonParser.GetVersion() + ", kernel " + SimdJsonParser.ActiveImplementation + ", RequiredPadding=" + SimdJsonParser.RequiredPadding + ")"); + Console.WriteLine("Runtime " + RuntimeInformation.FrameworkDescription + " on " + RuntimeInformation.OSDescription + " / " + RuntimeInformation.ProcessArchitecture + ", " + Environment.ProcessorCount + " logical cores, Server GC=" + System.Runtime.GCSettings.IsServerGC + ", tiered PGO on"); + Console.WriteLine(); + foreach (var (name, bytes) in inputs) + Console.WriteLine($" input '{name}': {bytes.Length} bytes"); + Console.WriteLine(); + + // ---- correctness smoke test: every walker must see the same method / id / param count ---- + foreach (var (name, bytes) in inputs) + { + var a = Describe.Jsmn(bytes); + var b = Describe.Simd(bytes); + if (a != b) { Console.WriteLine($"MISMATCH on '{name}':\n jsmn: {a}\n simd: {b}"); return 1; } + } + Console.WriteLine("smoke test: jsmn reader and simdjson walk agree on method/id/param-count for every input"); + Console.WriteLine(); + + var rows = new List(); + + // the cost of one trivial P/Invoke through this binding (GetPadding: no arguments, returns a constant) + rows.Add(Bench.Run("(any)", "P/Invoke floor: SimdJsonParser.RequiredPadding", () => SimdJsonParser.RequiredPadding, quick)); + + foreach (var (name, bytes) in inputs) + { + var doc = new ReadOnlyMemory(bytes); + + var tok = new JsmnTokenizer(); + rows.Add(Bench.Run(name, "jsmn: JsmnTokenizer.Parse", () => Walks.JsmnTokenize(tok, doc.Span), quick)); + + var reader = new JsmnRequestReader(JsmnSerializer.Instance); + rows.Add(Bench.Run(name, "jsmn: JsmnRequestReader parse+locate", () => Walks.JsmnReader(reader, doc), quick)); + + rows.Add(Bench.Run(name, "STJ: Utf8JsonReader full walk", () => Walks.Utf8JsonReaderWalk(doc.Span), quick)); + + using (var parser = new SimdJsonParser()) + { + // Parse(span) alone (2 P/Invokes: parse + destroy document): the floor for any walk built on this binding. + rows.Add(Bench.Run(name, "simdjson: Parse(span) only, no walk", () => Walks.SimdParseOnly(parser, doc.Span), quick)); + + // Parse(span): the native side copies the bytes into its own padded buffer. + rows.Add(Bench.Run(name, "simdjson: Parse(span) + GetField x3", () => Walks.SimdParseGetField(parser, doc.Span), quick)); + rows.Add(Bench.Run(name, "simdjson: Parse(span) + enumerate", () => Walks.SimdParseEnumerate(parser, doc.Span), quick)); + + // ParseInPlace: the caller supplies RequiredPadding bytes of slack. Two shapes: + // (i) the transport buffer is NOT padded, so the request is copied into a padded pooled buffer first + // (ii) the transport buffer was over-allocated, so no copy + var padded = new byte[bytes.Length + SimdJsonParser.RequiredPadding]; + Buffer.BlockCopy(bytes, 0, padded, 0, bytes.Length); + var paddedMem = new ReadOnlyMemory(padded); + rows.Add(Bench.Run(name, "simdjson: copy+ParseInPlace + GetField x3", () => Walks.SimdCopyParseInPlaceGetField(parser, bytes, padded), quick)); + rows.Add(Bench.Run(name, "simdjson: ParseInPlace(prepadded) + GetField x3", () => Walks.SimdParseInPlaceGetField(parser, paddedMem, bytes.Length), quick)); + } + } + + Console.WriteLine(); + Console.WriteLine("| input | parser | ns/op (min) | ns/op (median) | B/op | checksum |"); + Console.WriteLine("|---|---|---:|---:|---:|---:|"); + foreach (var r in rows) + Console.WriteLine($"| {r.Input} | {r.Parser} | {r.MinNs:F1} | {r.MedianNs:F1} | {r.BytesPerOp} | {r.Checksum} |"); + return 0; + } + + private static string BuildBatch(int n) + { + var sb = new StringBuilder("["); + string[] cycle = { S1, S2, S3 }; + for (int i = 0; i < n; i++) + { + if (i > 0) sb.Append(','); + sb.Append(cycle[i % 3]); + } + return sb.Append(']').ToString(); + } + + private static string BuildBigObject(int members) + { + // ~2 KB single request whose params is one object with `members` members of mixed scalar types. + var sb = new StringBuilder("{\"method\":\"Configure\",\"params\":{"); + for (int i = 0; i < members; i++) + { + if (i > 0) sb.Append(','); + sb.Append("\"member").Append(i.ToString("00")).Append("\":"); + switch (i % 5) + { + case 0: sb.Append(i * 1234567L); break; + case 1: sb.Append((i * 3.14159).ToString("R", System.Globalization.CultureInfo.InvariantCulture)); break; + case 2: sb.Append("\"value-").Append(i).Append("-the quick brown fox jumps over the lazy dog\""); break; + case 3: sb.Append(i % 2 == 0 ? "true" : "false"); break; + default: sb.Append("null"); break; + } + } + return sb.Append("},\"id\":7}").ToString(); + } + } + + internal readonly struct Row + { + public readonly string Input, Parser; + public readonly double MinNs, MedianNs; + public readonly long BytesPerOp; + public readonly long Checksum; + public Row(string input, string parser, double min, double median, long bytes, long checksum) + { Input = input; Parser = parser; MinNs = min; MedianNs = median; BytesPerOp = bytes; Checksum = checksum; } + } + + internal static class Bench + { + public static Row Run(string input, string parser, Func op, bool quick) + { + // warm-up: JIT + tier-up (tiered PGO needs a few thousand calls before the optimised tier kicks in) + long acc = 0; + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < (quick ? 150 : 600)) { for (int i = 0; i < 1000; i++) acc += op(); } + + // calibrate the trial length to ~200 ms + int n = 1000; + sw.Restart(); + for (int i = 0; i < n; i++) acc += op(); + double perOp = sw.Elapsed.TotalMilliseconds / n; + n = (int)Math.Max(1000, Math.Min(20_000_000, (quick ? 60 : 200) / Math.Max(perOp, 1e-6))); + + int trials = quick ? 5 : 9; + var results = new double[trials]; + for (int t = 0; t < trials; t++) + { + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + sw.Restart(); + for (int i = 0; i < n; i++) acc += op(); + sw.Stop(); + results[t] = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / n; + } + Array.Sort(results); + + // allocation: one extra trial, bytes allocated on this thread divided by n + int m = Math.Max(1000, n / 10); + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < m; i++) acc += op(); + long perOpBytes = (GC.GetAllocatedBytesForCurrentThread() - before) / m; + + Console.WriteLine($"{input,-28} {parser,-46} {results[0],9:F1} ns (min) {results[trials / 2],9:F1} ns (median) {perOpBytes,6} B/op"); + return new Row(input, parser, results[0], results[trials / 2], perOpBytes, acc); + } + } + + internal static class Describe + { + public static string Jsmn(byte[] bytes) + { + var r = new JsmnRequestReader(JsmnSerializer.Instance); + if (!r.TryParse(bytes, out var err)) return "parse error: " + err; + var sb = new StringBuilder(); + for (int i = 0; i < r.Count; i++) + { + r.Select(i); + sb.Append(Encoding.UTF8.GetString(r.MethodUtf8)).Append('|').Append(Encoding.UTF8.GetString(r.IdRaw)).Append('|').Append(r.ParamCount).Append(';'); + } + r.Release(); + return sb.ToString(); + } + + public static string Simd(byte[] bytes) + { + using var parser = new SimdJsonParser(); + using var doc = parser.Parse(bytes); + var sb = new StringBuilder(); + if (doc.ValueKind == JsonValueKind.Array) + { + using var arr = doc.GetArray(); + foreach (var item in arr) { using (item) { using var o = item.GetObject(); One(o, sb); } } + } + else + { + using var o = doc.GetObject(); + One(o, sb); + } + return sb.ToString(); + + static void One(JsonObject o, StringBuilder sb) + { + using var m = o.GetField("method"); + sb.Append(m.GetString()).Append('|'); + using var p = o.GetField("params"); + int count = 0; + if (p.ValueKind == JsonValueKind.Array) { using var a = p.GetArray(); foreach (var e in a) { e.Dispose(); count++; } } + else if (p.ValueKind == JsonValueKind.Object) { using var po = p.GetObject(); foreach (var e in po) { e.Value.Dispose(); count++; } } + if (o.TryGetField("id", out var id)) { using (id) sb.Append(id.GetRawJsonToken()); } + sb.Append('|').Append(count).Append(';'); + } + } + } + + internal static class Walks + { + // (a) the tokenizer alone, one instance reused as the reader does + [MethodImpl(MethodImplOptions.NoInlining)] + public static int JsmnTokenize(JsmnTokenizer tok, ReadOnlySpan doc) + { + int n = tok.Parse(doc); + if (n < 0) throw new InvalidOperationException("jsmn error " + n); + return n; + } + + // (a') the full envelope read the core performs: tokenize, then per request locate method / params / id + // and touch the slices the binder would consume (MethodUtf8, IdRaw, ParamRaw(i), ParamNameUtf8(i)) + [MethodImpl(MethodImplOptions.NoInlining)] + public static int JsmnReader(JsmnRequestReader r, ReadOnlyMemory doc) + { + if (!r.TryParse(doc, out _)) throw new InvalidOperationException("jsmn parse error"); + int acc = 0; + for (int i = 0; i < r.Count; i++) + { + r.Select(i); + acc += r.MethodUtf8.Length + r.IdRaw.Length; + int n = r.ParamCount; + for (int p = 0; p < n; p++) acc += r.ParamRaw(p).Length + r.ParamNameUtf8(p).Length; + } + r.Release(); + return acc; + } + + // (b) System.Text.Json reference: a full forward token walk, comparing property names to the three keys + [MethodImpl(MethodImplOptions.NoInlining)] + public static int Utf8JsonReaderWalk(ReadOnlySpan doc) + { + var reader = new Utf8JsonReader(doc, isFinalBlock: true, state: default); + int acc = 0; + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.PropertyName: + if (reader.ValueTextEquals(KeyMethod) || reader.ValueTextEquals(KeyParams) || reader.ValueTextEquals(KeyId)) acc++; + break; + case JsonTokenType.String: + case JsonTokenType.Number: + acc += reader.ValueSpan.Length; + break; + } + } + return acc; + } + + private static readonly byte[] KeyMethod = Encoding.ASCII.GetBytes("method"); + private static readonly byte[] KeyParams = Encoding.ASCII.GetBytes("params"); + private static readonly byte[] KeyId = Encoding.ASCII.GetBytes("id"); + + // (c0) simdjson On-Demand: parse and dispose, nothing else + [MethodImpl(MethodImplOptions.NoInlining)] + public static int SimdParseOnly(SimdJsonParser parser, ReadOnlySpan doc) + { + using var d = parser.Parse(doc); + return doc.Length; + } + + // (c) simdjson On-Demand: parse, then the minimum envelope walk (method, params elements, id) + [MethodImpl(MethodImplOptions.NoInlining)] + public static int SimdParseGetField(SimdJsonParser parser, ReadOnlySpan doc) + { + using var d = parser.Parse(doc); + return SimdWalkGetField(d); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int SimdCopyParseInPlaceGetField(SimdJsonParser parser, byte[] source, byte[] padded) + { + // what the processor would have to do when the transport buffer has no slack: copy into a padded buffer + Buffer.BlockCopy(source, 0, padded, 0, source.Length); + using var d = parser.ParseInPlace(padded, source.Length); + return SimdWalkGetField(d); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int SimdParseInPlaceGetField(SimdJsonParser parser, ReadOnlyMemory padded, int length) + { + using var d = parser.ParseInPlace(padded, length); + return SimdWalkGetField(d); + } + + private static int SimdWalkGetField(JsonDocument d) + { + if (d.ValueKind == JsonValueKind.Array) + { + int acc = 0; + using var arr = d.GetArray(); + foreach (var item in arr) + { + using (item) + { + using var o = item.GetObject(); + acc += SimdRequestGetField(o); + } + } + return acc; + } + else + { + using var o = d.GetObject(); + return SimdRequestGetField(o); + } + } + + // GetField = simdjson find_field_unordered; the three keys are in document order so each is a forward scan + private static int SimdRequestGetField(JsonObject o) + { + int acc; + using (var m = o.GetField("method")) acc = m.GetStringSpan().Length; + using (var p = o.GetField("params")) acc += SimdParams(p); + // id is optional (notifications); TryGetField is the binding's non-throwing lookup, but it is + // implemented as catch(SimdJsonException) around GetField, so a miss still costs a throw. + if (o.TryGetField("id", out var id)) { using (id) acc += id.GetRawJsonTokenSpan().Length; } + return acc; + } + + private static int SimdParams(JsonValue p) + { + int acc = 0; + var kind = p.ValueKind; + if (kind == JsonValueKind.Array) + { + using var a = p.GetArray(); + foreach (var e in a) { acc += e.GetRawJsonTokenSpan().Length; e.Dispose(); } + } + else if (kind == JsonValueKind.Object) + { + using var po = p.GetObject(); + foreach (var prop in po) { acc += prop.EscapedNameSpan.Length + prop.Value.GetRawJsonTokenSpan().Length; prop.Value.Dispose(); } + } + return acc; + } + + // (c') the same, but locating the keys by enumerating the object's members (as the jsmn reader does) + [MethodImpl(MethodImplOptions.NoInlining)] + public static int SimdParseEnumerate(SimdJsonParser parser, ReadOnlySpan doc) + { + using var d = parser.Parse(doc); + if (d.ValueKind == JsonValueKind.Array) + { + int acc = 0; + using var arr = d.GetArray(); + foreach (var item in arr) + { + using (item) + { + using var o = item.GetObject(); + acc += SimdRequestEnumerate(o); + } + } + return acc; + } + else + { + using var o = d.GetObject(); + return SimdRequestEnumerate(o); + } + } + + private static int SimdRequestEnumerate(JsonObject o) + { + int acc = 0; + foreach (var prop in o) + { + var name = prop.EscapedNameSpan; + if (name.SequenceEqual(KeyMethod)) acc += prop.Value.GetStringSpan().Length; + else if (name.SequenceEqual(KeyParams)) acc += SimdParams(prop.Value); + else if (name.SequenceEqual(KeyId)) acc += prop.Value.GetRawJsonTokenSpan().Length; + prop.Value.Dispose(); + } + return acc; + } + } +} diff --git a/benchmarks/SimdJsonEval/RESULTS.md b/benchmarks/SimdJsonEval/RESULTS.md new file mode 100644 index 0000000..72f2a17 --- /dev/null +++ b/benchmarks/SimdJsonEval/RESULTS.md @@ -0,0 +1,205 @@ +# simdjson evaluation for JSON-RPC.NET 2.0 (fourth serializer?) + +Decision rule from the owner: adopt simdjson **only** if it is measurably faster than the built-in jsmn +tokenizer on the library's own workload (single small JSON-RPC requests; the whole pipeline runs at +~273 ns per request single-threaded, so the tokenizer's share is well under 100 ns). + +**Result: do not adopt.** On every input the simdjson binding is 3.4x to 6x slower than the full jsmn +envelope read, allocates 350 B to 14 KB per document where jsmn allocates nothing, and cannot hand back +slices of the request bytes in the shape `JsonRpcRequestReader` needs without extra copies. Details below. + +## Environment + +| item | value | +|---|---| +| machine | AMD Ryzen 7 7800X3D (8C/16T), 63 GB RAM, Windows 11 Pro 10.0.26200, x64 | +| SDK / runtime | .NET SDK 10.0.112 (global.json pins 10.0.100, rollForward latestFeature), runtime .NET 10.0.12, workstation GC, tiered PGO | +| repo | branch `finish-netstandard-upgrade` (worktree branch `worktree-agent-a5c308ba699f30667`), base commit `e598569` | +| core under test | `Json-Rpc/AustinHarris.JsonRpc.csproj` net10.0 target, Release | +| simdjson binding | NuGet `SimdJson.Net` 4.6.11.1 (published 2026-09-15, author Zoltan Csizmadia, MIT), wraps simdjson 4.6.11 On-Demand via a C ABI shim (`SimdJsonNative.dll`) | +| kernel selected by simdjson | `icelake` (AVX-512) | +| `SimdJsonParser.RequiredPadding` | 64 bytes (= SIMDJSON_PADDING) | +| harness | `benchmarks/SimdJsonEval` hand-rolled Stopwatch loop: 600 ms warm-up, trial length calibrated to ~200 ms, 9 trials, min and median reported; B/op from `GC.GetAllocatedBytesForCurrentThread` over a further n/10 iterations | + +Run: `dotnet run -c Release --project benchmarks/SimdJsonEval` (add `--quick` for a 30-second pass). + +## Candidate bindings on NuGet (2026-09-22) + +| package | latest | published | TFMs | native runtimes | license | verdict | +|---|---|---|---|---|---|---| +| `SimdJson.Net` | 4.6.11.1 | 2026-09-15 | net8.0, net9.0, net10.0 | win-x64, win-arm64, linux-x64, linux-arm64, linux-musl-x64/arm64, osx-x64/arm64 | MIT (native part Apache-2.0) | the only maintained option; benchmarked here | +| `SimdJsonSharp.Bindings` (EgorBo) | 1.7.0 | 2019-05-26 | netstandard2.0 | win-x64, osx-x64 only | Apache-2.0 | 7 years stale, wraps a 2019 simdjson (DOM only, pre On-Demand); not evaluated | +| `SimdJsonSharp.Managed` (EgorBo) | 1.5.0 | 2019-03 | netcoreapp3.0 | pure C# port | Apache-2.0 | stale port of the 2019 DOM parser; not evaluated | +| `SimdJsonBindingsParser` / `SimdJsonBindings` | 1.8.1 / 1.7.0 | 2024-02 | - | none | Apache-2.0 | unlisted / deprecated by the owner | + +`SimdJson.Net` is net8.0+ only, so a serializer built on it could never ship in the core package +(netstandard2.0/2.1 targets); it would be a companion package like the Json.NET / STJ ones. + +## Inputs + +| input | bytes | +|---|---:| +| `{"method":"add","params":[1,2],"id":1}` | 38 | +| `{"method":"NullableFloatToNullableFloat","params":[1.23],"id":3}` | 64 | +| `{"method":"StringMe","params":["Foo"],"id":5}` | 45 | +| batch x40: array of the three requests above, cycled | 1990 | +| object params x30: `{"method":"Configure","params":{ 30 members: int / double / string / bool / null },"id":7}` | 879 | +| notification (no id): `{"method":"add","params":[1,2]}` | 31 | + +## What each row measures + +| row | what it does | +|---|---| +| jsmn: JsmnTokenizer.Parse | (a) `JsmnTokenizer.Parse(span)` on one reused tokenizer instance (as `JsmnRequestReader` does): full token array, no walk | +| jsmn: JsmnRequestReader parse+locate | the envelope read the core actually performs: `TryParse`, then for every request `Select(i)`, `MethodUtf8`, `IdRaw`, `ParamCount`, `ParamRaw(i)`, `ParamNameUtf8(i)`, then `Release()` | +| STJ: Utf8JsonReader full walk | (b) `Utf8JsonReader` over the whole document, `ValueTextEquals` on property names, touching `ValueSpan` of scalars | +| simdjson: Parse(span) only, no walk | `SimdJsonParser.Parse(ReadOnlySpan)` + `Dispose` = 2 P/Invokes; the native side copies the bytes into its own padded buffer and runs stage 1 (structural index). No values are parsed (On-Demand is lazy) | +| simdjson: Parse(span) + GetField x3 | (c) parse, `ValueKind`, `GetObject`, `GetField("method")` -> `GetStringSpan`, `GetField("params")` -> iterate elements / members taking `GetRawJsonTokenSpan` (and `EscapedNameSpan`), `TryGetField("id")` -> `GetRawJsonTokenSpan`; dispose everything. For the batch: `GetArray`, iterate, `GetObject` per element, same walk | +| simdjson: Parse(span) + enumerate | same but the three keys are found by enumerating the object's members (`foreach JsonProperty`), which is how the jsmn reader locates them | +| simdjson: copy+ParseInPlace + GetField x3 | what the processor would do when the transport buffer has no slack: `Buffer.BlockCopy` into a padded pooled buffer, `ParseInPlace(padded, len)`, same walk | +| simdjson: ParseInPlace(prepadded) + GetField x3 | the buffer already has 64 bytes of slack (an over-allocated transport buffer): no copy, same walk | +| P/Invoke floor | one trivial call through the binding (`SimdJsonParser.RequiredPadding` -> `SimdJsonNative_GetPadding`) | + +All walkers were cross-checked on every input (method, id, param count agree) before timing. + +## Results (ns per document, single thread) + +| input | parser | ns/op (min) | ns/op (median) | B/op | +|---|---|---:|---:|---:| +| (any) | P/Invoke floor: SimdJsonParser.RequiredPadding | 6.0 | 6.3 | 0 | +| add [1,2] | jsmn: JsmnTokenizer.Parse | 75.5 | 77.0 | 0 | +| add [1,2] | jsmn: JsmnRequestReader parse+locate | 112.8 | 122.5 | 0 | +| add [1,2] | STJ: Utf8JsonReader full walk | 100.9 | 106.6 | 0 | +| add [1,2] | simdjson: Parse(span) only, no walk | 154.0 | 156.2 | 64 | +| add [1,2] | simdjson: Parse(span) + GetField x3 | 679.3 | 690.1 | 392 | +| add [1,2] | simdjson: Parse(span) + enumerate | 734.3 | 749.1 | 576 | +| add [1,2] | simdjson: copy+ParseInPlace + GetField x3 | 711.3 | 740.8 | 392 | +| add [1,2] | simdjson: ParseInPlace(prepadded) + GetField x3 | 742.6 | 752.4 | 392 | +| NullableFloat [1.23] | jsmn: JsmnTokenizer.Parse | 87.4 | 90.3 | 0 | +| NullableFloat [1.23] | jsmn: JsmnRequestReader parse+locate | 115.9 | 127.4 | 0 | +| NullableFloat [1.23] | STJ: Utf8JsonReader full walk | 90.0 | 91.7 | 0 | +| NullableFloat [1.23] | simdjson: Parse(span) only, no walk | 145.7 | 146.3 | 64 | +| NullableFloat [1.23] | simdjson: Parse(span) + GetField x3 | 633.6 | 644.7 | 352 | +| NullableFloat [1.23] | simdjson: Parse(span) + enumerate | 686.1 | 723.6 | 536 | +| NullableFloat [1.23] | simdjson: copy+ParseInPlace + GetField x3 | 660.8 | 668.3 | 352 | +| NullableFloat [1.23] | simdjson: ParseInPlace(prepadded) + GetField x3 | 626.5 | 645.8 | 352 | +| StringMe ["Foo"] | jsmn: JsmnTokenizer.Parse | 86.8 | 88.7 | 0 | +| StringMe ["Foo"] | jsmn: JsmnRequestReader parse+locate | 110.9 | 111.7 | 0 | +| StringMe ["Foo"] | STJ: Utf8JsonReader full walk | 86.3 | 89.8 | 0 | +| StringMe ["Foo"] | simdjson: Parse(span) only, no walk | 145.9 | 161.6 | 64 | +| StringMe ["Foo"] | simdjson: Parse(span) + GetField x3 | 625.3 | 635.2 | 352 | +| StringMe ["Foo"] | simdjson: Parse(span) + enumerate | 671.9 | 677.3 | 536 | +| StringMe ["Foo"] | simdjson: copy+ParseInPlace + GetField x3 | 623.1 | 638.6 | 352 | +| StringMe ["Foo"] | simdjson: ParseInPlace(prepadded) + GetField x3 | 627.7 | 656.5 | 352 | +| batch x40 (~2 KB) | jsmn: JsmnTokenizer.Parse | 3016.5 | 3060.5 | 0 | +| batch x40 (~2 KB) | jsmn: JsmnRequestReader parse+locate | 4121.8 | 4227.1 | 0 | +| batch x40 (~2 KB) | STJ: Utf8JsonReader full walk | 3307.2 | 3398.2 | 0 | +| batch x40 (~2 KB) | simdjson: Parse(span) only, no walk | 459.0 | 462.6 | 64 | +| batch x40 (~2 KB) | simdjson: Parse(span) + GetField x3 | 22539.6 | 23146.3 | 13832 | +| batch x40 (~2 KB) | simdjson: Parse(span) + enumerate | 24371.6 | 27758.4 | 21192 | +| batch x40 (~2 KB) | simdjson: copy+ParseInPlace + GetField x3 | 22020.0 | 24049.3 | 13832 | +| batch x40 (~2 KB) | simdjson: ParseInPlace(prepadded) + GetField x3 | 21869.6 | 22460.2 | 13832 | +| object params x30 (~2 KB) | jsmn: JsmnTokenizer.Parse | 913.4 | 928.6 | 0 | +| object params x30 (~2 KB) | jsmn: JsmnRequestReader parse+locate | 1145.1 | 1160.5 | 0 | +| object params x30 (~2 KB) | STJ: Utf8JsonReader full walk | 742.6 | 753.3 | 0 | +| object params x30 (~2 KB) | simdjson: Parse(span) only, no walk | 278.7 | 288.9 | 64 | +| object params x30 (~2 KB) | simdjson: Parse(span) + GetField x3 | 3819.5 | 3905.8 | 2736 | +| object params x30 (~2 KB) | simdjson: Parse(span) + enumerate | 3820.8 | 3930.4 | 2920 | +| object params x30 (~2 KB) | simdjson: copy+ParseInPlace + GetField x3 | 3757.1 | 3808.4 | 2736 | +| object params x30 (~2 KB) | simdjson: ParseInPlace(prepadded) + GetField x3 | 3732.8 | 3755.1 | 2736 | +| notification (no id) | jsmn: JsmnTokenizer.Parse | 62.8 | 65.8 | 0 | +| notification (no id) | jsmn: JsmnRequestReader parse+locate | 90.2 | 91.1 | 0 | +| notification (no id) | STJ: Utf8JsonReader full walk | 82.6 | 85.4 | 0 | +| notification (no id) | simdjson: Parse(span) only, no walk | 146.2 | 154.1 | 64 | +| notification (no id) | simdjson: Parse(span) + GetField x3 | 2705.0 | 2767.2 | 824 | +| notification (no id) | simdjson: Parse(span) + enumerate | 634.7 | 639.9 | 504 | +| notification (no id) | simdjson: copy+ParseInPlace + GetField x3 | 2669.7 | 2701.7 | 824 | +| notification (no id) | simdjson: ParseInPlace(prepadded) + GetField x3 | 2704.0 | 2833.1 | 824 | + +An earlier full run (before the parse-only and P/Invoke-floor rows were added) produced the same numbers +within noise; the min column is the stable one, medians on the first input group are occasionally +perturbed by the OS. + +## Reading the numbers + +- **Small requests (the workload):** the whole jsmn envelope read is 111-128 ns and allocation-free. + simdjson's parse *alone* (stage 1 + native copy + document handle, no values read) is 146-162 ns, i.e. + already slower than everything jsmn does, and the minimum envelope walk brings it to 625-750 ns with + 352-392 B of managed garbage per request. That is 5x-6x the jsmn reader and 2.3x the *entire* current + pipeline (~273 ns/request). +- **The 64-byte padding copy is not the problem.** `Parse(span)` (native-side copy), `copy+ParseInPlace` + (managed-side copy into a padded pooled buffer) and `ParseInPlace(prepadded)` (no copy) are all within + noise of each other (about 620-750 ns). Removing the copy by over-allocating transport buffers would + save at most ~10-30 ns; the cost is in the handle-per-node interop model, not in the memcpy. +- **~2 KB documents:** simdjson's structural index really is fast (459 ns for the 1990-byte batch vs + 3.0 us for jsmn's full tokenization), but the walk needed to get method/params/id out of it costs + ~550 ns per request (22 us for 40 requests vs 4.1 us for the jsmn reader). For the 30-member object + params: 3.7 us vs 1.15 us (jsmn) / 0.74 us (Utf8JsonReader). +- **Notifications:** the binding reports a missing field by throwing `SimdJsonException` + (`TryGetField` is `try { GetField } catch`), so every notification costs a thrown-and-caught exception: + 2.7 us and 824 B. Locating keys by enumeration avoids the throw but allocates a `string` per member + name (the enumerator always materialises the key). +- **Utf8JsonReader** (for reference) is on par with the jsmn tokenizer on small requests and faster on + the 30-member object; both are far ahead of the binding. + +## Interop and slicing findings (SimdJson.Net 4.6.11.1) + +- **Object model:** every `JsonDocument`, `JsonValue`, `JsonArray`, `JsonObject` and each iterator is a + managed class wrapping a native handle allocated by the shim (`SimdJsonNative_Create*`/`Destroy*`). + One P/Invoke to obtain each node, one to destroy it; no struct/ref-struct API and no way to reuse a + handle. `[LibraryImport]` with `Cdecl`, no `SuppressGCTransition`; the measured floor per call is + ~6 ns, the real calls cost more because each performs a native heap allocation or a lazy parse step. +- **P/Invoke count for one parse + minimum walk of `{"method":"add","params":[1,2],"id":1}`: 25** + (`Parse`, `DocumentGetType`, `DocumentGetObject`, 3x `ObjectGetFieldByKey`, `ValueGetString`, + `ValueGetType`, `ValueGetArray`, `ArrayBegin`, 3x `ArrayIterNext`, 2x `ValueRawJsonToken` for the + elements, `ValueRawJsonToken` for the id, `DestroyArrayIter`, `DestroyArray`, 6x `DestroyValue`, + `DestroyObject`, `DestroyDocument`). General formula for a single request with n array params: + 19 + 3n; for a batch element add `ValueGetObject` + `DestroyValue` + the per-element `ArrayIterNext`. + The 40-request batch is ~890 P/Invokes and ~330 managed objects (13.8 KB). +- **Slices of the request bytes (the `JsonRpcRequestReader` contract: `MethodUtf8`, `IdRaw`, + `ParamRaw(i)` are spans of the document):** + - With `Parse(span)` the shim copies the input into a native padded buffer, so `GetRawJsonTokenSpan` + / `GetRawJsonStringSpan` / `EscapedNameSpan` return spans over **native memory**, not the request. + They are valid only until the document is disposed and cannot be turned into a `ReadOnlyMemory` + of the document without another copy. + - With `ParseInPlace(ReadOnlyMemory, len)` the shim reads the caller's pinned buffer directly, so + raw-token spans do point into the request bytes (pointer arithmetic against the pinned base recovers + the offset). This requires `RequiredPadding` = 64 readable bytes after the JSON in *every* input + buffer, which the `Process(sessionId, ReadOnlyMemory, ...)` entry point cannot guarantee; the + processor would have to copy into an over-allocated pooled buffer on that path (the `ReadOnlySequence` + / span / string entry points already copy into `Scratch.Input`, which could simply be rented 64 bytes + larger). + - `GetStringSpan` (unescaped) points into the parser's own string buffer (native, overwritten on the next + parse), never into the input. `MethodUtf8` wants the escaped bytes, which `GetRawJsonStringSpan` + provides. + - Raw JSON of a **structured** param (`ParamRaw(i)` for an object/array parameter) needs + `GetArray()`/`GetObject()` + `GetRawJsonSpan()` + dispose (3 extra P/Invokes and one more object), and + it consumes the On-Demand iterator (forward-only), so params must be walked strictly in order. +- **Lifetime model:** one parser per thread (`SimdJsonParser.Shared` is `[ThreadStatic]`), one live + document per parser, every node must be disposed (or the native handle leaks). This maps onto the + per-thread `Scratch`/reader in `JsonRpcProcessor`, but the reader's `Release()` would have to dispose + a tree of handles rather than reset an int. +- **Lenient mode:** simdjson is strict RFC 8259 only; the Json.NET-style lenient envelope reading + (`JsonRpcSerializer.Lenient`) could not be served by it. +- **Packaging:** net8.0+ only; native binaries for win/linux/linux-musl/osx x64+arm64 are included and + load via `NativeLibrary` from `runtimes//native` (works out of the box with `dotnet run` on + win-x64). No x86, no netstandard. + +## Recommendation + +**Do not adopt** SimdJson.Net (or any current simdjson binding) as a fourth serializer. + +- On the library's workload it is 5x-6x slower than the built-in jsmn envelope read and would more than + double the whole per-request pipeline cost (~273 ns -> ~800 ns), while turning an allocation-free + path into 350-400 B of garbage per request (2.7 us and 824 B for notifications). +- The 64-byte padding is a real integration cost (a copy on the `ReadOnlyMemory` fast path unless + callers over-allocate), but it is not what makes it slow: the three parse variants are within noise. + The cost is structural in the binding: one native handle and one P/Invoke per JSON node, exceptions + for missing fields, and string materialisation of member names during enumeration. +- There is no document size at which this binding wins on this workload: at ~2 KB it is still 3.3x-5.3x + slower than the jsmn reader. The only thing simdjson wins is the structural-index pass on multi-KB + input (459 ns vs 3.0 us for the 2 KB batch). Capturing that would require writing a custom native + shim that performs the whole envelope walk in one call and returns offsets (not this package), and + even then the parse-only floor (~150 ns) exceeds the full jsmn read for the single small requests the + server is tuned for. If very large batches (tens of KB) ever become the dominant traffic that could be + revisited as a custom-native-shim project, not as a NuGet dependency. diff --git a/benchmarks/SimdJsonEval/SimdJsonEval.csproj b/benchmarks/SimdJsonEval/SimdJsonEval.csproj new file mode 100644 index 0000000..b901bf5 --- /dev/null +++ b/benchmarks/SimdJsonEval/SimdJsonEval.csproj @@ -0,0 +1,26 @@ + + + + + Exe + net10.0 + disable + disable + true + false + true + true + false + true + + + + + + + + + + + diff --git a/benchmarks/charts/benchmarks.json b/benchmarks/charts/benchmarks.json new file mode 100644 index 0000000..e6acc0f --- /dev/null +++ b/benchmarks/charts/benchmarks.json @@ -0,0 +1,141 @@ +{ + "schema": 1, + "machine": "AMD Ryzen 7 7800X3D (8 cores / 16 threads, 4.2 GHz), 64 GB, Windows 11", + "runtime": ".NET 10, Release, Server GC, built-in serializer", + "sets": { + "sync": { + "title": "JSON-RPC.Net alone, by worker threads", + "date": "2026-09-23", + "source": "README.md, Sync table; commit 192a97f", + "harness": "TestServer_Console --sync 3, and --sync 2 for single rows", + "workload": "the five benchmark requests through the byte-level JsonRpcProcessor.Process in a loop; no scheduler, no transport", + "conditions": "idle box, WSL VM shut down; the 1-thread row from an idle core", + "policy": "observed low and high over the day's runs; run count not recorded; ns per request is the harness's reported figure for one run", + "x": { "name": "worker threads", "values": [1, 2, 4, 8, 16] }, + "series": [ + { + "id": "ours-sync", + "label": "JSON-RPC.Net, byte entry point", + "family": "ours", + "points": [ + { "low": 4500000, "high": 4600000, "status": "range", "published": "4.5 M to 4.6 M", "ns": 217 }, + { "low": 7600000, "high": 9500000, "status": "range", "published": "7.6 M to 9.5 M", "ns": 222 }, + { "low": 16400000, "high": 17400000, "status": "range", "published": "16.4 M to 17.4 M", "ns": 230 }, + { "low": 25100000, "high": 26800000, "status": "range", "published": "25.1 M to 26.8 M", "ns": 298 }, + { "low": 30600000, "high": 35800000, "status": "range", "published": "30.6 M to 35.8 M", "ns": 446 } + ] + } + ] + }, + "kestrel": { + "title": "JSON-RPC.Net by transport", + "date": "2026-09-23", + "source": "README.md, Kestrel table; commit 192a97f", + "harness": "TestServer_Console --kestrel 3", + "workload": "AustinHarris.JsonRpc.AspNetCore on Kestrel, loopback, 16 clients on the server's 8 cores", + "conditions": "WSL VM shut down; two 3 s runs", + "policy": "observed low and high over two runs", + "groups": [ + { "heading": "", "rule": false, "series": ["ours-http-1", "ours-http-100", "ours-tcp"] }, + { "heading": "Reference: no transport", "rule": true, "series": ["ours-inproc-16"] } + ], + "series": [ + { "id": "ours-http-1", "label": "HTTP, 1 request per POST", "note": "each client awaits one POST at a time; 95 to 125 µs per round trip", "family": "ours", "low": 128000, "high": 168000, "status": "range", "published": "128 k to 168 k" }, + { "id": "ours-http-100", "label": "HTTP, batch of 100 per POST", "note": "one POST outstanding per client, 100 RPCs in it", "family": "ours", "low": 12700000, "high": 13700000, "status": "range", "published": "12.7 M to 13.7 M" }, + { "id": "ours-tcp", "label": "TCP, 256 pipelined", "note": "JsonRpcConnectionHandler, 256 requests in flight per connection", "family": "ours", "low": 15200000, "high": 15500000, "status": "range", "published": "15.2 M to 15.5 M" }, + { "id": "ours-inproc-16", "label": "in-process, 16 threads", "note": "the same requests through the byte entry point", "family": "scale", "low": 30800000, "high": 31300000, "status": "range", "published": "30.8 M to 31.3 M" } + ] + }, + "compare": { + "title": "JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connections", + "date": "2026-09-23", + "source": "README.md, comparison table; commit 192a97f", + "harness": "TestServer_Console --compare 3", + "workload": "the same five calls on the same Kestrel; 16 connections or channels with 256 requests in flight each; every request carries \"jsonrpc\":\"2.0\"", + "conditions": "WSL VM shut down; two 3 s runs", + "policy": "observed low and high over two runs; a single figure is one value that both runs rounded to", + "versions": "StreamJsonRpc 2.25.29, gRPC for .NET 2.84.0", + "groups": [ + { "heading": "JSON-RPC.Net", "rule": false, "series": ["ours-tcp-jsonrpc"] }, + { "heading": "StreamJsonRpc 2.25", "rule": false, "series": ["sjr-newline-stj", "sjr-cl-stj", "sjr-cl-newtonsoft"] }, + { "heading": "gRPC for .NET 2.84: its .NET client shares the server's cores", "rule": false, "series": ["grpc-unary", "grpc-stream"] } + ], + "series": [ + { "id": "ours-tcp-jsonrpc", "label": "over Kestrel TCP", "note": "raw documents", "family": "ours", "low": 13700000, "high": 14600000, "status": "range", "published": "13.7 M to 14.6 M" }, + { "id": "sjr-newline-stj", "label": "over Kestrel TCP", "note": "newline framing, System.Text.Json formatter", "family": "theirs", "low": 1380000, "high": 1440000, "status": "range", "published": "1.38 M to 1.44 M" }, + { "id": "sjr-cl-stj", "label": "over Kestrel TCP", "note": "Content-Length framing, System.Text.Json formatter", "family": "theirs", "low": 1410000, "high": 1450000, "status": "range", "published": "1.41 M to 1.45 M" }, + { "id": "sjr-cl-newtonsoft", "label": "over Kestrel TCP", "note": "Content-Length framing, Json.NET formatter (default)", "family": "theirs", "low": 625000, "high": 625000, "status": "single", "published": "625 k" }, + { "id": "grpc-unary", "label": "unary calls", "note": "HTTP/2 + protobuf, 16 channels × 256 in flight", "family": "grpc", "low": 192000, "high": 198000, "status": "range", "published": "192 k to 198 k" }, + { "id": "grpc-stream", "label": "bidirectional stream", "note": "one stream per channel, 256 in flight, batched writes", "family": "grpc", "low": 200000, "high": 209000, "status": "range", "published": "200 k to 209 k" } + ] + }, + "inprocess": { + "title": "In-process paths: different execution boundaries", + "date": "2026-09-23", + "source": "README.md, comparison table; commit 192a97f", + "harness": "TestServer_Console --compare 3", + "workload": "the same five calls with no network: a direct call, a Pipe pair, a typed proxy", + "conditions": "WSL VM shut down; two 3 s runs", + "policy": "observed low and high over two runs", + "groups": [ + { "heading": "", "rule": false, "series": ["ours-direct-1", "sjr-pipe", "sjr-proxy"] } + ], + "series": [ + { "id": "ours-direct-1", "label": "JSON-RPC.Net, direct call, 1 thread", "note": "bytes in, bytes out, no transport", "family": "ours", "low": 2600000, "high": 3600000, "status": "range", "published": "2.6 M to 3.6 M" }, + { "id": "sjr-pipe", "label": "StreamJsonRpc, Pipe pair, 1 client", "note": "newline framing, System.Text.Json, 256 pipelined", "family": "theirs", "low": 117000, "high": 142000, "status": "range", "published": "117 k to 142 k" }, + { "id": "sjr-proxy", "label": "StreamJsonRpc, typed proxy", "note": "sequential await per call, 10 µs per round trip", "family": "theirs", "low": 96000, "high": 97000, "status": "range", "published": "96 k to 97 k" } + ] + }, + "sweep": { + "title": "Every library and transport, by client connections", + "source": "TestServer_Console --sweep 2 ; one file per run, matched by the pattern below", + "runs": "sweep*.json", + "workload": "the same five calls on the same Kestrel, clients on the server's 8 cores; each cell warmed for 2 s, then timed", + "conditions": "WSL VM running and other sessions active: a separate session from the tables, so its absolute figures are not comparable with them", + "policy": "observed low, high and median over the runs; each run contributes one observation per cell", + "x": { "name": "client connections (gRPC: channels)" }, + "series": [ + { "id": "sweep-ours-tcp", "name": "JSON-RPC.Net, TCP", "label": "TCP", "family": "ours", "style": 0, "concurrency": "256 requests in flight per connection" }, + { "id": "sweep-ours-http-100", "name": "JSON-RPC.Net, HTTP, batch of 100 per POST", "label": "HTTP, batch of 100 per POST", "family": "ours", "style": 1, "concurrency": "one POST outstanding per connection, 100 RPCs in it" }, + { "id": "sweep-ours-http-1", "name": "JSON-RPC.Net, HTTP, 1 request per POST", "label": "HTTP, 1 request per POST", "family": "ours", "style": 2, "concurrency": "one POST outstanding per connection" }, + { "id": "sweep-sjr-newline-stj", "name": "StreamJsonRpc, TCP, newline + System.Text.Json", "label": "TCP, newline + System.Text.Json", "family": "theirs", "style": 0, "concurrency": "256 requests in flight per connection" }, + { "id": "sweep-sjr-cl-stj", "name": "StreamJsonRpc, TCP, Content-Length + System.Text.Json", "label": "TCP, Content-Length + System.Text.Json", "family": "theirs", "style": 1, "concurrency": "256 requests in flight per connection" }, + { "id": "sweep-sjr-cl-newtonsoft", "name": "StreamJsonRpc, TCP, Content-Length + Json.NET", "label": "TCP, Content-Length + Json.NET", "family": "theirs", "style": 2, "concurrency": "256 requests in flight per connection" }, + { "id": "sweep-grpc-unary", "name": "gRPC for .NET, unary", "label": "unary", "family": "grpc", "style": 0, "concurrency": "256 calls in flight per channel" }, + { "id": "sweep-grpc-stream", "name": "gRPC for .NET, bidirectional stream", "label": "bidirectional stream", "family": "grpc", "style": 1, "concurrency": "one stream per channel, 256 in flight, batched writes" } + ] + }, + "wasm": { + "title": "In the browser: interpreter vs AOT", + "date": "2026-09-23", + "source": "samples/WasmHost/README.md table; commit 41371ec", + "harness": "samples/WasmHost, Run benchmark in Chrome 152", + "workload": "add(1, 2) through each interop path, 20,000 RPCs per row", + "conditions": "8-core desktop; interpreter from dotnet run, AOT from dotnet publish -c Release with the wasm-tools workload", + "policy": "interpreter: one run; AOT: the better of two runs. The interpreter-to-AOT distance is a deployment difference, not a range", + "modes": [ + { "id": "interp", "label": "interpreter", "marker": "circle", "policy": "one run" }, + { "id": "aot", "label": "AOT", "marker": "diamond", "policy": "better of two runs" } + ], + "groups": [ + { "heading": "plain Blazor interop, one method per operation", "rule": false, "series": ["wasm-plain-invoke", "wasm-plain-invoke-async", "wasm-plain-jsexport"] }, + { "heading": "JSON-RPC, one request document per call", "rule": false, "series": ["wasm-rpc-invoke", "wasm-rpc-invoke-async", "wasm-rpc-jsexport", "wasm-rpc-bytes"] }, + { "heading": "JSON-RPC, a batch of 100 per call", "rule": false, "series": ["wasm-rpc-invoke-100", "wasm-rpc-jsexport-100", "wasm-rpc-bytes-100"] }, + { "heading": "Reference: the server in a .NET loop, no interop", "rule": true, "series": ["wasm-loop"] } + ], + "series": [ + { "id": "wasm-plain-invoke", "label": "invokeMethod Add", "family": "scale", "values": { "interp": 15700, "aot": 66600 }, "published": { "interp": "15,700", "aot": "66,600" } }, + { "id": "wasm-plain-invoke-async", "label": "invokeMethodAsync Add", "family": "scale", "values": { "interp": 14500, "aot": 59300 }, "published": { "interp": "14,500", "aot": "59,300" } }, + { "id": "wasm-plain-jsexport", "label": "[JSExport] AddExported, typed arguments", "family": "scale", "values": { "interp": 2860000, "aot": 3230000 }, "published": { "interp": "2,860,000", "aot": "3,230,000" } }, + { "id": "wasm-rpc-invoke", "label": "invokeMethod Process", "family": "ours", "values": { "interp": 6000, "aot": 33500 }, "published": { "interp": "6,000", "aot": "33,500" } }, + { "id": "wasm-rpc-invoke-async", "label": "invokeMethodAsync Process", "family": "ours", "values": { "interp": 5700, "aot": 36000 }, "published": { "interp": "5,700", "aot": "36,000" } }, + { "id": "wasm-rpc-jsexport", "label": "[JSExport] ProcessExported, strings", "family": "ours", "values": { "interp": 17100, "aot": 151600 }, "published": { "interp": "17,100", "aot": "151,600" } }, + { "id": "wasm-rpc-bytes", "label": "[JSExport] ProcessBytes, UTF-8 buffers", "family": "ours", "values": { "interp": 19000, "aot": 142000 }, "published": { "interp": "19,000", "aot": "142,000" } }, + { "id": "wasm-rpc-invoke-100", "label": "invokeMethod Process", "family": "ours", "values": { "interp": 13000, "aot": 136000 }, "published": { "interp": "13,000", "aot": "136,000" } }, + { "id": "wasm-rpc-jsexport-100", "label": "[JSExport] ProcessExported, strings", "family": "ours", "values": { "interp": 25000, "aot": 185500 }, "published": { "interp": "25,000", "aot": "185,500" } }, + { "id": "wasm-rpc-bytes-100", "label": "[JSExport] ProcessBytes, UTF-8 buffers", "family": "ours", "values": { "interp": 27000, "aot": 210500 }, "published": { "interp": "27,000", "aot": "210,500" } }, + { "id": "wasm-loop", "label": "ProcessMany, the string request N times", "family": "scale", "values": { "interp": 17600, "aot": 169800 }, "published": { "interp": "17,600", "aot": "169,800" } } + ] + } + } +} diff --git a/benchmarks/charts/compare-connections-dark.svg b/benchmarks/charts/compare-connections-dark.svg new file mode 100644 index 0000000..98891d0 --- /dev/null +++ b/benchmarks/charts/compare-connections-dark.svg @@ -0,0 +1,290 @@ + +Every library and transport, by client connectionsSame five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight per connection; HTTP clients await one POST at a time (1 or 100 RPCs). 5 runs of 2 s per point; whiskers span the runs. Data revision a4f53e6a848f. + +Every library and transport, by client connections +Same five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight +per connection; HTTP clients await one POST at a time (1 or 100 RPCs). 5 runs of 2 s per point; whiskers span the runs. +JSON-RPC.Net + +10 k + + + +100 k + + + +1 M + + + +10 M + + + + + + + +1 +2 +4 +8 +16 + + + + + + + + + + + + + + + + + + + + + + + +TCP +at 16: 10.9 M to 13 M + + + + + + + + + + + + + + + + + + + + + + + +HTTP, batch of 100 per POST +at 16: 8.64 M to 10.2 M + + + + + + + + + + + + + + + + + + + + + + + +HTTP, 1 request per POST +at 16: 178 k to 210 k +StreamJsonRpc + +10 k + + + +100 k + + + +1 M + + + +10 M + + + + + + + +1 +2 +4 +8 +16 + + + + + + + + + + + + + + + + + + + + + + + +TCP, newline + System.Text.Json +at 16: 1.06 M to 1.24 M + + + + + + + + + + + + + + + + + + + + + + + +TCP, Content-Length + System.Text.Json +at 16: 1.12 M to 1.37 M + + + + + + + + + + + + + + + + + + + + + + + +TCP, Content-Length + Json.NET +at 16: 544 k to 677 k +gRPC for .NET + +10 k + + + +100 k + + + +1 M + + + +10 M + + + + + + + +1 +2 +4 +8 +16 + + + + + + + + + + + + + + + + + + + + + + + +unary +at 16: 360 k to 404 k + + + + + + + + + + + + + + + + + + + + + + + +bidirectional stream +at 16: 233 k to 291 k +client connections (gRPC: channels); each step doubles +requests per second, log scale, shared by the panels; marker at the median, whisker low to high + diff --git a/benchmarks/charts/compare-connections.svg b/benchmarks/charts/compare-connections.svg new file mode 100644 index 0000000..29194c5 --- /dev/null +++ b/benchmarks/charts/compare-connections.svg @@ -0,0 +1,290 @@ + +Every library and transport, by client connectionsSame five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight per connection; HTTP clients await one POST at a time (1 or 100 RPCs). 5 runs of 2 s per point; whiskers span the runs. Data revision a4f53e6a848f. + +Every library and transport, by client connections +Same five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight +per connection; HTTP clients await one POST at a time (1 or 100 RPCs). 5 runs of 2 s per point; whiskers span the runs. +JSON-RPC.Net + +10 k + + + +100 k + + + +1 M + + + +10 M + + + + + + + +1 +2 +4 +8 +16 + + + + + + + + + + + + + + + + + + + + + + + +TCP +at 16: 10.9 M to 13 M + + + + + + + + + + + + + + + + + + + + + + + +HTTP, batch of 100 per POST +at 16: 8.64 M to 10.2 M + + + + + + + + + + + + + + + + + + + + + + + +HTTP, 1 request per POST +at 16: 178 k to 210 k +StreamJsonRpc + +10 k + + + +100 k + + + +1 M + + + +10 M + + + + + + + +1 +2 +4 +8 +16 + + + + + + + + + + + + + + + + + + + + + + + +TCP, newline + System.Text.Json +at 16: 1.06 M to 1.24 M + + + + + + + + + + + + + + + + + + + + + + + +TCP, Content-Length + System.Text.Json +at 16: 1.12 M to 1.37 M + + + + + + + + + + + + + + + + + + + + + + + +TCP, Content-Length + Json.NET +at 16: 544 k to 677 k +gRPC for .NET + +10 k + + + +100 k + + + +1 M + + + +10 M + + + + + + + +1 +2 +4 +8 +16 + + + + + + + + + + + + + + + + + + + + + + + +unary +at 16: 360 k to 404 k + + + + + + + + + + + + + + + + + + + + + + + +bidirectional stream +at 16: 233 k to 291 k +client connections (gRPC: channels); each step doubles +requests per second, log scale, shared by the panels; marker at the median, whisker low to high + diff --git a/benchmarks/charts/compare-streamjsonrpc-dark.svg b/benchmarks/charts/compare-streamjsonrpc-dark.svg new file mode 100644 index 0000000..d427a26 --- /dev/null +++ b/benchmarks/charts/compare-streamjsonrpc-dark.svg @@ -0,0 +1,62 @@ + +JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connectionsSame five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. Every request carries "jsonrpc":"2.0" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value. Data revision a4f53e6a848f. + +JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connections +Same five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. +Every request carries "jsonrpc":"2.0" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value. + +100 k + +200 k + +500 k + +1 M + +2 M + +5 M + +10 M + +20 M +requests per second, log scale +JSON-RPC.Net +over Kestrel TCP +raw documents + + + +13.7 M to 14.6 M +StreamJsonRpc 2.25 +over Kestrel TCP +newline framing, System.Text.Json formatter + + + +1.38 M to 1.44 M +over Kestrel TCP +Content-Length framing, System.Text.Json formatter + + + +1.41 M to 1.45 M +over Kestrel TCP +Content-Length framing, Json.NET formatter (default) + + +625 k +gRPC for .NET 2.84: its .NET client shares the server's cores +unary calls +HTTP/2 + protobuf, 16 channels × 256 in flight + + + +192 k to 198 k +bidirectional stream +one stream per channel, 256 in flight, batched writes + + + +200 k to 209 k + diff --git a/benchmarks/charts/compare-streamjsonrpc.svg b/benchmarks/charts/compare-streamjsonrpc.svg new file mode 100644 index 0000000..f283484 --- /dev/null +++ b/benchmarks/charts/compare-streamjsonrpc.svg @@ -0,0 +1,62 @@ + +JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connectionsSame five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. Every request carries "jsonrpc":"2.0" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value. Data revision a4f53e6a848f. + +JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connections +Same five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. +Every request carries "jsonrpc":"2.0" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value. + +100 k + +200 k + +500 k + +1 M + +2 M + +5 M + +10 M + +20 M +requests per second, log scale +JSON-RPC.Net +over Kestrel TCP +raw documents + + + +13.7 M to 14.6 M +StreamJsonRpc 2.25 +over Kestrel TCP +newline framing, System.Text.Json formatter + + + +1.38 M to 1.44 M +over Kestrel TCP +Content-Length framing, System.Text.Json formatter + + + +1.41 M to 1.45 M +over Kestrel TCP +Content-Length framing, Json.NET formatter (default) + + +625 k +gRPC for .NET 2.84: its .NET client shares the server's cores +unary calls +HTTP/2 + protobuf, 16 channels × 256 in flight + + + +192 k to 198 k +bidirectional stream +one stream per channel, 256 in flight, batched writes + + + +200 k to 209 k + diff --git a/benchmarks/charts/explorer.html b/benchmarks/charts/explorer.html new file mode 100644 index 0000000..f1323dc --- /dev/null +++ b/benchmarks/charts/explorer.html @@ -0,0 +1,358 @@ + + + + + +JSON-RPC.NET benchmark explorer + + + + + +
+

JSON-RPC.NET benchmark explorer

+

The data behind the README charts, data revision a4f53e6a848f. Every range is the observed low and high over the stated runs, never a confidence interval. Hover, tap or tab to a marker for the exact values. The README stays complete without this page. Back to the README.

+ +

+

+
+ + + + + + +
+
+
+ +
+
+ +

All series at one connection count

+
+ +
+
Every series at the chosen connection count
+ +
+ Full sweep table: low, median, high and every observation per cell +
+
+ +

Snapshot sets from the README

+

These are the tables the README publishes, with their measurement conditions. They are separate sessions from the sweep, so their absolute figures are not comparable with it.

+
+
+ + + + + diff --git a/benchmarks/charts/explorer_template.html b/benchmarks/charts/explorer_template.html new file mode 100644 index 0000000..48b8236 --- /dev/null +++ b/benchmarks/charts/explorer_template.html @@ -0,0 +1,358 @@ + + + + + +JSON-RPC.NET benchmark explorer + + + + + +
+

JSON-RPC.NET benchmark explorer

+

The data behind the README charts, data revision __REVISION__. Every range is the observed low and high over the stated runs, never a confidence interval. Hover, tap or tab to a marker for the exact values. The README stays complete without this page. Back to the README.

+ +

+

+
+ + + + + + +
+
+
+ +
+
+ +

All series at one connection count

+
+ +
+
Every series at the chosen connection count
+ +
+ Full sweep table: low, median, high and every observation per cell +
+
+ +

Snapshot sets from the README

+

These are the tables the README publishes, with their measurement conditions. They are separate sessions from the sweep, so their absolute figures are not comparable with it.

+
+
+ + + + + diff --git a/benchmarks/charts/inprocess-paths-dark.svg b/benchmarks/charts/inprocess-paths-dark.svg new file mode 100644 index 0000000..7511093 --- /dev/null +++ b/benchmarks/charts/inprocess-paths-dark.svg @@ -0,0 +1,40 @@ + +In-process paths: different execution boundariesNo network. A direct call, a Pipe pair and a typed proxy cross different boundaries, so these rows are not a like-for-like comparison with each other or with the transport rows. Two 3 s runs; intervals span them. Data revision a4f53e6a848f. + +In-process paths: different execution boundaries +No network. A direct call, a Pipe pair and a typed proxy cross different boundaries, so these rows are not a +like-for-like comparison with each other or with the transport rows. Two 3 s runs; intervals span them. + +50 k + +100 k + +200 k + +500 k + +1 M + +2 M + +5 M +requests per second, log scale +JSON-RPC.Net, direct call, 1 thread +bytes in, bytes out, no transport + + + +2.6 M to 3.6 M +StreamJsonRpc, Pipe pair, 1 client +newline framing, System.Text.Json, 256 pipelined + + + +117 k to 142 k +StreamJsonRpc, typed proxy +sequential await per call, 10 µs per round trip + + + +96 k to 97 k + diff --git a/benchmarks/charts/inprocess-paths.svg b/benchmarks/charts/inprocess-paths.svg new file mode 100644 index 0000000..d05b92a --- /dev/null +++ b/benchmarks/charts/inprocess-paths.svg @@ -0,0 +1,40 @@ + +In-process paths: different execution boundariesNo network. A direct call, a Pipe pair and a typed proxy cross different boundaries, so these rows are not a like-for-like comparison with each other or with the transport rows. Two 3 s runs; intervals span them. Data revision a4f53e6a848f. + +In-process paths: different execution boundaries +No network. A direct call, a Pipe pair and a typed proxy cross different boundaries, so these rows are not a +like-for-like comparison with each other or with the transport rows. Two 3 s runs; intervals span them. + +50 k + +100 k + +200 k + +500 k + +1 M + +2 M + +5 M +requests per second, log scale +JSON-RPC.Net, direct call, 1 thread +bytes in, bytes out, no transport + + + +2.6 M to 3.6 M +StreamJsonRpc, Pipe pair, 1 client +newline framing, System.Text.Json, 256 pipelined + + + +117 k to 142 k +StreamJsonRpc, typed proxy +sequential await per call, 10 µs per round trip + + + +96 k to 97 k + diff --git a/benchmarks/charts/kestrel-transports-dark.svg b/benchmarks/charts/kestrel-transports-dark.svg new file mode 100644 index 0000000..5a8ba7a --- /dev/null +++ b/benchmarks/charts/kestrel-transports-dark.svg @@ -0,0 +1,52 @@ + +JSON-RPC.Net by transportAustinHarris.JsonRpc.AspNetCore on Kestrel, loopback, 16 clients on the server's 8 cores, two 3 s runs. HTTP clients await one POST at a time; TCP keeps 256 requests in flight per connection. Data revision a4f53e6a848f. + +JSON-RPC.Net by transport +AustinHarris.JsonRpc.AspNetCore on Kestrel, loopback, 16 clients on the server's 8 cores, two 3 s runs. +HTTP clients await one POST at a time; TCP keeps 256 requests in flight per connection. + +100 k + +200 k + +500 k + +1 M + +2 M + +5 M + +10 M + +20 M + +50 M +requests per second, log scale +HTTP, 1 request per POST +each client awaits one POST at a time; 95 to 125 µs per round trip + + + +128 k to 168 k +HTTP, batch of 100 per POST +one POST outstanding per client, 100 RPCs in it + + + +12.7 M to 13.7 M +TCP, 256 pipelined +JsonRpcConnectionHandler, 256 requests in flight per connection + + + +15.2 M to 15.5 M + +Reference: no transport +in-process, 16 threads +the same requests through the byte entry point + + + +30.8 M to 31.3 M + diff --git a/benchmarks/charts/kestrel-transports.svg b/benchmarks/charts/kestrel-transports.svg new file mode 100644 index 0000000..e07d558 --- /dev/null +++ b/benchmarks/charts/kestrel-transports.svg @@ -0,0 +1,52 @@ + +JSON-RPC.Net by transportAustinHarris.JsonRpc.AspNetCore on Kestrel, loopback, 16 clients on the server's 8 cores, two 3 s runs. HTTP clients await one POST at a time; TCP keeps 256 requests in flight per connection. Data revision a4f53e6a848f. + +JSON-RPC.Net by transport +AustinHarris.JsonRpc.AspNetCore on Kestrel, loopback, 16 clients on the server's 8 cores, two 3 s runs. +HTTP clients await one POST at a time; TCP keeps 256 requests in flight per connection. + +100 k + +200 k + +500 k + +1 M + +2 M + +5 M + +10 M + +20 M + +50 M +requests per second, log scale +HTTP, 1 request per POST +each client awaits one POST at a time; 95 to 125 µs per round trip + + + +128 k to 168 k +HTTP, batch of 100 per POST +one POST outstanding per client, 100 RPCs in it + + + +12.7 M to 13.7 M +TCP, 256 pipelined +JsonRpcConnectionHandler, 256 requests in flight per connection + + + +15.2 M to 15.5 M + +Reference: no transport +in-process, 16 threads +the same requests through the byte entry point + + + +30.8 M to 31.3 M + diff --git a/benchmarks/charts/render.py b/benchmarks/charts/render.py new file mode 100644 index 0000000..d23063a --- /dev/null +++ b/benchmarks/charts/render.py @@ -0,0 +1,618 @@ +"""Renders the benchmark charts (SVG, light and dark) and the explorer page from benchmarks.json. + + python benchmarks/charts/render.py # write every output next to this file + python benchmarks/charts/render.py --check # render in memory and fail if a committed output differs + +benchmarks.json is the canonical data: the README tables transcribed with their published precision, plus an +index of the connection-sweep runs (sweep*.json, one file per `TestServer_Console --sweep 2 `), which are +summarised to low, high and median per cell here. The README tables are checked against the published strings in +`--check`, so a number cannot change in one place only. Every range drawn is the observed low and high over the +stated runs, as a capped interval; a lone value is a marker. GitHub serves README images as plain , so the +SVGs carry everything a reader needs and the explorer page (inline SVG, vanilla JavaScript, the same summaries +embedded) supplies the toggles and exact values. Plain Python, standard library only. +Design notes: docs/reviews/2026-09-23_codex-astra-charts.md. +""" +import argparse +import glob +import hashlib +import json +import math +import os +import statistics +import sys +import xml.dom.minidom + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +FONT = "-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" + +THEMES = { + "light": dict(suffix="", bg="#ffffff", edge="#d1d5db", ink="#1f2328", muted="#57606a", grid="#e5e7eb"), + "dark": dict(suffix="-dark", bg="#0d1117", edge="#30363d", ink="#f0f6fc", muted="#9da7b3", grid="#21262d"), +} +# Okabe-Ito family colours: distinguishable under the common colour-vision deficiencies, 3:1 or better on both themes. +FAMILY = {"ours": "#009e73", "theirs": "#0072b2", "grpc": "#d55e00", "scale": "#767676"} +FAMILY_NAME = {"ours": "JSON-RPC.Net", "theirs": "StreamJsonRpc", "grpc": "gRPC for .NET", "scale": "reference"} +# Setting styles within a family, by the series' declared style index: dash and marker, so identity never rests on colour. +STYLES = [("", "circle"), ("9 5", "square"), ("2 4", "triangle"), ("12 4 2 4", "diamond")] + + +class DataError(ValueError): + pass + + +# ---------------------------------------------------------------- data + +def load(path=None): + """Reads benchmarks.json, folds the sweep runs in, validates, and returns the model with a revision id.""" + path = path or os.path.join(HERE, "benchmarks.json") + with open(path, encoding="utf-8") as f: + data = json.load(f) + # The revision hashes the parsed data, not the bytes, so line endings (CRLF checkouts) and whitespace do not change it. + digest = hashlib.sha256(canonical(data)) + sweep = data["sets"].get("sweep") + if sweep: + files = sorted(glob.glob(os.path.join(os.path.dirname(path), sweep["runs"])), key=lambda n: (len(n), n)) # sweep, sweep-2, ..., sweep-10 + if not files: + raise DataError(f"no sweep runs match {sweep['runs']}; run TestServer_Console --sweep 2 benchmarks/charts/sweep.json") + runs = [] + for name in files: + with open(name, encoding="utf-8") as f: + runs.append(json.load(f)) + digest.update(canonical(runs[-1])) + fold_sweep(sweep, runs, [os.path.basename(n) for n in files]) + data["revision"] = digest.hexdigest()[:12] + validate(data) + return data + + +def canonical(obj): + return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def fold_sweep(sweep, runs, names): + """Attaches per-cell summaries (low, high, median, n, values) to each sweep series from the raw run files.""" + first = runs[0] + xs = first["connections"] + for r in runs: + if r["connections"] != xs: + raise DataError("sweep runs disagree on the connection counts") + by_name = {s["name"]: [] for s in sweep["series"]} + for r in runs: + seen = set() + for s in r["series"]: + if s["name"] not in by_name: + raise DataError(f"sweep run has an unknown series {s['name']!r}; add it to benchmarks.json") + if len(s["rpcPerSec"]) != len(xs): + raise DataError(f"sweep series {s['name']!r} has {len(s['rpcPerSec'])} values for {len(xs)} connection counts") + by_name[s["name"]].append(s["rpcPerSec"]) + seen.add(s["name"]) + missing = set(by_name) - seen + if missing: + raise DataError(f"sweep run lacks {sorted(missing)}") + sweep["x"]["values"] = xs + sweep["run_files"] = names + sweep["seconds_per_cell"] = first["secondsPerCell"] + sweep["pipeline"] = first["pipeline"] + sweep["date"] = ", ".join(sorted({r["date"] for r in runs})) + sweep["machine"] = first["machine"] + for s in sweep["series"]: + cols = list(zip(*by_name[s["name"]])) + s["points"] = [dict(low=min(c), high=max(c), median=statistics.median(c), n=len(c), values=list(c), + status="range" if len(c) > 1 else "single") for c in cols] + + +def validate(data): + ids = set() + for key, st in data["sets"].items(): + for s in st["series"]: + if s["id"] in ids: + raise DataError(f"duplicate series id {s['id']}") + ids.add(s["id"]) + if s["family"] not in FAMILY: + raise DataError(f"{s['id']}: unknown family {s['family']!r}") + if "points" in s: + points = s["points"] + if "x" in st and len(points) != len(st["x"]["values"]): + raise DataError(f"{s['id']}: {len(points)} points for {len(st['x']['values'])} x values") + elif "values" in s: + points = [dict(low=v, high=v) for v in s["values"].values()] + else: + points = [s] + for p in points: + lo, hi = p["low"], p["high"] + if not (isinstance(lo, (int, float)) and isinstance(hi, (int, float)) and math.isfinite(lo) and math.isfinite(hi)): + raise DataError(f"{s['id']}: non-finite value") + if lo <= 0 or hi <= 0: + raise DataError(f"{s['id']}: values must be positive (log axes)") + if lo > hi: + raise DataError(f"{s['id']}: low {lo} above high {hi}") + for g in st.get("groups", []): + for sid in g["series"]: + if sid not in ids: + raise DataError(f"group {g['heading']!r} names unknown series {sid}") + return data + + +# ---------------------------------------------------------------- text and geometry helpers + +def esc(s): + return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + +def fmt_axis(v): + if v >= 1e6: + return f"{v / 1e6:g} M" + if v >= 1e3: + return f"{v / 1e3:g} k" + return f"{v:g}" + + +def fmt(v): + """Three significant figures with a unit, for a value that has no published text.""" + if v >= 1e6: + return f"{float(f'{v / 1e6:.3g}'):g} M" + if v >= 1e3: + return f"{float(f'{v / 1e3:.3g}'):g} k" + return f"{float(f'{v:.3g}'):g}" + + +def fmt_range(p): + """The published text when the data carries one; otherwise the endpoints at three significant figures.""" + if p.get("published"): + return p["published"] + if p["low"] == p["high"]: + return fmt(p["low"]) + return f"{fmt(p['low'])} to {fmt(p['high'])}" + + +def text(x, y, s, t, size=13, fill=None, anchor="start", weight=None, extra=""): + w = f' font-weight="{weight}"' if weight else "" + return f'{esc(s)}' + + +def marker(x, y, shape, color, t, r=4.5): + if shape == "circle": + return f'' + if shape == "square": + return f'' + if shape == "diamond": + d = r * 1.3 + return (f'') + s = r * 1.25 + return (f'') + + +def capped(out, x0, y0, x1, y1, color, cap, vertical): + """A range interval from (x0, y0) to (x1, y1) with opaque end caps.""" + out.append(f'') + for x, y in ((x0, y0), (x1, y1)): + if vertical: + out.append(f'') + else: + out.append(f'') + + +def card(width, height, title, subtitle, t, revision): + lines = subtitle if isinstance(subtitle, (list, tuple)) else [subtitle] + out = [f'', + f'{esc(title)}{esc(" ".join(lines))} Data revision {revision}.', + f'', + text(24, 32, title, t, size=18, weight=600)] + y = 54 + for line in lines: + out.append(text(24, y, line, t, size=12, fill=t["muted"])) + y += 16 + return out + + +class Linear: + def __init__(self, lo, hi, p0, p1): + if hi <= lo: + raise DataError("empty linear domain") + self.lo, self.hi, self.p0, self.p1 = lo, hi, p0, p1 + + def __call__(self, v): + return self.p0 + (self.p1 - self.p0) * (v - self.lo) / (self.hi - self.lo) + + def ticks(self, max_ticks=6): + span = self.hi - self.lo + step = 10 ** math.floor(math.log10(span)) + if span / step < 3: + step /= 2 + while span / step > max_ticks: + step *= 2 + out, v = [], math.ceil(self.lo / step) * step + while v <= self.hi + 1e-9: + out.append((v, True)) + v += step + return out + + +class Log: + """Domain from the last 1-2-5 step at or below the smallest value to the first one with 15 % headroom above the largest.""" + + def __init__(self, lo, hi, p0, p1): + if lo <= 0 or hi < lo: + raise DataError("log domain needs positive values") + d = 10 ** math.floor(math.log10(lo)) + self.lo = max(d * m for m in (1, 2, 5) if d * m <= lo) + d = 10 ** math.floor(math.log10(hi * 1.15)) + self.hi = next(d * m for m in (1, 2, 5, 10) if d * m >= hi * 1.15) + if self.hi <= self.lo: + self.hi = self.lo * 10 + self.p0, self.p1 = p0, p1 + + def __call__(self, v): + return self.p0 + (self.p1 - self.p0) * (math.log10(v) - math.log10(self.lo)) / (math.log10(self.hi) - math.log10(self.lo)) + + def ticks(self): + out, d = [], 10 ** math.floor(math.log10(self.lo)) + while d <= self.hi: + for m in (1, 2, 5): + if self.lo <= d * m <= self.hi: + out.append((d * m, m == 1)) + d *= 10 + return out + + +def log2_positions(values, x0, x1): + """x positions for doubling counts: log2 spacing from the numeric counts, centred when there is one value.""" + if len(values) == 1: + return [(x0 + x1) / 2] + a, b = math.log2(values[0]), math.log2(values[-1]) + return [x0 + (x1 - x0) * (math.log2(v) - a) / (b - a) for v in values] + + +def nice_max(v): + step = 10 ** math.floor(math.log10(v)) + if v / step < 2: + step /= 4 + elif v / step < 5: + step /= 2 + return math.ceil(v / step) * step + + +def style(series): + return STYLES[series.get("style", 0) % len(STYLES)] + + +def groups_of(st): + by_id = {s["id"]: s for s in st["series"]} + return [(g["heading"], [by_id[i] for i in g["series"]], g["rule"]) for g in st["groups"]] + + +# ---------------------------------------------------------------- charts: each returns SVG text for one theme + +def interval_chart(st, subtitle, t, revision, name, width=940): + """Horizontal capped intervals on a log axis, grouped; a group with rule=True sits under a separator.""" + groups = groups_of(st) + left, num_w, row_h, group_h = 330, 160, 40, 30 + top = 70 + 16 * len(subtitle) + x0, x1 = left, width - num_w - 30 + n_rows = sum(len(rows) for _, rows, _ in groups) + height = top + row_h * n_rows + sum(group_h if h else 6 for h, _, _ in groups) + 12 * sum(r for _, _, r in groups) + 52 + xs = Log(min(s["low"] for s in st["series"]), max(s["high"] for s in st["series"]), x0, x1) + out = card(width, height, st["title"], subtitle, t, revision) + y_axis = height - 42 + for v, major in xs.ticks(): + x = xs(v) + out.append(f'') + out.append(text(x, y_axis + 16, fmt_axis(v), t, size=11 if major else 10, fill=t["muted"], anchor="middle")) + out.append(text(x1, height - 10, "requests per second, log scale", t, size=11, fill=t["muted"], anchor="end")) + y = top + for heading, rows, rule in groups: + if rule: + y += 6 + out.append(f'') + y += 6 + if heading: + out.append(text(24, y + 14, heading, t, size=12, weight=600)) + y += group_h + else: + y += 6 + for s in rows: + color = FAMILY[s["family"]] + note = s.get("note") + cy = y + row_h / 2 - (6 if note else 0) + out.append(text(left - 14, cy + 4, s["label"], t, anchor="end")) + if note: + out.append(text(left - 14, cy + 18, note, t, size=10, fill=t["muted"], anchor="end")) + if s["low"] == s["high"]: + out.append(marker(xs(s["low"]), cy, "circle", color, t)) + out.append(f'') + else: + capped(out, xs(s["low"]), cy, xs(s["high"]), cy, color, 6, vertical=False) + out.append(text(x1 + 18, cy + 4, fmt_range(s), t, weight=600)) + y += row_h + out.append("") + return "\n".join(out) + "\n" + + +def scaling_chart(st, subtitle, t, revision, name, width=940): + """Two aligned panels: aggregate RPC/s as an envelope (opaque low and high lines, capped intervals, no centre + line) and the reported ns per request per worker as single points. x is log2-spaced: each step doubles.""" + s = st["series"][0] + threads, pts = st["x"]["values"], s["points"] + left, right = 86, 40 + top = 70 + 16 * len(subtitle) + p1_y0, p1_y1 = top + 250, top + 16 + p2_y0, p2_y1 = p1_y0 + 180, p1_y0 + 56 + height = p2_y0 + 60 + x0, x1 = left, width - right - 40 + xpos = log2_positions(threads, x0, x1) + ys = Linear(0, nice_max(max(p["high"] for p in pts)), p1_y0, p1_y1) + ns = Linear(0, nice_max(max(p["ns"] for p in pts) * 1.25), p2_y0, p2_y1) + color = FAMILY[s["family"]] + out = card(width, height, st["title"], subtitle, t, revision) + out.append(text(x0, p1_y1 - 4, "Aggregate requests per second: low to high over the day's runs", t, size=11, fill=t["muted"])) + for v, _ in ys.ticks(): + y = ys(v) + out.append(f'') + out.append(text(x0 - 8, y + 4, fmt_axis(v), t, size=11, fill=t["muted"], anchor="end")) + for x in xpos: + out.append(f'') + clip = f"clip-{name}-{t['suffix'].strip('-') or 'light'}" + out.append(f'') + base = pts[0]["low"] + ref = " ".join(f"{x:.1f},{ys(base * n):.1f}" for x, n in zip(xpos, threads)) + out.append(f'') + lx, ly = max(((x, ys(base * n)) for x, n in zip(xpos, threads) if base * n <= ys.hi), key=lambda p: p[0]) + out.append(text(lx - 10, ly - 10, "linear scaling from the 1-thread low", t, size=11, fill=t["muted"], anchor="end")) + upper = " ".join(f"{x:.1f},{ys(p['high']):.1f}" for x, p in zip(xpos, pts)) + lower_pts = [f"{x:.1f},{ys(p['low']):.1f}" for x, p in zip(xpos, pts)] + lower_rev = " ".join(reversed(lower_pts)) + out.append(f'') + for poly in (upper, " ".join(lower_pts)): + out.append(f'') + last = len(xpos) - 1 + for i, (x, p) in enumerate(zip(xpos, pts)): + capped(out, x, ys(p["low"]), x, ys(p["high"]), color, 7, vertical=True) + anchor = "start" if i == 0 else ("end" if i == last else "middle") + dx = 12 if i == 0 else (-12 if i == last else 0) + out.append(text(x + dx, ys(p["high"]) - 12, fmt_range(p), t, size=11, weight=600, anchor=anchor)) + out.append(text(x0, p2_y1 - 6, "Reported ns per request per worker thread: one value per row, no range", t, size=11, fill=t["muted"])) + for v, _ in ns.ticks(max_ticks=4): + y = ns(v) + out.append(f'') + out.append(text(x0 - 8, y + 4, f"{v:.0f}", t, size=11, fill=t["muted"], anchor="end")) + ns_pts = " ".join(f"{x:.1f},{ns(p['ns']):.1f}" for x, p in zip(xpos, pts)) + out.append(f'') + for i, (x, p) in enumerate(zip(xpos, pts)): + out.append(marker(x, ns(p["ns"]), "circle", color, t)) + anchor = "start" if i == 0 else ("end" if i == last else "middle") + dx = 10 if i == 0 else (-10 if i == last else 0) + out.append(text(x + dx, ns(p["ns"]) - 11, f"{p['ns']} ns", t, size=11, weight=600, anchor=anchor)) + out.append(f'') + for x, n in zip(xpos, threads): + out.append(text(x, p2_y0 + 18, str(n), t, size=12, anchor="middle")) + out.append(text((x0 + x1) / 2, height - 12, "worker threads (each step doubles; 8 physical cores, 16 with SMT)", t, size=12, fill=t["muted"], anchor="middle")) + out.append(text(0, 0, "requests per second", t, size=12, fill=t["muted"], anchor="middle", extra=f' transform="translate(20 {(p1_y0 + p1_y1) / 2:.1f}) rotate(-90)"')) + out.append(text(0, 0, "ns per request", t, size=12, fill=t["muted"], anchor="middle", extra=f' transform="translate(20 {(p2_y0 + p2_y1) / 2:.1f}) rotate(-90)"')) + out.append("") + return "\n".join(out) + "\n" + + +def sweep_chart(st, subtitle, t, revision, name, width=940): + """One panel per library on a shared log y axis; markers at the median, capped whiskers low to high.""" + conns = st["x"]["values"] + families = [] + for s in st["series"]: + if not families or families[-1][0] != s["family"]: + families.append((s["family"], [])) + families[-1][1].append(s) + left, legend_w, panel_h, gap = 86, 300, 175, 26 + top = 70 + 16 * len(subtitle) + x0, x1 = left, width - legend_w - 30 + height = top + len(families) * (panel_h + gap) + 30 + xpos = log2_positions(conns, x0, x1) + lo_all = min(p["low"] for s in st["series"] for p in s["points"]) + hi_all = max(p["high"] for s in st["series"] for p in s["points"]) + out = card(width, height, st["title"], subtitle, t, revision) + py = top + for family, series in families: + color = FAMILY[family] + y0, y1 = py + panel_h, py + 14 + ys = Log(lo_all, hi_all, y0, y1) + out.append(text(x0, y1 - 2, FAMILY_NAME[family], t, size=12, weight=600)) + for v, major in ys.ticks(): + y = ys(v) + out.append(f'') + if major: + out.append(text(x0 - 8, y + 4, fmt_axis(v), t, size=11, fill=t["muted"], anchor="end")) + for x in xpos: + out.append(f'') + out.append(f'') + for x, c in zip(xpos, conns): + out.append(text(x, y0 + 16, str(c), t, size=11, anchor="middle")) + ly = y1 + 6 + for s in series: + dash, shape = style(s) + d = f' stroke-dasharray="{dash}"' if dash else "" + pts = s["points"] + mid = " ".join(f"{x:.1f},{ys(p['median']):.1f}" for x, p in zip(xpos, pts)) + out.append(f'') + for x, p in zip(xpos, pts): + if p["low"] != p["high"]: + capped(out, x, ys(p["low"]), x, ys(p["high"]), color, 5, vertical=True) + for x, p in zip(xpos, pts): + out.append(marker(x, ys(p["median"]), shape, color, t)) + lx = x1 + 26 + out.append(f'') + out.append(marker(lx + 15, ly + 5, shape, color, t, r=4)) + out.append(text(lx + 38, ly + 9, s["label"], t, size=11.5)) + out.append(text(lx + 38, ly + 22, f"at {conns[-1]}: {fmt_range(pts[-1])}", t, size=10.5, fill=t["muted"])) + ly += 32 + py += panel_h + gap + out.append(text((x0 + x1) / 2, height - 10, f"{st['x']['name']}; each step doubles", t, size=12, fill=t["muted"], anchor="middle")) + out.append(text(0, 0, "requests per second, log scale, shared by the panels; marker at the median, whisker low to high", t, size=12, fill=t["muted"], anchor="middle", + extra=f' transform="translate(20 {(top + height - 30) / 2:.1f}) rotate(-90)"')) + out.append("") + return "\n".join(out) + "\n" + + +def paired_chart(st, subtitle, t, revision, name, width=940): + """Two modes per row (interpreter and AOT) as distinct markers joined by a thin line, on a log axis.""" + groups = groups_of(st) + modes = st["modes"] + left, num_w, row_h, group_h = 300, 190, 30, 30 + top = 70 + 16 * len(subtitle) + x0, x1 = left, width - num_w - 30 + n_rows = sum(len(rows) for _, rows, _ in groups) + height = top + row_h * n_rows + sum(group_h if h else 6 for h, _, _ in groups) + 12 * sum(r for _, _, r in groups) + 80 + vals = [v for s in st["series"] for v in s["values"].values()] + xs = Log(min(vals), max(vals), x0, x1) + out = card(width, height, st["title"], subtitle, t, revision) + y_axis = height - 66 + for v, major in xs.ticks(): + x = xs(v) + out.append(f'') + if major: + out.append(text(x, y_axis + 16, fmt_axis(v), t, size=11, fill=t["muted"], anchor="middle")) + out.append(text(x1, y_axis + 34, "add(1, 2) operations per second, log scale", t, size=11, fill=t["muted"], anchor="end")) + lx = 24 + for m in modes: + out.append(marker(lx + 6, y_axis + 30, m["marker"], FAMILY["ours"], t, r=4)) + out.append(text(lx + 18, y_axis + 34, f"{m['label']} ({m['policy']})", t, size=11, fill=t["muted"])) + lx += 200 + for i, m in enumerate(modes): + out.append(text(x1 + 18 + 90 * i, top - 8, m["label"], t, size=11, fill=t["muted"], weight=600)) + y = top + for heading, rows, rule in groups: + if rule: + y += 6 + out.append(f'') + y += 6 + if heading: + out.append(text(24, y + 14, heading, t, size=12, weight=600)) + y += group_h + else: + y += 6 + for s in rows: + color = FAMILY[s["family"]] + cy = y + row_h / 2 + out.append(text(left - 14, cy + 4, s["label"], t, size=12, anchor="end")) + xa, xb = xs(s["values"][modes[0]["id"]]), xs(s["values"][modes[1]["id"]]) + out.append(f'') + for i, m in enumerate(modes): + out.append(marker(xs(s["values"][m["id"]]), cy, m["marker"], color, t)) + out.append(text(x1 + 18 + 90 * i, cy + 4, s["published"][m["id"]], t, size=12, weight=600)) + y += row_h + out.append("") + return "\n".join(out) + "\n" + + +# ---------------------------------------------------------------- outputs + +def chart_specs(data): + """(file stem, chart function, measurement set, subtitle lines) for every static chart.""" + sets = data["sets"] + sw = sets["sweep"] + n = len(sw["run_files"]) + runs_text = f"{n} runs of {sw['seconds_per_cell']:g} s per point; whiskers span the runs" if n > 1 else "one run per point; no range yet" + return [ + ("sync-threads", scaling_chart, sets["sync"], + ["Byte entry point in a loop, no scheduler, no transport. Ryzen 7 7800X3D, .NET 10, Server GC, two sweeps on an idle box."]), + ("kestrel-transports", interval_chart, sets["kestrel"], + ["AustinHarris.JsonRpc.AspNetCore on Kestrel, loopback, 16 clients on the server's 8 cores, two 3 s runs.", + "HTTP clients await one POST at a time; TCP keeps 256 requests in flight per connection."]), + ("compare-streamjsonrpc", interval_chart, sets["compare"], + ["Same five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs.", + "Every request carries \"jsonrpc\":\"2.0\" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value."]), + ("inprocess-paths", interval_chart, sets["inprocess"], + ["No network. A direct call, a Pipe pair and a typed proxy cross different boundaries, so these rows are not a", + "like-for-like comparison with each other or with the transport rows. Two 3 s runs; intervals span them."]), + ("compare-connections", sweep_chart, sw, + [f"Same five calls, same Kestrel, clients on the server's 8 cores, {sw['date']}. TCP and gRPC keep {sw['pipeline']} requests in flight", + f"per connection; HTTP clients await one POST at a time (1 or 100 RPCs). {runs_text}."]), + ("wasm-interop", paired_chart, sets["wasm"], + ["samples/WasmHost in Chrome 152, .NET 10, 20,000 RPCs per row. Interpreter: one run. AOT: the better of two runs.", + "Batches are per RPC. The interpreter-to-AOT distance is a deployment difference, not a range."]), + ] + + +def render_all(data): + """Every output as {filename: text}: two SVGs per chart and the explorer page.""" + outputs = {} + for stem, fn, st, subtitle in chart_specs(data): + for t in THEMES.values(): + outputs[f"{stem}{t['suffix']}.svg"] = fn(st, subtitle, t, data["revision"], stem) + outputs["explorer.html"] = explorer(data) + return outputs + + +def explorer(data): + """The interactive page: the template with the summarised data embedded, so the saved file needs no server.""" + with open(os.path.join(HERE, "explorer_template.html"), encoding="utf-8") as f: + template = f.read() + payload = json.dumps(dict(revision=data["revision"], machine=data["machine"], runtime=data["runtime"], + families=FAMILY, familyNames=FAMILY_NAME, styles=STYLES, sets=data["sets"]), + separators=(",", ":"), sort_keys=True).replace("<", "\\u003c") + return template.replace("__DATA__", payload).replace("__REVISION__", data["revision"]) + + +def check_readme(data): + """Every published string in the data must appear in the README it was transcribed from.""" + problems = [] + for key, st in data["sets"].items(): + readme = os.path.join(ROOT, "samples", "WasmHost", "README.md") if key == "wasm" else os.path.join(ROOT, "README.md") + with open(readme, encoding="utf-8") as f: + body = f.read() + for s in st["series"]: + pubs = [p["published"] for p in s.get("points", []) if "published" in p] + if isinstance(s.get("published"), str): + pubs.append(s["published"]) + elif isinstance(s.get("published"), dict): + pubs += list(s["published"].values()) + for pub in pubs: + if pub not in body: + problems.append(f"{key}/{s['id']}: {pub!r} is not in {os.path.relpath(readme, ROOT)}") + return problems + + +def check_outputs(outputs): + problems = [] + for name, content in outputs.items(): + path = os.path.join(HERE, name) + if not os.path.exists(path): + problems.append(f"{name}: missing") + continue + with open(path, encoding="utf-8", newline="") as f: + if f.read().replace("\r\n", "\n") != content: + problems.append(f"{name}: differs from the rendered output") + return problems + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--check", action="store_true", help="render in memory and fail if any committed output or README figure disagrees") + args = ap.parse_args(argv) + try: + data = load() + outputs = render_all(data) + for name, content in outputs.items(): + if name.endswith(".svg"): + xml.dom.minidom.parseString(content) + except (DataError, KeyError, ValueError, OSError) as e: + print(f"error: {e}", file=sys.stderr) + return 2 + problems = check_readme(data) + if args.check: + problems += check_outputs(outputs) + else: + for name, content in outputs.items(): + with open(os.path.join(HERE, name), "w", encoding="utf-8", newline="\n") as f: + f.write(content) + print("wrote", os.path.relpath(os.path.join(HERE, name), ROOT)) + for p in problems: + print("check:", p, file=sys.stderr) + if not problems: + print(f"data revision {data['revision']}: all outputs and README figures agree" if args.check else f"data revision {data['revision']}") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/charts/sweep-2.json b/benchmarks/charts/sweep-2.json new file mode 100644 index 0000000..2880e06 --- /dev/null +++ b/benchmarks/charts/sweep-2.json @@ -0,0 +1,103 @@ +{ + "machine": "AMD Ryzen 7 7800X3D, 8 cores / 16 threads, Windows 11, .NET 10, Release, Server GC", + "date": "2026-09-23", + "secondsPerCell": 2, + "pipeline": 256, + "connections": [ + 1, + 2, + 4, + 8, + 16 + ], + "series": [ + { + "name": "JSON-RPC.Net, TCP", + "kind": "ours", + "rpcPerSec": [ + 1044121, + 3507388, + 5407126, + 8599018, + 10918454 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, batch of 100 per POST", + "kind": "ours", + "rpcPerSec": [ + 845968, + 1912938, + 3002007, + 4500453, + 8643939 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, 1 request per POST", + "kind": "ours", + "rpcPerSec": [ + 14693, + 41735, + 68160, + 141373, + 182739 + ] + }, + { + "name": "StreamJsonRpc, TCP, newline \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 156480, + 512457, + 716482, + 1050993, + 1237849 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 268252, + 442018, + 550061, + 1075333, + 1278084 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B Json.NET", + "kind": "theirs", + "rpcPerSec": [ + 179958, + 316537, + 339727, + 653316, + 637935 + ] + }, + { + "name": "gRPC for .NET, unary", + "kind": "grpc", + "rpcPerSec": [ + 193335, + 347537, + 329847, + 295814, + 360097 + ] + }, + { + "name": "gRPC for .NET, bidirectional stream", + "kind": "grpc", + "rpcPerSec": [ + 63708, + 112901, + 187967, + 241483, + 232514 + ] + } + ] +} diff --git a/benchmarks/charts/sweep-3.json b/benchmarks/charts/sweep-3.json new file mode 100644 index 0000000..ff970a2 --- /dev/null +++ b/benchmarks/charts/sweep-3.json @@ -0,0 +1,103 @@ +{ + "machine": "AMD Ryzen 7 7800X3D, 8 cores / 16 threads, Windows 11, .NET 10, Release, Server GC", + "date": "2026-09-23", + "secondsPerCell": 2, + "pipeline": 256, + "connections": [ + 1, + 2, + 4, + 8, + 16 + ], + "series": [ + { + "name": "JSON-RPC.Net, TCP", + "kind": "ours", + "rpcPerSec": [ + 1753461, + 2864903, + 5134810, + 8320994, + 12342034 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, batch of 100 per POST", + "kind": "ours", + "rpcPerSec": [ + 902499, + 1655066, + 3251689, + 5803041, + 9773010 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, 1 request per POST", + "kind": "ours", + "rpcPerSec": [ + 16608, + 34760, + 68072, + 136547, + 194976 + ] + }, + { + "name": "StreamJsonRpc, TCP, newline \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 259099, + 489920, + 734867, + 960889, + 1063106 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 270328, + 476075, + 657408, + 1045688, + 1117102 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B Json.NET", + "kind": "theirs", + "rpcPerSec": [ + 182308, + 255019, + 396942, + 582792, + 544394 + ] + }, + { + "name": "gRPC for .NET, unary", + "kind": "grpc", + "rpcPerSec": [ + 179749, + 322979, + 332854, + 377916, + 361627 + ] + }, + { + "name": "gRPC for .NET, bidirectional stream", + "kind": "grpc", + "rpcPerSec": [ + 62493, + 113543, + 204608, + 246839, + 265818 + ] + } + ] +} diff --git a/benchmarks/charts/sweep-4.json b/benchmarks/charts/sweep-4.json new file mode 100644 index 0000000..67d7cfa --- /dev/null +++ b/benchmarks/charts/sweep-4.json @@ -0,0 +1,103 @@ +{ + "machine": "AMD Ryzen 7 7800X3D, 8 cores / 16 threads, Windows 11, .NET 10, Release, Server GC", + "date": "2026-09-23", + "secondsPerCell": 2, + "pipeline": 256, + "connections": [ + 1, + 2, + 4, + 8, + 16 + ], + "series": [ + { + "name": "JSON-RPC.Net, TCP", + "kind": "ours", + "rpcPerSec": [ + 1272069, + 2977904, + 5461841, + 8830555, + 12635255 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, batch of 100 per POST", + "kind": "ours", + "rpcPerSec": [ + 887229, + 1950239, + 3155212, + 6032561, + 10046224 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, 1 request per POST", + "kind": "ours", + "rpcPerSec": [ + 14644, + 39241, + 63810, + 137996, + 199511 + ] + }, + { + "name": "StreamJsonRpc, TCP, newline \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 239319, + 498769, + 616795, + 927354, + 1191808 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 265461, + 445920, + 636495, + 975373, + 1368614 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B Json.NET", + "kind": "theirs", + "rpcPerSec": [ + 155954, + 252428, + 387471, + 520293, + 612214 + ] + }, + { + "name": "gRPC for .NET, unary", + "kind": "grpc", + "rpcPerSec": [ + 174616, + 319815, + 319253, + 334528, + 389365 + ] + }, + { + "name": "gRPC for .NET, bidirectional stream", + "kind": "grpc", + "rpcPerSec": [ + 61273, + 109486, + 202477, + 237856, + 291115 + ] + } + ] +} diff --git a/benchmarks/charts/sweep-5.json b/benchmarks/charts/sweep-5.json new file mode 100644 index 0000000..6a1b09b --- /dev/null +++ b/benchmarks/charts/sweep-5.json @@ -0,0 +1,103 @@ +{ + "machine": "AMD Ryzen 7 7800X3D, 8 cores / 16 threads, Windows 11, .NET 10, Release, Server GC", + "date": "2026-09-23", + "secondsPerCell": 2, + "pipeline": 256, + "connections": [ + 1, + 2, + 4, + 8, + 16 + ], + "series": [ + { + "name": "JSON-RPC.Net, TCP", + "kind": "ours", + "rpcPerSec": [ + 1819781, + 3264403, + 5606579, + 9065076, + 12964855 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, batch of 100 per POST", + "kind": "ours", + "rpcPerSec": [ + 1033818, + 2120158, + 3736721, + 6609939, + 10154393 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, 1 request per POST", + "kind": "ours", + "rpcPerSec": [ + 18617, + 42797, + 71053, + 136975, + 209586 + ] + }, + { + "name": "StreamJsonRpc, TCP, newline \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 273248, + 498498, + 775504, + 1054633, + 1167263 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 286794, + 486043, + 707816, + 1091866, + 1342829 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B Json.NET", + "kind": "theirs", + "rpcPerSec": [ + 195030, + 291457, + 427511, + 645594, + 676702 + ] + }, + { + "name": "gRPC for .NET, unary", + "kind": "grpc", + "rpcPerSec": [ + 233405, + 366628, + 387737, + 417512, + 383022 + ] + }, + { + "name": "gRPC for .NET, bidirectional stream", + "kind": "grpc", + "rpcPerSec": [ + 74526, + 119705, + 228385, + 246075, + 286672 + ] + } + ] +} diff --git a/benchmarks/charts/sweep.json b/benchmarks/charts/sweep.json new file mode 100644 index 0000000..26b341b --- /dev/null +++ b/benchmarks/charts/sweep.json @@ -0,0 +1,103 @@ +{ + "machine": "AMD Ryzen 7 7800X3D, 8 cores / 16 threads, Windows 11, .NET 10, Release, Server GC", + "date": "2026-09-23", + "secondsPerCell": 2, + "pipeline": 256, + "connections": [ + 1, + 2, + 4, + 8, + 16 + ], + "series": [ + { + "name": "JSON-RPC.Net, TCP", + "kind": "ours", + "rpcPerSec": [ + 1069351, + 3237018, + 5284082, + 8800925, + 11580423 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, batch of 100 per POST", + "kind": "ours", + "rpcPerSec": [ + 674970, + 1916457, + 3291844, + 5791331, + 9206129 + ] + }, + { + "name": "JSON-RPC.Net, HTTP, 1 request per POST", + "kind": "ours", + "rpcPerSec": [ + 16983, + 41752, + 69178, + 143600, + 178050 + ] + }, + { + "name": "StreamJsonRpc, TCP, newline \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 110390, + 497083, + 692733, + 1143682, + 1119388 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B System.Text.Json", + "kind": "theirs", + "rpcPerSec": [ + 199908, + 451627, + 701605, + 870961, + 1301094 + ] + }, + { + "name": "StreamJsonRpc, TCP, Content-Length \u002B Json.NET", + "kind": "theirs", + "rpcPerSec": [ + 110820, + 325667, + 392057, + 469099, + 647221 + ] + }, + { + "name": "gRPC for .NET, unary", + "kind": "grpc", + "rpcPerSec": [ + 137744, + 350661, + 390318, + 361392, + 404186 + ] + }, + { + "name": "gRPC for .NET, bidirectional stream", + "kind": "grpc", + "rpcPerSec": [ + 58380, + 124083, + 204217, + 244421, + 260798 + ] + } + ] +} diff --git a/benchmarks/charts/sync-threads-dark.svg b/benchmarks/charts/sync-threads-dark.svg new file mode 100644 index 0000000..5ea1d9b --- /dev/null +++ b/benchmarks/charts/sync-threads-dark.svg @@ -0,0 +1,77 @@ + +JSON-RPC.Net alone, by worker threadsByte entry point in a loop, no scheduler, no transport. Ryzen 7 7800X3D, .NET 10, Server GC, two sweeps on an idle box. Data revision a4f53e6a848f. + +JSON-RPC.Net alone, by worker threads +Byte entry point in a loop, no scheduler, no transport. Ryzen 7 7800X3D, .NET 10, Server GC, two sweeps on an idle box. +Aggregate requests per second: low to high over the day's runs + +0 + +10 M + +20 M + +30 M + +40 M + + + + + + + +linear scaling from the 1-thread low + + + + + + +4.5 M to 4.6 M + + + +7.6 M to 9.5 M + + + +16.4 M to 17.4 M + + + +25.1 M to 26.8 M + + + +30.6 M to 35.8 M +Reported ns per request per worker thread: one value per row, no range + +0 + +200 + +400 + +600 + + +217 ns + +222 ns + +230 ns + +298 ns + +446 ns + +1 +2 +4 +8 +16 +worker threads (each step doubles; 8 physical cores, 16 with SMT) +requests per second +ns per request + diff --git a/benchmarks/charts/sync-threads.svg b/benchmarks/charts/sync-threads.svg new file mode 100644 index 0000000..4d08367 --- /dev/null +++ b/benchmarks/charts/sync-threads.svg @@ -0,0 +1,77 @@ + +JSON-RPC.Net alone, by worker threadsByte entry point in a loop, no scheduler, no transport. Ryzen 7 7800X3D, .NET 10, Server GC, two sweeps on an idle box. Data revision a4f53e6a848f. + +JSON-RPC.Net alone, by worker threads +Byte entry point in a loop, no scheduler, no transport. Ryzen 7 7800X3D, .NET 10, Server GC, two sweeps on an idle box. +Aggregate requests per second: low to high over the day's runs + +0 + +10 M + +20 M + +30 M + +40 M + + + + + + + +linear scaling from the 1-thread low + + + + + + +4.5 M to 4.6 M + + + +7.6 M to 9.5 M + + + +16.4 M to 17.4 M + + + +25.1 M to 26.8 M + + + +30.6 M to 35.8 M +Reported ns per request per worker thread: one value per row, no range + +0 + +200 + +400 + +600 + + +217 ns + +222 ns + +230 ns + +298 ns + +446 ns + +1 +2 +4 +8 +16 +worker threads (each step doubles; 8 physical cores, 16 with SMT) +requests per second +ns per request + diff --git a/benchmarks/charts/test_render.py b/benchmarks/charts/test_render.py new file mode 100644 index 0000000..e98b7aa --- /dev/null +++ b/benchmarks/charts/test_render.py @@ -0,0 +1,128 @@ +"""Checks for the chart renderer: python -m unittest benchmarks/charts/test_render.py (standard library only).""" +import copy +import json +import os +import re +import sys +import unittest +import xml.dom.minidom + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import render # noqa: E402 + + +class Formatting(unittest.TestCase): + def test_published_text_is_preserved(self): + self.assertEqual(render.fmt_range(dict(low=13.7e6, high=14.6e6, published="13.7 M to 14.6 M")), "13.7 M to 14.6 M") + self.assertEqual(render.fmt_range(dict(low=30.6e6, high=35.8e6, published="30.6 M to 35.8 M")), "30.6 M to 35.8 M") + + def test_three_significant_figures_without_published_text(self): + self.assertEqual(render.fmt(1.38e6), "1.38 M") + self.assertEqual(render.fmt(13.7e6), "13.7 M") + self.assertEqual(render.fmt(625e3), "625 k") + self.assertEqual(render.fmt(96.4e3), "96.4 k") + self.assertEqual(render.fmt_range(dict(low=1.28e6, high=1.30e6)), "1.28 M to 1.3 M") + self.assertEqual(render.fmt_range(dict(low=5e5, high=5e5)), "500 k") + + def test_axis_ticks(self): + self.assertEqual(render.fmt_axis(10e6), "10 M") + self.assertEqual(render.fmt_axis(200e3), "200 k") + + +class Scales(unittest.TestCase): + def test_log_domain_hugs_the_data(self): + s = render.Log(96e3, 14.6e6, 0, 1) + self.assertEqual((s.lo, s.hi), (50e3, 20e6)) + self.assertAlmostEqual(s(50e3), 0) + self.assertAlmostEqual(s(20e6), 1) + + def test_log2_positions(self): + self.assertEqual(render.log2_positions([1, 2, 4, 8, 16], 0, 4), [0, 1, 2, 3, 4]) + self.assertEqual(render.log2_positions([4], 0, 4), [2]) + + def test_rejects_bad_domains(self): + with self.assertRaises(render.DataError): + render.Log(0, 10, 0, 1) + with self.assertRaises(render.DataError): + render.Linear(5, 5, 0, 1) + + +class Validation(unittest.TestCase): + def setUp(self): + self.data = render.load() + + def bad(self, mutate): + d = copy.deepcopy(self.data) + mutate(d) + with self.assertRaises(render.DataError): + render.validate(d) + + def test_reversed_range(self): + self.bad(lambda d: d["sets"]["compare"]["series"][0].update(low=2e6, high=1e6)) + + def test_non_positive(self): + self.bad(lambda d: d["sets"]["kestrel"]["series"][0].update(low=0)) + + def test_unknown_family(self): + self.bad(lambda d: d["sets"]["kestrel"]["series"][0].update(family="nope")) + + def test_duplicate_id(self): + self.bad(lambda d: d["sets"]["kestrel"]["series"][1].update(id=d["sets"]["kestrel"]["series"][0]["id"])) + + def test_length_mismatch(self): + self.bad(lambda d: d["sets"]["sync"]["series"][0]["points"].pop()) + + def test_group_names_unknown_series(self): + self.bad(lambda d: d["sets"]["compare"]["groups"][0]["series"].append("missing")) + + def test_sweep_run_mismatch(self): + sweep = copy.deepcopy(self.data["sets"]["sweep"]) + runs = [dict(connections=[1, 2], secondsPerCell=2, pipeline=256, date="d", machine="m", + series=[dict(name=s["name"], rpcPerSec=[1, 2]) for s in sweep["series"]])] + runs[0]["series"][0]["rpcPerSec"] = [1] + with self.assertRaises(render.DataError): + render.fold_sweep(sweep, runs, ["a"]) + + +class Outputs(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.data = render.load() + cls.outputs = render.render_all(cls.data) + + def test_every_svg_is_well_formed_and_carries_the_revision(self): + for name, content in self.outputs.items(): + if name.endswith(".svg"): + xml.dom.minidom.parseString(content) + self.assertIn(f'data-revision="{self.data["revision"]}"', content, name) + self.assertNotIn("&amp;", content, name) + + def test_published_endpoints_survive_into_the_svgs(self): + for key in ("kestrel", "compare", "inprocess"): + for s in self.data["sets"][key]["series"]: + self.assertIn(render.esc(s["published"]), self.outputs["compare-streamjsonrpc.svg"] + self.outputs["kestrel-transports.svg"] + self.outputs["inprocess-paths.svg"], s["id"]) + for p in self.data["sets"]["sync"]["series"][0]["points"]: + self.assertIn(p["published"], self.outputs["sync-threads.svg"]) + + def test_clip_ids_are_unique_across_themes(self): + ids = re.findall(r'clipPath id="([^"]+)"', self.outputs["sync-threads.svg"] + self.outputs["sync-threads-dark.svg"]) + self.assertEqual(len(ids), len(set(ids))) + + def test_explorer_embeds_the_same_summaries(self): + page = self.outputs["explorer.html"] + self.assertNotIn("", page.split('', page, re.S) + embedded = json.loads(m.group(1)) + self.assertEqual(embedded["revision"], self.data["revision"]) + self.assertEqual(embedded["sets"]["sweep"]["series"][0]["points"], self.data["sets"]["sweep"]["series"][0]["points"]) + self.assertEqual(embedded["sets"]["compare"]["series"][0]["published"], self.data["sets"]["compare"]["series"][0]["published"]) + + def test_rendering_is_deterministic(self): + self.assertEqual(render.render_all(render.load()), self.outputs) + + def test_readme_figures_match(self): + self.assertEqual(render.check_readme(self.data), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/benchmarks/charts/wasm-interop-dark.svg b/benchmarks/charts/wasm-interop-dark.svg new file mode 100644 index 0000000..7004e3a --- /dev/null +++ b/benchmarks/charts/wasm-interop-dark.svg @@ -0,0 +1,98 @@ + +In the browser: interpreter vs AOTsamples/WasmHost in Chrome 152, .NET 10, 20,000 RPCs per row. Interpreter: one run. AOT: the better of two runs. Batches are per RPC. The interpreter-to-AOT distance is a deployment difference, not a range. Data revision a4f53e6a848f. + +In the browser: interpreter vs AOT +samples/WasmHost in Chrome 152, .NET 10, 20,000 RPCs per row. Interpreter: one run. AOT: the better of two runs. +Batches are per RPC. The interpreter-to-AOT distance is a deployment difference, not a range. + + +10 k + + + +100 k + + + +1 M + + +add(1, 2) operations per second, log scale + +interpreter (one run) + +AOT (better of two runs) +interpreter +AOT +plain Blazor interop, one method per operation +invokeMethod Add + + +15,700 + +66,600 +invokeMethodAsync Add + + +14,500 + +59,300 +[JSExport] AddExported, typed arguments + + +2,860,000 + +3,230,000 +JSON-RPC, one request document per call +invokeMethod Process + + +6,000 + +33,500 +invokeMethodAsync Process + + +5,700 + +36,000 +[JSExport] ProcessExported, strings + + +17,100 + +151,600 +[JSExport] ProcessBytes, UTF-8 buffers + + +19,000 + +142,000 +JSON-RPC, a batch of 100 per call +invokeMethod Process + + +13,000 + +136,000 +[JSExport] ProcessExported, strings + + +25,000 + +185,500 +[JSExport] ProcessBytes, UTF-8 buffers + + +27,000 + +210,500 + +Reference: the server in a .NET loop, no interop +ProcessMany, the string request N times + + +17,600 + +169,800 + diff --git a/benchmarks/charts/wasm-interop.svg b/benchmarks/charts/wasm-interop.svg new file mode 100644 index 0000000..bf08570 --- /dev/null +++ b/benchmarks/charts/wasm-interop.svg @@ -0,0 +1,98 @@ + +In the browser: interpreter vs AOTsamples/WasmHost in Chrome 152, .NET 10, 20,000 RPCs per row. Interpreter: one run. AOT: the better of two runs. Batches are per RPC. The interpreter-to-AOT distance is a deployment difference, not a range. Data revision a4f53e6a848f. + +In the browser: interpreter vs AOT +samples/WasmHost in Chrome 152, .NET 10, 20,000 RPCs per row. Interpreter: one run. AOT: the better of two runs. +Batches are per RPC. The interpreter-to-AOT distance is a deployment difference, not a range. + + +10 k + + + +100 k + + + +1 M + + +add(1, 2) operations per second, log scale + +interpreter (one run) + +AOT (better of two runs) +interpreter +AOT +plain Blazor interop, one method per operation +invokeMethod Add + + +15,700 + +66,600 +invokeMethodAsync Add + + +14,500 + +59,300 +[JSExport] AddExported, typed arguments + + +2,860,000 + +3,230,000 +JSON-RPC, one request document per call +invokeMethod Process + + +6,000 + +33,500 +invokeMethodAsync Process + + +5,700 + +36,000 +[JSExport] ProcessExported, strings + + +17,100 + +151,600 +[JSExport] ProcessBytes, UTF-8 buffers + + +19,000 + +142,000 +JSON-RPC, a batch of 100 per call +invokeMethod Process + + +13,000 + +136,000 +[JSExport] ProcessExported, strings + + +25,000 + +185,500 +[JSExport] ProcessBytes, UTF-8 buffers + + +27,000 + +210,500 + +Reference: the server in a .NET loop, no interop +ProcessMany, the string request N times + + +17,600 + +169,800 + diff --git a/docs/reviews/2026-09-23_codex-astra-charts.md b/docs/reviews/2026-09-23_codex-astra-charts.md new file mode 100644 index 0000000..c8e797a --- /dev/null +++ b/docs/reviews/2026-09-23_codex-astra-charts.md @@ -0,0 +1,439 @@ + + +# Chart design review for JSON-RPC.NET + +## 1. Summary + +- Replace categorical bars with log-scaled interval plots: show both endpoints of every published range. +- Use line charts for measured thread and connection sweeps, with visible uncertainty at each sampled count. +- Separate network comparisons from direct-call, pipe, and sequential-proxy measurements. +- Preserve existing README ranges when adding the sweep; its current single observations cannot replace repeated-run evidence. +- Keep library colours consistent, distinguish settings with shapes and dashes, and generate explicit light/dark variants. +- GitHub README images cannot provide tooltips or toggles; a linked HTML explorer becomes worthwhile with the eight-series sweep. +- Make one versioned data file authoritative for tables, SVGs, and HTML; fix precision, validation, and measurement captions first. + +## 2. What the current charts get wrong + +### Review basis + +I read the README benchmark section and subsequent content, both requested benchmark implementations, the three original SVGs as text, and `render.py`. +I also read the WebAssembly results, the gRPC client implementation, and relevant `BenchmarkRunner` calculations. +The review is advice only; I did not modify files or run the benchmark or renderer. + +The branch was `finish-netstandard-upgrade`, with HEAD at `192a97f`. +The eight inspected commits cover the chart introduction, comparison harness, performance pass, gRPC addition, and refreshed measurements. +Chroma queries returned benchmark context and older documentation, but no relevant chart-design decision that supersedes the repository evidence. + +The working tree changed during this review. +Initially, the renderer produced linear bars; subsequently, revised charts, a revised renderer, and `sweep.json` appeared. +I read the revised renderer and sweep as well. +Findings below distinguish the original design from the working-tree revision; they are grounded in source and SVG geometry, not a browser visual inspection. + +### Design and interpretation problems + +1. **The original linear bars suppress the slower paths.** + Their plot area is only 480 pixels wide. + The comparison SVG gives gRPC approximately 6.6–6.9 pixels, the pipe row 4.4 pixels, and the sequential proxy 3.3 pixels. + The original Kestrel HTTP-single bar is only 2.3 pixels wide. + +2. **The original marks do not encode the ranges.** + Bar lengths represent `(low + high) / 2`; only text preserves the endpoints. + That midpoint is not necessarily an observed result, mean, or median. + Two-thread throughput spans 7.6–9.5 M, an important difference that should be visible geometrically. + +3. **The revised logarithmic bars improve visibility but retain an unsuitable length encoding.** + `log_bars()` draws from an arbitrary positive axis minimum to a geometric midpoint. + Those lengths cannot be interpreted as throughput ratios. + Position a capped interval on the logarithmic axis instead. + +4. **Measurement boundaries are mixed.** + Direct byte processing, pipelined pipes, sequential typed proxies, raw TCP clients, and Grpc.Net.Client exercise different amounts of work. + Grouping primarily by library does not resolve that distinction. + Put network results together and in-process context in a separate panel. + +5. **The captions overgeneralise concurrency.** + The revised sweep subtitle says every connection has 256 requests in flight. + TCP and gRPC use that pipeline setting; HTTP clients await each POST, containing either one RPC or 100 RPCs. + Label the x-axis “client connections / gRPC channels,” never “threads,” for the network sweep. + +6. **The new sweep is a different measurement set.** + It currently contains eight network series, five connection counts, and one two-second observation per cell after warm-up. + It does not sweep the direct-call or StreamJsonRpc in-process paths. + Its 16-connection TCP result is 11.58 M, versus the README comparison range of 13.7–14.6 M. + Its unary gRPC result is 404 k, versus 192–198 k. + Preserve these as separate sessions; do not combine them into an unexplained range or silently substitute one for another. + +7. **The revision loses published information.** + It overwrites `kestrel-transports.svg` with single-run sweep lines, removing the original transport ranges. + Its `fmt()` also changes 13.7–14.6 M into “14 M to 15 M” and 30.6–35.8 M into “31 M to 36 M.” + These are material losses in a chart intended to communicate ranges. + +8. **Thread cost is not request latency.** + The harness computes `threads × 1e9 / aggregate_RPC_per_second`. + This is average processing time per worker, not a sampled network latency distribution. + The revised comment calls all supplied nanosecond values fastest-run costs, but at two threads 9.5 M implies about 211 ns, not the published 222 ns. + +9. **Layout and theme treatment need explicit design.** + Long subtitles and settings are single SVG text elements, with fixed margins and no wrapping. + The revised eight-series chart leaves approximately 500 pixels for the plot and uses circles for every setting. + White cards remain readable in dark mode, but they do not provide the requested dark-theme presentation. + +## 3. Recommended chart set + +Keep three immediate README views: library comparison, transport comparison, and thread scaling. +Add the connection sweep as a fourth view. +Place in-process detail in an expandable section and WebAssembly detail in the sample README. + +Across all views, “range” means the observed minimum–maximum over the stated runs, not a confidence interval. +Every interval gets full-opacity endpoint caps and a readable numeric range where space permits. +A published singleton gets a marker and a caption explaining that a range was not supplied. +Do not manufacture interval width to make a narrow range visible; preserve the endpoints in adjacent text. + +Use the revised renderer’s Okabe–Ito family colours at full opacity: + +| Meaning | Colour | Contrast on white | Contrast on `#0d1117` | +|---|---|---:|---:| +| JSON-RPC.Net | `#009e73` | 3.42:1 | 5.53:1 | +| StreamJsonRpc | `#0072b2` | 5.19:1 | 3.65:1 | +| gRPC for .NET | `#d55e00` | 3.87:1 | 4.89:1 | +| Context/reference marks | `#767676` | 4.54:1 | 4.17:1 | + +These calculated contrasts apply to solid marks against the backgrounds, not translucent fills. +Use neutral theme-specific text for labels; the series colours are not all suitable for small text. +Shapes, dashes, panel titles, and direct labels make identification independent of colour. +Keep essential strokes at least 2 pixels at the intended display size. +The 3:1 graphical contrast target follows [W3C guidance](https://www.w3.org/WAI/WCAG21/understanding/non-text-contrast.html). + +All sketches below describe layout; character positions are not calibrated measurements. + +### 3.1. Library comparison at 16 connections + +- **Purpose:** compare the measured network configurations while exposing framing, formatter, and client differences. +- **Data:** the six network rows in the README comparison table; exclude its three in-process rows. +- **Chart type:** horizontal capped interval plot, with a separate labelled gRPC subgroup. +- **Axes and scale:** categorical configurations vertically; RPC/s horizontally on a logarithmic axis, approximately 100 k–20 M. +- **Range encoding:** a segment from low to high, caps at both ends, and an aligned numeric column; 625 k is a singleton marker. +- **Series and colours:** green JSON-RPC.Net, blue StreamJsonRpc, vermilion gRPC; settings appear in row labels. +- **Caption:** five small calls, loopback, 16 connections/channels; TCP/gRPC pipeline 256; gRPC includes its .NET client on the server’s machine. +- Preserve the comparison corpus’s `jsonrpc` member and identify JSON-RPC.Net’s actual configured serializer; a StreamJsonRpc formatter name does not establish a shared serializer. + +```text +Network configuration RPC/s, logarithmic Reported range +JSON-RPC.Net TCP, raw documents |---| 13.7–14.6 M +StreamJsonRpc TCP, newline + STJ |--| 1.38–1.44 M +StreamJsonRpc TCP, Content-Length + STJ |--| 1.41–1.45 M +StreamJsonRpc TCP, Content-Length + Json.NET o 625 k +gRPC: .NET client and service share the machine + Unary |-| 192–198 k + Bidirectional stream |-| 200–209 k + 100 k 1 M 10 M 20 M +``` + +Use stable configuration order rather than repeatedly sorting small, overlapping differences. +The two StreamJsonRpc STJ ranges overlap; the graphic should not imply a decisive framing winner. + +### 3.2. JSON-RPC.Net transport comparison + +- **Purpose:** show the effect of transport and batching on the existing measured workload. +- **Data:** all four rows of the README Kestrel table, retaining its own session and request corpus. +- **Chart type:** horizontal capped interval plot, with the in-process reference separated by a rule. +- **Axes and scale:** transport vertically; logarithmic RPC/s horizontally, approximately 100 k–50 M. +- **Range encoding:** show all four low/high segments and their exact published labels. +- **Series and colours:** green network rows; neutral grey in-process reference. +- **Caption:** 16 HTTP clients or TCP connections; HTTP is one outstanding POST per client; TCP pipeline is 256. + +```text +Transport RPC/s, logarithmic +HTTP, one RPC per POST |---| 128–168 k +HTTP, 100 RPCs per POST |--| 12.7–13.7 M +TCP, 256 outstanding per connection || 15.2–15.5 M +---------------------------------------------------------------- +Reference: direct calls, 16 threads || 30.8–31.3 M + 100 k 1 M 10 M 50 M +``` + +Label the unit as RPC/s, so a POST containing 100 RPCs contributes 100 operations. +Do not connect these categorical rows with a line. +Keep this snapshot when adding the sweep; they answer different questions. + +### 3.3. Library-only scaling by worker threads + +- **Purpose:** reveal aggregate throughput growth and the accompanying increase in per-worker processing cost. +- **Data:** the five README sync ranges at 1, 2, 4, 8, and 16 threads, plus the five published nanosecond figures. +- **Chart type:** two vertically aligned panels: throughput range envelope above, reported cost points below. +- **Axes and scale:** shared x-axis at 1, 2, 4, 8, 16, explicitly labelled “worker threads; each step doubles.” +- Use log2 x-positioning; use linear y-axes of approximately 0–40 M RPC/s and 0–600 ns respectively. +- **Range encoding:** throughput has capped vertical intervals and a lightly filled envelope bounded by visible low/high lines. +- Join adjacent range boundaries with straight segments; do not smooth or introduce an unlabelled midpoint line. +- The nanosecond figures have no supplied ranges: show them as individual reported values and say so. +- **Series and colours:** green throughput and cost marks; neutral annotation at eight physical cores. +- Treat 16 workers as exceeding physical core count; this benchmark does not establish exact per-core thread placement. + +```text +Aggregate RPC/s; higher is better +40 M | upper boundary +30 M | | |===| +20 M | |===| |===| +10 M | |===| + 0 M | || + +-----1------2------4------8------16 + +Reported ns/RPC per worker; lower is better +600 | +400 | o +200 | o o o o + 0 +-----1------2------4------8------16 + 217 222 230 298 446 +``` + +Do not describe the nanosecond points as medians or fastest observations without recovering their provenance. +If costs are later derived from each throughput interval, transform endpoints as `[threads × 1e9 / high, threads × 1e9 / low]` and label them derived. +For example, the current 16-thread throughput implies approximately 447–523 ns per worker. +Keep the published scalar figures distinguishable from that derived range. + +### 3.4. Network scaling by connections + +- **Purpose:** show where each library/configuration scales, plateaus, or declines as client concurrency changes. +- **Data:** the eight network series in `sweep.json`, at exactly its five recorded connection counts. +- **Chart type:** three stacked line-chart panels, grouped by library, with identical axes and plot widths. +- **Axes and scale:** log2 x-axis at 1, 2, 4, 8, 16; shared logarithmic RPC/s y-axis, currently approximately 10 k–20 M. +- **Range encoding now:** one marker per observed value; explicitly state “one measured run per point; range unavailable.” +- **Range encoding after repeats:** median line through actual samples, with capped minimum–maximum whiskers at every point. +- Prefer whiskers over eight overlapping translucent bands; retain numeric low/high values in the accompanying table. +- **Series and colours:** retain family colours and use the configuration encodings below. +- Keep all configurations visible in the static panels; arbitrary selections belong in the HTML explorer. + +| Family | Solid + circle | Long dash + square | Dotted + triangle | +|---|---|---|---| +| JSON-RPC.Net | TCP | HTTP batch 100 | HTTP single | +| StreamJsonRpc | Newline + STJ | Content-Length + STJ | Content-Length + Json.NET | +| gRPC | Unary | Bidirectional stream | — | + +```text +Shared logarithmic RPC/s scale; layout only +JSON-RPC.Net o------o------o------o------o TCP + s------s------s------s------s HTTP batch + ^------^------^------^------^ HTTP single +StreamJsonRpc o------o------o------o------o Newline + STJ + s------s------s------s------s Header + STJ + ^------^------^------^------^ Header + Json.NET +gRPC o------o------o------o------o Unary + s------s------s------s------s Stream + 1 2 4 8 16 connections/channels +Future repeated points: capped vertical whisker through each median marker. +``` + +Use the actual observed shapes, including decreases; never force monotonic curves. +Do not infer CPU thread counts from connections, or fabricate in-process sweep lines from fixed comparison rows. +The caption must list TCP/gRPC pipeline 256, HTTP one POST outstanding, and batch size 100 separately. + +### 3.5. In-process execution context + +- **Purpose:** explain the overhead included in three different in-process calling paths. +- **Data:** the comparison table’s direct-call 2.6–3.6 M, pipe 117–142 k, and sequential-proxy 96–97 k rows. +- **Chart type:** separate horizontal interval plot under “In-process paths: different execution boundaries.” +- **Axes and scale:** path vertically; logarithmic RPC/s horizontally, approximately 50 k–5 M. +- **Range encoding:** capped low/high segments and numeric endpoint labels for all three rows. +- **Series and colours:** green direct-call row; blue pipe/proxy rows, distinguished by complete labels. +- Place this in an expandable detail section; its ranges remain visible whenever expanded. + +```text +Direct bytes, one worker |-------| 2.6–3.6 M +Pipe pair, one client, 256 outstanding |----| 117–142 k +Typed proxy, sequential await || 96–97 k + 50 k 100 k 1 M 5 M +``` + +Do not draw this as a continuation of the network sweep or call it an equal-feature implementation comparison. +The comparison direct-call range also remains distinct from the sync table’s newer 4.5–4.6 M range. + +### 3.6. WebAssembly interpreter versus AOT + +- **Purpose:** expose the effect of compilation and interop path within the browser workload. +- **Data:** all eleven rows in `samples/WasmHost/README.md`, using its operations/RPC-per-second columns. +- **Chart type:** paired horizontal dot plot, grouped into plain interop, single JSON-RPC, batched JSON-RPC, and internal .NET loop. +- **Axes and scale:** path vertically; logarithmic logical operations/s horizontally, approximately 1 k–10 M. +- **Range encoding now:** separate interpreter and AOT markers; no uncertainty intervals are available from the published table. +- State that AOT reports the better of two runs; the distance between interpreter and AOT is a deployment difference, not a low/high range. +- **Range encoding after repeats:** one capped interval per compilation mode, with its own sample count and summary policy. +- **Series and colours:** green JSON-RPC paths, neutral plain-interop references; circle for interpreter and diamond for AOT. +- Keep this chart in the sample README and link from the main README. + +```text +Path Operations/s, logarithmic +Plain invokeMethod Add o------D +Plain typed JSExport Add oD +JSON-RPC invokeMethod Process o---------D +JSON-RPC UTF-8 ProcessBytes o-----------D +JSON-RPC UTF-8 batch 100 o------------D + o interpreter; D AOT +``` + +Use per-RPC throughput for batches, not calls-per-second or microseconds per batch. +Do not merge these `add(1, 2)` measurements into the server’s five-call comparison. + +## 4. GitHub constraints and the interactive option + +**Tooltips and toggles cannot work inside the README’s SVG images.** +GitHub’s image presentation does not expose the SVG as an interactive document. +Scripts, SVG-internal links, CSS hover interactions, and `` tooltips cannot supply missing chart information. +External fonts and images are unavailable; SMIL/CSS animation can run, but does not enable interaction. +These restrictions are consistent with the [W3C SVG Integration draft’s secure animated image model](https://www.w3.org/TR/svg-integration/). + +Generate explicit light and dark SVG variants from the same data and geometry. +Use white with `#1f2328` primary text for light mode; use `#0d1117` with `#f0f6fc` text for dark mode. +Use sufficiently contrasting secondary text in each theme, rather than reusing the light-theme muted colour. +GitHub supports selection through `<picture>` and `prefers-color-scheme`. [GitHub documentation](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/quickstart-for-writing-on-github) + +Keep captions, range definitions, sample counts, and setting names visible without hover. +Provide descriptive image alt text and retain the numeric tables. +Do not animate benchmark values or alternate configurations over time. + +A linked, dependency-free HTML explorer is worthwhile once the sweep is retained and repeated. +It should use the same datasets and uncertainty rules as the static charts, with inline SVG and vanilla JavaScript: + +- Toggle individual configurations or isolate two curves while preserving their low/high whiskers. +- Inspect exact values, low/high, sample count, run duration, and measurement metadata through pointer, keyboard focus, or tap. +- Select a connection count and compare all corresponding configurations without substituting older session values. +- Switch between logarithmic and linear axes, preserving endpoints and clearly updating the scale label. +- Keep axes fixed during ordinary series toggling; make “fit selected series” an explicit action. +- Expose an accessible data table and a download of the underlying data, including missing-range flags. + +Host it on GitHub Pages and provide a downloadable self-contained HTML file. +A GitHub repository HTML preview is not the interactive page. +The static README must remain complete when the explorer is never opened. + +## 5. Data pipeline + +Use one canonical, versioned file such as `benchmarks/charts/benchmarks.json`. +It should contain separate measurement sets for sync, Kestrel snapshot, comparison snapshot, network sweep, and WebAssembly. +Each set keeps its own workload, timing, versions, and summary policy. +All outputs consume those sets without pooling incompatible runs. + +```text +Harness exports + faithfully transcribed published intervals + | + v + benchmarks.json + | + validate + summarise + | + +--------------+--------------+ + v v v + README tables light/dark SVGs self-contained HTML + exact endpoints same endpoints embedded same dataset +``` + +The canonical format needs these fields: + +| Level | Required information | +|---|---| +| File | Schema version and stable measurement-set IDs | +| Measurement set | Date, source commit/dirty state, machine, runtime, GC, workload/corpus identity | +| Series | Stable ID, library, transport, framing, serializer, client implementation, batch and pipeline settings | +| Point | Thread/connection count, reported value or summary, low/high, sample count, range status | +| Raw run, when available | Completed RPC count, actual elapsed seconds, warm-up, repeat/session ID | +| Provenance | Published precision, aggregation policy, source location, and any unknown fields | + +Distinguish “observed range,” “single observation,” “published value only,” and “best of two.” +Equal displayed endpoints do not prove zero variation. +Do not infer the two original observations from rounded README endpoints. +For historical ranges with unknown sample counts, keep the count unknown. + +Adapt the present sweep format explicitly: +`connections[i]` pairs with `series[j].rpcPerSec[i]`, producing a point with one observation and unavailable repeat variability. +Add raw repetitions before calculating minimum, maximum, and median. +Recommend five measured repeats per cell, retaining warm-up and rotating configuration order across repeats. +Report those extrema as observed spread, not statistical confidence. + +Preserve the README’s original endpoint precision during the initial transcription. +Generate its tables from the same canonical data thereafter, or make renderer check mode verify them. +Keep different workload/session IDs even when their dates and display names match. + +For HTML, embed the validated JSON during generation so opening the downloaded file does not require `fetch()` or a local server. +Escape `<` in embedded JSON to prevent an accidental `</script>` terminator. +Have JavaScript use the already computed summaries, rather than implementing a second aggregation policy. +Include the same data revision identifier in HTML and SVG metadata. + +## 6. Review of `benchmarks/charts/render.py` as code + +The dependency-free approach is appropriate. +The revised `Linear`, `Log`, and chart functions provide a useful starting point. +The following changes apply to the working-tree revision inspected during this review. + +1. **Fix value formatting before publishing revised charts.** + `fmt()` rounds all values at or above 10 M to whole millions. + Separate tick formatting from observation formatting, preserving source precision for low/high labels. + Require 13.7–14.6 M and 30.6–35.8 M to survive unchanged. + `fmt_range()` must not fall back to millions for a small sub-million interval that rounds identically. + +2. **Replace `log_bars()` with `interval_chart()`.** + Remove the background-to-midpoint rectangle and geometric midpoint calculation. + Draw the actual low/high coordinates, caps, and singleton markers. + Use a fixed numeric label column so a high endpoint cannot push its text beyond the canvas. + +3. **Make centre statistics explicit in `line_chart()`.** + It currently derives arithmetic midpoints from every interval. + Accept an optional recorded median/value separately from low/high. + For historical sync ranges, draw boundaries and capped intervals without asserting an observed centre. + For single sweep observations, show markers and “range unavailable.” + +4. **Preserve measurement-set identity and captions.** + Do not overwrite the Kestrel range snapshot with a sweep chart under the same filename. + Generate titles, concurrency descriptions, and sample-count notes from per-series metadata. + The global pipeline value must not be presented as HTTP concurrency. + +5. **Validate before constructing geometry.** + Reject non-finite numbers, reversed ranges, non-positive log values, unknown kinds, duplicate IDs, and mismatched array lengths. + Handle empty datasets, one x-value, and constant log domains explicitly. + Current `zip()` calls can silently discard unmatched values; current x-positioning divides by `len(xs) - 1`. + +6. **Make axis semantics and domains deliberate.** + Equal index spacing is valid for this doubling sweep only when labelled accordingly. + Implement log2 x-positioning from numeric counts and reusable 1–2–5 logarithmic ticks. + Fit domains to all range endpoints with headroom; avoid extending a 14.6 M maximum unnecessarily to 100 M. + Remove the redundant conditional around `ys.hi` in reference-label placement. + +7. **Use stable style IDs rather than input order.** + The current dash lookup changes identity when series order changes and fails if another setting exceeds its list. + Map each series ID to colour, dash, and marker. + Provide distinct marker shapes; preserve range caps independently of the central-line dash pattern. + +8. **Centralise text and SVG construction.** + `esc()` is only applied to some fields; titles, subtitles, annotations, and reference labels remain interpolated. + Use `xml.etree.ElementTree`, or one consistently applied text/attribute escaping layer. + Add `<title>` and `<desc>` for standalone accessibility, without promising README hover behaviour. + Give clip paths unique IDs so multiple SVGs can coexist inline in the HTML explorer. + +9. **Separate layout, themes, and rendering from file writes.** + Return SVG text from pure rendering functions; place writing under `main()` and an `if __name__ == "__main__"` guard. + Use standard-library `json`, `math`, `pathlib`, `argparse`, and XML utilities only. + Add explicit line breaks or `tspan` wrapping, reserve space for range labels, and use theme backgrounds for marker fills. + Keep range boundaries opaque even when their optional connecting envelope is translucent. + +10. **Make generation reproducible and failure visible.** + Add `--check` to render in memory and compare with committed outputs. + Missing sweep data should fail when sweep outputs are requested, rather than silently leave stale committed charts. + Use deterministic ordering, UTF-8, stable coordinate formatting, and no render-time timestamps. + Add focused standard-library checks for endpoint preservation, invalid data, XML validity, and static/HTML summary agreement. + Visually verify both themes at typical README width and narrow-screen width before accepting the eventual implementation. + +## 7. Needs-decision items + +- **needs decision: interval plots vs logarithmic bars, recommended interval plots because position represents the README snapshot values honestly and both low/high endpoints remain explicit.** + +- **needs decision: one eight-series sweep panel vs three coordinated panels, recommended three panels because all eight series remain available with readable settings and per-point ranges on shared axes.** + +- **needs decision: single-run sweep publication vs repeated sweep publication, recommended five repeats per cell before headline publication because the sweep needs observed low/high values; label the existing data provisional with range unavailable.** + +- **needs decision: replacing snapshots with the sweep vs retaining both, recommended retaining both because the current sweep is a separate session and cannot preserve the README’s historical ranges by substitution.** + +- **needs decision: midpoint curves vs evidence-backed summaries, recommended range boundaries for historical sync data and median curves for future raw repeats because neither arithmetic nor geometric midpoint establishes a measured typical result.** + +- **needs decision: cost annotations vs a separate cost panel, recommended a separate panel because published nanosecond scalars have no supplied ranges and should not be mistaken for throughput-derived latency statistics.** + +- **needs decision: static-only presentation vs a linked explorer, recommended a linked explorer after the static redesign because selecting settings and inspecting exact per-connection ranges adds value that GitHub images cannot provide.** + +- **needs decision: one white SVG vs light/dark variants, recommended variants because the same data, scales, colours, and opaque range caps can remain readable while text and backgrounds match GitHub’s themes.** + +- **needs decision: Python literals plus sweep JSON vs one canonical data file, recommended one versioned file because every table, static interval, and interactive tooltip must report the same endpoints and provenance.** \ No newline at end of file diff --git a/docs/reviews/2026-09-23_codex-astra-perf-review.md b/docs/reviews/2026-09-23_codex-astra-perf-review.md new file mode 100644 index 0000000..0e42a40 --- /dev/null +++ b/docs/reviews/2026-09-23_codex-astra-perf-review.md @@ -0,0 +1,832 @@ +# Independent performance review of JSON-RPC.NET + +## 1. Summary + +1. Reviewed `finish-netstandard-upgrade` at `ccce23c`, for Astn/JSON-RPC.NET#148; all source locations below refer to that commit. +2. Biggest opportunity: compile an internal built-in invoker that calls typed jsmn readers and writers directly; estimate 12–25 ns/request saved (P1). +3. Second: keep tokenizer scan position in locals and make bounds proofs visible to the JIT; estimate 8–18 ns/request saved (P2). +4. Third: preserve the concrete pooled writer through primitive result writing; estimate 6–12 ns/request saved (P3). +5. Planning estimate for the combined primary-path work, including smaller dispatch improvements: 30–55 ns off the documented 227 ns/request. +6. That means roughly 172–197 ns/request, 5.1–5.8 M RPC/s, 13–24% less time, or 15–32% more throughput on one thread. +7. These are unmeasured estimates with overlapping benefits, not additive promises; individual changes may produce no gain under optimized .NET 10 code generation. +8. Numeric benchmark shapes already allocate zero bytes after warm-up; sealing alone will not materially change the headline number. +9. Reaching 7–10 M on one thread requires saving about 84–127 ns; the evidence does not justify promising that, although the existing process already exceeds that target across threads. +10. Release tests pass 745/745 on both .NET 8 and .NET 10; the solution build has a WebAssembly task-host failure and the optional benchmark stops at denied WMI access. + +## 2. Findings, ranked by expected gain + +### Evidence, scope, and how to read the estimates + +I read the requested README, serializer guide, prior review, all listed core/serializer/host files, +`Invocation/RpcMethod.cs`, the remaining core DTO/client files, and both benchmark implementations. +I inspected `git log --oneline -40` and the registration, invocation, mapping, framing, and buffer callers. +The initial tree had only an untracked `.claude/` directory. +During review another actor added an upstream-provenance comment to `JsmnTokenizer.cs`; I left it untouched. +That comment does not change executable code; this review uses the original HEAD line numbers. + +Memory queries ran against `json-rpc.net_code` and `json-rpc.net_docs` for hot path, allocation, +tokenizer, Utf8KeyTable, PooledByteBufferWriter, RpcMethod invocation, and serializer constraints. +Results support these constraints: byte-first processing, rollback-capable output staging, +long-lived serializers, typed compiled invocation, preserved wire conventions, and bounded nesting. +Some nearest-neighbor results were irrelevant comments/configuration; I did not treat those as design evidence. +The checked-in serializer guide and implementation are the authority where index descriptions differ. +In particular, `Scratch.GetReader` caches the **last serializer**, not a dictionary of readers per serializer. +The historical simdjson evaluation was read for its decision, not used as a current tokenizer profile. +Its base predates parser hardening, so its 77 ns parse number cannot partition today's 227 ns total. + +Tree-sitter `analyze_code`, `search_code`, and `find_usage` were used for the proposed types and removals. +The index returned false dead-file warnings and omitted references inside large files such as +`Handler.cs` and `JsmnMapper.cs`; for example, `BindMap` returned zero despite two visible callers. +I cross-checked with repository-wide `rg --no-ignore` searches excluding generated output, and read those callers. +Caller inventories below describe local source coverage; external NuGet consumers cannot be enumerated. +No absent local caller is sufficient evidence to delete a public API. + +P1–P6 are ordered by expected contribution to the five-shape sync workload. +P7–P14 cover other workloads, ordered by practical opportunity; each explicitly states zero headline contribution. +Times are planning estimates on the README machine/runtime, not results from modified implementations. +No proposed implementation was installed or benchmarked in this review. +Byte sizes marked “estimate” assume ordinary x64 managed layouts, excluding application-owned results. +Each implementation needs an A/B run, per-shape allocations, and byte-for-byte response comparison. + +### P1 — Compile a built-in invocation path with direct typed reads and writes + +**Location:** `Json-Rpc/Invocation/RpcMethod.cs:129,160,168`; `Json-Rpc/Jsmn/JsmnRequestReader.cs:300`; +`Json-Rpc/Jsmn/JsmnMapper.cs:47,89`; `Json-Rpc/Handler.cs:358`. +**Now:** each compiled argument calls virtual `ReadParam<T>`, which constructs a cursor and invokes +`JsmnReader<T>.Read`; results call virtual `JsonRpcSerializer.Write<T>`, then `JsmnWriter<T>.Write`. +Nullable adapters add another cached delegate invocation; argument maps also retain a default branch. +**Cost:** the service call itself is already compiled, but surrounding indirect calls impede inlining. +Do not replace it with `MethodInfo.Invoke`, `DynamicInvoke`, or a boxed `object[]` invoker. +**Change:** retain public `StreamingInvoker` and add an internal specialization selected once per invocation: + +```csharp +internal delegate void BuiltInInvoker( + JsmnRequestReader reader, int[] map, PooledByteBufferWriter output); +// In JsmnRequestReader; used only after successful Select/BindMap: +internal JsmnCursor CursorAt(int i) => new JsmnCursor(_doc.Span, _tok.Tokens, _paramVals[i]); +// A helper the generated expression calls directly: +internal static int ReadInt32(JsmnRequestReader reader, int i) +{ + var cursor = reader.CursorAt(i); + return JsmnMapper.ReadInt32(ref cursor); +} +// In Handler, inside the existing exception/context/rollback boundary: +if (reader is JsmnRequestReader jr && serializer is JsmnSerializer && method.InvokeBuiltIn != null) + method.InvokeBuiltIn(jr, map, output); +else + method.Invoke(reader, map, serializer, output); +``` + +Generate analogous direct expressions for the existing primitive readers, nullable tests, and writers. +Reuse the existing target `Expression.Call`, default expressions, and trailing-ref-exception handling. +Only generate a specialization where its semantics exactly match the existing built-in path. +Unsupported shapes, custom readers, Newtonsoft, STJ, and hook-driven boxed dispatch keep their current path. +**Gain:** estimate 12–25 ns/request on the mixed sync benchmark, 0 B/request; lower confidence without disassembly. +This estimate excludes P3's elimination of writer-interface calls; combined savings still overlap through inlining. +**Risk:** code size, registration cost, and interpreter/AOT behavior; do not generate every possible arity/type permutation eagerly. +**Affected:** `ServiceBinder.BindService` and both `SMD.AddService` registration routes construct `RpcMethod`; +`Handler.HandleRequest` consumes streaming invokers; `InvokeBoxed` must retain hook semantics. +**Verify:** compare generated-code disassembly and per-shape timings, including nullable null/non-null, +optional arguments, private/static/delegate methods, ref errors, custom serializers, and nested calls. +An open `Func<T1,T2,TResult>` delegate is not automatically faster: it adds a delegate call where `FromMethod` already emits a direct method call. + +### P2 — Keep the tokenizer cursor local and expose safe loop bounds + +**Location:** `Json-Rpc/Jsmn/JsmnTokenizer.cs:102,111,217,253,374`. +**Now:** the outer byte loop reads/writes `_pos` on the tokenizer object; helpers mutate the same field. +Token allocation and grammar state are interleaved with field loads, checks, and helper calls. +**Cost:** aliasable object fields make register retention and bounds-check elimination harder. +The existing `ParseString` already uses a local `i`; optimize the outer loop before adding SIMD or unsafe indexing. +**Change:** use a local position throughout the scanner, passing it by reference to helpers and publishing `Position` on exit: + +```csharp +int pos = 0; +try +{ + for (; pos < js.Length; pos++) + { + byte c = js[pos]; + // Existing grammar switch. Helpers take ref pos instead of accessing _pos. + // Preserve the old position reported on every success and error return. + } +} +finally { _pos = pos; } +``` + +Extract cold token-array growth; initialize each new token completely before use. +Cache arrays only across regions where `AllocToken` cannot replace them; reload after growth. +For digit runs, load one byte and use `(uint)(b - '0') <= 9` instead of repeating indexed reads/comparisons. +Keep the length test adjacent to the access; retain strict grammar, surrogate, UTF-8, and depth checks. +**Gain:** estimate 8–18 ns/request, 0 B/request; a JIT already retaining fields well may erase this benefit. +**Risk:** medium: error positions, partial input, token growth, and malformed input are easy to regress. +**Affected:** `JsmnRequestReader.TryParse`, standalone `JsmnSerializer.Tokenize`, parser tests, and the simdjson comparison harness. +**Verify:** parser-hardening tests plus randomized differential token streams, lengths around growth thresholds, +and Tier-1 disassembly showing fewer field traffic/range-check instructions. No blanket `Unsafe.Add` conversion. + +### P3 — Keep concrete writer calls inside the built-in primitive path + +**Location:** `Json-Rpc/Serialization/Utf8Json.cs:23,47,70,84,97,110,193`; +`Json-Rpc/Jsmn/JsmnMapper.cs:89`; `Json-Rpc/Invocation/RpcMethod.cs:101`. +**Now:** `Handler` has a concrete `PooledByteBufferWriter`, but the invoker erases it to `IBufferWriter<byte>`. +Primitive formatting obtains a span and advances through that interface; nullable null writing does the same. +**Cost:** two interface calls per formatted value, with limited inlining across the cached delegate chain. +**Change:** add a small internal concrete writer helper used by P1, while preserving public interface-based helpers: + +```csharp +internal static void WriteInt64Pooled(PooledByteBufferWriter output, long value) +{ + Span<byte> span = output.GetSpan(20); + System.Buffers.Text.Utf8Formatter.TryFormat(value, span, out int written); + output.Advance(written); +} +``` + +For float/decimal, share internal span-formatting code with the existing public writer so `.0`, +non-finite values, and netstandard2.0's formatting branch cannot diverge. +Use distinct helper names: adding another public `Utf8Json.WriteNull` overload would break the +name-only `GetMethod(nameof(Utf8Json.WriteNull))` lookup at `RpcMethod.cs:54` unless that lookup changes too. +Leave one interface boundary at `PooledByteBufferWriter.CopyTo(destination)` for arbitrary callers/PipeWriters. +**Gain:** estimate 6–12 ns/request, 0 B/request; verify whether .NET 10 PGO already devirtualizes either call. +**Risk:** low to medium; duplicate formatting logic would turn a local optimization into wire-format drift. +**Affected:** built-in streaming invokers, `JsmnWriter<T>`, and `Utf8Json`; STJ/Newtonsoft and custom output writers retain existing APIs. +**Verify:** same response bytes across all serializers and targets, all numeric edge cases, and a writer that returns exactly its requested span length. + +### P4 — Cache the last session hit without hashing its string on every request + +**Location:** `Json-Rpc/Handler.cs:53`; `Json-Rpc/JsonRpcProcessor.cs:169`. +**Now:** every document checks a session-registry version and hashes the session ID in a thread-local dictionary. +The benchmark and a raw connection repeatedly pass the same string instance, often a GUID-length default ID. +**Cost:** dictionary/hash work remains even when the previous lookup resolved exactly the same session. +**Change:** add thread-local last-ID/last-handler slots, invalidated alongside the existing local snapshot: + +```csharp +// After the existing registry-version validation/rebuild: +if (ReferenceEquals(sessionId, _lastSessionId) && _lastSessionHandler != null) + return _lastSessionHandler; +if (_sessionHandlersLocal.TryGetValue(sessionId, out var local)) +{ + _lastSessionId = sessionId; + _lastSessionHandler = local; + return local; +} +// Existing miss/create path, with the same invalidation semantics. +``` + +Clear both slots whenever the snapshot version changes; never cache a handler permanently in the transport. +Keep the dictionary path for equal-but-distinct strings and interleaved tenants. +**Gain:** estimate 4–10 ns/document, 0 B/request; amortized once per batch, so much less per batch member. +**Risk:** medium: session destroy/recreate must invalidate the shortcut exactly as it invalidates the current cache. +**Affected:** processor entry points, Config setters, ServiceBinder, default/session helpers, and host session selection. +**Verify:** same-instance/equal-string/alternating-session benchmarks and destroy/rebind tests, including other threads. +Do not infer or change the existing registry's concurrency contract as part of this optimization. + +### P5 — Dispatch envelope keys by length before comparing their bytes + +**Location:** `Json-Rpc/Jsmn/JsmnRequestReader.cs:124,138`; `Json-Rpc/Serialization/Utf8Json.cs:476`. +**Now:** each envelope name tries method, params, id, then jsonrpc with a general ASCII-case-insensitive loop. +**Cost:** repeated helper/length checks and byte-at-a-time comparison on a fixed, tiny vocabulary. +**Change:** decode escaped keys exactly as today, then select the possible name by length/first byte: + +```csharp +switch (name.Length) +{ + case 2: + if (Utf8Json.EqualsIgnoreAsciiCase(name, KeyId)) _idTok = val; + break; + case 6: + if ((name[0] | 0x20) == 'm' && Utf8Json.EqualsIgnoreAsciiCase(name, KeyMethod)) _methodTok = val; + else if ((name[0] | 0x20) == 'p' && Utf8Json.EqualsIgnoreAsciiCase(name, KeyParams)) _paramsTok = val; + break; + case 7: + if (Utf8Json.EqualsIgnoreAsciiCase(name, KeyJsonRpc)) _versionTok = val; + break; +} +``` + +An exact `SequenceEqual` fast path is another candidate, but measure it: an extra comparison can lose on tiny keys. +**Gain:** estimate 3–8 ns/request, 0 B/request; speculative until the generated code is compared. +**Risk:** low if full comparison remains; checking only a prefix or folding punctuation would be incorrect. +**Affected:** every serializer using the default reader; `Select` is called by processor dispatch, +`Handler.Handle`, `InvokeModified`, and the reader benchmark. +**Verify:** arbitrary member order, case variants, escaped keys, unknown same-length names, repeated members, +and version-present requests. Do not stop scanning after three fields: later members are currently significant. + +### P6 — Flatten dispatch entries only if the lookup profile warrants it + +**Location:** `Json-Rpc/Serialization/Utf8KeyTable.cs:13,27,83,98`. +**Now:** each bucket points to separately allocated linked `Entry` objects; FNV-1a hashes every lookup. +Every mutation clones entries, and `Insert` repeatedly calls `CountEntries` while rebuilding. +**Cost:** pointer chasing on collisions; repeated table walks and many objects at registration/rebinding. +**Change:** use one immutable published snapshot with bucket indices and a contiguous entry array: + +```csharp +private readonly struct Entry +{ + internal readonly byte[] Key; + internal readonly int Hash, Next; // -1 terminates a chain + internal readonly TValue Value; + internal Entry(byte[] key, int hash, int next, TValue value) + => (Key, Hash, Next, Value) = (key, hash, next, value); +} +// Snapshot owns readonly int[] buckets and Entry[] entries; publish one snapshot reference. +// A builder carries count explicitly instead of rescanning all buckets for each insertion. +``` + +Keep `SequenceEqual` for exact matching and preserve lock-free reads/copy-on-write replacement. +Do not replace it with hand-written 8-byte comparisons without evidence; runtime span equality is already optimized. +**Gain:** estimate 0–5 ns/request for the existing small table; larger savings are registration time and memory. +The benchmark service registers 20 methods, not just its five measured methods; preserve that table when comparing. +**Risk:** medium; array bounds, publication consistency, mutation visibility, and rebuild complexity. +**Affected:** only `SMDServiceCollection` instantiates `Utf8KeyTable<SMDService>`; its Add/indexer/Remove/Clear paths publish changes. +**Verify:** 1/20/100/1,000 methods, colliding buckets, long and escaped names, same-count replacement, and concurrent read/mutation scenarios. +If P6 does not improve steady-state lookup, take just the explicit-count rebuild simplification. + +### P7 — Reuse nested scratch instances and bound serializer-switch churn + +**Location:** `Json-Rpc/JsonRpcProcessor.cs:235,249,257,272`; +`Json-Rpc/Jsmn/JsmnSerializer.cs:96`; `Json-Rpc/Jsmn/JsmnTokenizer.cs:66,86`. +**Now:** a reentrant processor call creates a fresh Scratch, two 4 KiB rentals, a reader/tokenizer, +and index arrays; `Return` only resets `_inUse`, so the temporary rentals are not returned or reused. +A serializer identity change also replaces the sole cached reader, discarding its retained small token rental. +**Cost:** repeated nested calls can consume roughly 10 KiB of fresh backing storage per call after pool inventory drains. +Switching two long-lived serializers can allocate a new reader/tokenizer/index-array set on each switch. +**Change:** keep a small per-thread stack of reusable Scratch instances, and a bounded reader cache per slot: + +```csharp +[ThreadStatic] private static List<Scratch> _slots; +[ThreadStatic] private static int _activeDepth; +// Rent: take/create slot[_activeDepth], then increment depth. +// Return in finally: release document references, decrement depth, retain only a bounded number of slots. +// Dispose overflow slots: return input/output/token rentals through explicit ownership-aware cleanup. +// Cache two recent reader-owner pairs; use a bounded policy, never an unbounded serializer dictionary. +``` + +An alternative is to dispose every temporary Scratch; that saves lost rentals but still rents and allocates objects per nested call. +**Gain:** **0 ns on the non-reentrant sync benchmark**; potentially several microseconds and about 10 KiB per repeated nested request, estimate. +**Risk:** medium: nested calls must never share a live reader/output; foreign custom readers have only the public Release contract. +Keep disposal distinct from reusable Release, and do not invalidate spans before the outer invocation finishes. +**Affected:** all processor overloads, serializer selection, synchronous nested methods, standalone built-in reads, and converter reentrancy. +**Verify:** repeated depth-2/depth-4 calls, alternating serializers, exceptions at each depth, context restoration, +and allocations/retained heap after a large request followed by small requests. + +### P8 — Remove reflection invocation and per-entry arrays from dictionary mapping + +**Location:** `Json-Rpc/Jsmn/JsmnMapper.cs:438,636,650,700`. +**Now:** every dictionary entry invokes `MethodInfo.Invoke(d, new[] { k, v })`. +The generic dictionary fallback writer constructs reflection metadata and reads Key/Value reflectively while enumerating. +**Cost:** one two-reference array per inserted entry (estimate 40 B on x64), plus existing value boxing and reflection overhead. +**Change:** compile the dictionary Add call once, as the list path already does: + +```csharp +var target = Expression.Parameter(typeof(object), "dictionary"); +var key = Expression.Parameter(typeof(object), "key"); +var value = Expression.Parameter(typeof(object), "value"); +plan.DictionaryAdd = Expression.Lambda<Action<object, object, object>>( + Expression.Call(Expression.Convert(target, addMethod.DeclaringType), addMethod, + Expression.Convert(key, plan.KeyType), Expression.Convert(value, plan.ElementType)), + target, key, value).Compile(); +``` + +Use a once-closed generic dictionary enumerator helper for the fallback, instead of repeated `MakeGenericType`/`GetProperty`/`GetValue`. +That helper may still allocate an iterator and box value-type keys/values; claim only the reflection eliminated. +Ordinary `Dictionary<K,V>` writing takes the earlier non-generic `IDictionary` branch, so it does not benefit from changing only `EnumerateDictionary`. +**Gain:** **0 ns on the scalar sync benchmark**; 40 B per inserted entry removed, and estimate tens to hundreds of ns per entry. +**Risk:** low to medium; preserve duplicate-key behavior and exception-to-wire mapping, which may depend on reflection exception wrappers. +**Affected:** `ReadObject` → `ReadDictionary` → `TypePlan.DictionaryAdd`; dictionary result fallback → `TypePlan.Enumerate`. +**Verify:** dictionaries with reference/value keys and values, interface/read-only declarations, duplicate keys, +custom Add implementations throwing, and byte-identical error data with details enabled/disabled. + +### P9 — Return identity maps for ordered named parameters + +**Location:** `Json-Rpc/Handler.cs:547,574,631`; `Json-Rpc/Invocation/RpcMethod.cs:171`. +**Now:** positional exact matches return a cached identity array; all named calls rent a map and scan names quadratically. +Missing positional defaults also rent/fill an array even though the map depends only on supplied count. +**Cost:** pool bookkeeping and repeated name comparisons; escaped parameter names can be decoded repeatedly. +**Change:** recognize the common ordered-names case before renting: + +```csharp +if (given == expected) +{ + int p = 0; + for (; p < expected; p++) + if (!reader.ParamNameUtf8(p).SequenceEqual(parameters[p].NameUtf8)) break; + if (p == expected) return method.IdentityMap; +} +// Existing fully validated reordered/unknown/duplicate-name path remains. +``` + +Do this only for methods whose registered JSON parameter names are unique; establish that flag at registration. +For high arities, build a per-method UTF-8 name-to-index table and visit each supplied name once. +For defaults, optionally cache maps by valid supplied count; `ReturnMap` must distinguish borrowed maps from pool rentals. +**Gain:** **0 ns on the five positional benchmark requests**; estimate 10–35 ns on small ordered named requests, 0 steady-state B saved. +**Risk:** medium: unknown/repeated names and missing required parameters must keep their current errors. +**Affected:** both `HandleRequest` and `InvokeBoxed`; `ReturnMap` and compiled map consumers must agree on ownership. +**Verify:** named-order permutations, custom names, duplicates, escaped names, defaults, and same-thread reentrancy. +Do not stackalloc a map and then call `.ToArray()` to satisfy `StreamingInvoker`; that introduces a real allocation. + +### P10 — Remove or replace STJ's globally shared one-entry type-info cache + +**Location:** `AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs:66,85,259`. +**Now:** `TypeInfo<T>._last` is one process-wide options/info pair; a miss creates a new Entry. +Two serializers with different options repeatedly replace it, including when different threads each use one stable serializer. +**Cost:** estimate 32 B per missed Entry, redundant lookup, and cross-thread writes to a shared cache line. +**Change:** first compare using STJ's own options cache directly: + +```csharp +public override T Read<T>(ReadOnlySpan<byte> json) + => JsonSerializer.Deserialize<T>(json, _options); +// In Write<T>, retain RentWriter/Flush/ReturnWriter and replace only the Serialize call: +JsonSerializer.Serialize(writer, value, _options); +``` + +This deletes private `TypeInfo<T>`/Entry. A bounded per-thread two-entry cache is the alternative if homogeneous traffic measurably regresses. +The separate one-writer-per-thread cache also recreates writers when options alternate; evaluate a two-slot writer cache only after measuring that workload. +**Gain:** **0 ns with built-in jsmn**; 32 B per avoided type-info miss, potentially multiple misses per RPC under STJ option contention. +**Risk:** low for delegation to STJ's own cache; homogeneous STJ traffic could lose a few ns. +**Affected:** only generic STJ Read/Write call sites; non-generic reads/writes already use `_options` directly. +**Verify:** one serializer, two options alternating on one thread, and two independent option sets on two threads. +Retain failed-write detachment and reentrant-writer behavior; the previous review's writer fix must remain intact. + +### P11 — Reduce oversized string reservations and avoid small escape rentals + +**Location:** `Json-Rpc/Serialization/Utf8Json.cs:199,426`; `Json-Rpc/Jsmn/JsmnRequestReader.cs:263`. +**Now:** writing N UTF-16 characters requests `6*N+2` contiguous bytes even for plain ASCII. +Decoding every escaped string rents a byte array; normalizing a single-quoted ID creates a writer object and a decoded string. +**Cost:** a 1 MiB ASCII result requests about 6 MiB capacity, with pool rounding and long-lived scratch retention. +Small escaped values pay Rent/Return even when scratch would fit on the stack. +**Change:** keep the tiny-string loop, but add bounded chunk writing for long strings, preserving surrogate pairs across chunks. +For small escaped decoding on modern targets, use: + +```csharp +if (contents.Length <= 256) +{ + Span<byte> bytes = stackalloc byte[256]; + int written = Unescape(contents, bytes); + return Encoding.UTF8.GetString(bytes.Slice(0, written)); +} +// Existing pooled fallback; use the compatible decoding path on netstandard2.0. +``` + +Normalize lenient IDs directly into reusable dedicated ID storage, preserving its independent lifetime. +Do not reuse `_scratch` for IDs: method/name decoding must not overwrite the response's retained ID span. +**Gain:** headline credit **0 ns**; “Foo” has no escape and already fits the small writer buffer. +For long ASCII output, up to roughly 5*N bytes of unnecessary requested capacity avoided; the CLR result string remains. +For lenient IDs, eliminate a writer object and transient decoded string after capacity warm-up; estimate 32 B plus string size. +**Risk:** medium: UTF-16 surrogates, escapes, exact lowercase hex spellings, and output-span invalidation on growth. +**Affected:** all built-in string results, error text, precomputed property names, ID normalization, and DecodeString callers. +**Verify:** boundary-length Unicode/escape cases, exact-sized writers, long ASCII then tiny requests, and escaped method/parameter names with lenient IDs. + +### P12 — Carry framing state across incomplete pipe reads + +**Location:** `Json-Rpc/Serialization/JsonFramer.cs:18`; `AustinHarris.JsonRpc.AspNetCore/JsonRpcConnectionHandler.cs:31`. +**Now:** every incomplete read restarts depth/string/escape scanning at the retained document's beginning. +**Cost:** for an N-byte document arriving in k equal fragments, about N*(k+1)/2 bytes are inspected instead of N. +This develops the prior review's open performance question; it is not a repeated parser-correctness finding. +**Change:** add an internal incremental framing state for the connection while keeping the public stateless helper: + +```csharp +internal struct FrameScanState +{ + internal long Scanned, StartOffset; + internal int Depth; + internal bool Started, InString, Escaped; +} +// Scan only buffer.Slice(state.Scanned), recording bytes scanned and lexical state. +// On a full document: return its slice, advance buffer, reset state for the remainder. +// On an incomplete document: retain its bytes; carry offsets/state, not borrowed spans, across await. +``` + +Reset offsets relative to the new retained buffer after `AdvanceTo`; test empty/multiple segments explicitly. +Add a single-segment fast path using a local span/index if profiling shows sequence navigation is significant. +**Gain:** **0 ns in-process**; approximately (k+1)/2-fold fewer scanned bytes for equal-sized fragments, +but likely only 0–5% TCP throughput on the current small pipelined workload, estimate. +**Risk:** medium/high: segment boundaries, escaped quotes split across reads, completion/cancellation, and whitespace accounting. +**Affected:** raw `JsonRpcConnectionHandler` only; HTTP waits for the complete body and does not call JsonFramer. +The benchmark's `ResponseCounter` is already incremental, but validates a different contract and is not a drop-in server framer. +**Verify:** every split point in a request, byte-at-a-time 64 KiB input, several documents per read, quoted brackets, +and bounded incomplete-frame handling. Preserve strict raw-connection framing and MaxRequestBytes enforcement. + +### P13 — Avoid the HTTP counting wrapper when the BodyWriter exposes its count + +**Location:** `AustinHarris.JsonRpc.AspNetCore/JsonRpcEndpoint.cs:58,83`. +**Now:** every POST allocates `CountingBufferWriter` solely to distinguish zero output from a response. +**Cost:** estimate 32 B/POST and a forwarding interface layer; at batch size 100 this is only 0.32 B/RPC. +**Change:** use capability-checked unflushed-byte accounting, with the current wrapper as fallback: + +```csharp +var body = http.Response.BodyWriter; +long written; +if (body.CanGetUnflushedBytes) +{ + long before = body.UnflushedBytes; + JsonRpcProcessor.Process(session, in buffer, body, context, options.Serializer); + written = body.UnflushedBytes - before; +} +else +{ + var counting = new CountingBufferWriter(body); + JsonRpcProcessor.Process(session, in buffer, counting, context, options.Serializer); + written = counting.Written; +} +``` + +Check the count before the endpoint flush. The API is present in the installed reference pack; +unsupported writers require the fallback. [PipeWriter.UnflushedBytes](https://learn.microsoft.com/en-us/dotnet/api/system.io.pipelines.pipewriter.unflushedbytes?view=net-10.0) +**Gain:** **0 ns in-process/TCP**; estimate 32 B/POST removed on supporting writers, likely well under 1% single-POST throughput. +**Risk:** a service that writes/flushes the HTTP response itself invalidates count-delta assumptions; define that ownership first. +If that behavior must be supported, prefer a private/internal processor result-count bridge, retaining the public void API. +**Affected:** only `JsonRpcEndpoint.HandleAsync`; wrapper construction has one local caller. Middleware/custom BodyWriters need fallback coverage. +**Verify:** notifications, error notifications, normal/error responses, batches, stream-backed BodyWriters, and preexisting buffered bytes. +Changing the wrapper to a struct while passing it as `IBufferWriter<byte>` would box it and break local count observation. + +### P14 — Decode date and character inputs without temporary strings + +**Location:** `Json-Rpc/Jsmn/JsmnMapper.cs:233,245,304`; +`AustinHarris.JsonRpc.SystemTextJson/JsonRpcConverters.cs:177,188,198`. +**Now:** char/date conversion obtains a managed string before parsing, even for common short unescaped inputs. +**Cost:** estimate 24–96 B per temporary string depending on length; DateTimeOffset's boxed mapper also boxes the value. +**Change:** on modern targets, decode common short values into a bounded stack char buffer and call the existing span TryParse overload: + +```csharp +Span<char> chars = stackalloc char[64]; +// For validated, unescaped UTF-8 with byte length <= chars.Length: +int n = Encoding.UTF8.GetChars(text, chars); +if (DateTime.TryParse(chars.Slice(0, n), CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out var value)) return value; +// Existing DecodeString/TryParse fallback for escapes, long values, and older targets. +``` + +For char, directly return a single validated ASCII byte; retain Unicode/escaped/coercion fallbacks. +Use a typed DateTimeOffset reader/writer cache only if its workload matters; this also avoids the boxed fallback. +Do not blindly replace permissive DateTime.TryParse with Utf8Parser's narrower accepted formats. +**Gain:** **0 ns on the current benchmark**, which has no date/char requests; one string per eligible parameter avoided. +**Risk:** medium: culture, Kind, offsets, DST transitions, escaped text, and accepted non-canonical dates. +**Affected:** built-in typed/boxed mapping and STJ wire converters, including nested POCO members. +**Verify:** cross-serializer byte/value parity for UTC/local/unspecified, trimmed fractions, positive/negative offsets, +single UTF-16 code units, surrogate pairs, escaped strings, and permissive date spellings. + +## 3. Sealing and layout + +### Complete disposition of non-static production reference types + +Scope here is the core and the three companion packages requested, including private nested helpers. +Tests, benchmark fixtures, generated types, and the separate legacy 1.x projects are not a public sealing migration. +No local subclass was found for the unsealed concrete types below; this says nothing about external consumers. +`callvirt` in IL on a nonvirtual method is commonly a null check, not evidence of virtual dispatch overhead. + +| Type and declaration | Recommendation | Local callers/derivers affected | +| --- | --- | --- | +| `Handler`, `Handler.cs:12` | Seal safely: its only instance constructor is private. Expect negligible gain; instance request methods are already nonvirtual. | Processor, Config, ServiceBinder, JsonRpcService, SMD, context helpers, hosts, benchmarks, tests. | +| `JsonRpcContext`, `JsonRpcContext.cs:12` | Seal safely: private constructor. Make Value get-only. | Current constructs it; service/tests and HTTP context users consume it. | +| `SMD`, `SMDService.cs:14` | Technically sealable; public inheritance compatibility decision, no meaningful hot-path gain. | Handler construction/MetaData, dispatch-hardening tests. | +| `SMDService`, `SMDService.cs:290` | Same public compatibility decision; retain as reference object. | SMD registration/collection, Handler resolve, mutation tests. | +| `SMDResult`, `SMDService.cs:358` | Technically sealable; startup metadata only. | SMDService constructor and metadata serialization. | +| `ParameterDefaultValue`, `SMDService.cs:373` | Technically sealable; startup metadata only. | SMDService constructor/defaultValues and metadata serialization. | +| `SMDAdditionalParameters`, `SMDService.cs:391` | Technically sealable; retain mutable metadata shape. | SMD/SMDService/SMDResult construction and recursive type description. | +| `JsonRequest`, `JsonRequest.cs:7` | Technically sealable; recommend retain compatibility. | Handler/hooks, Config delegates, old client sources, protocol/serializer tests. | +| `JsonResponse`, `JsonResponse.cs:6`, and `JsonResponse<T>`, `:20` | Technically sealable; no normal streaming-path allocation to remove. | Handler/post hooks, old clients, tests, generic client responses. | +| `JsonRpcException`, `JsonResponseErrorObject.cs:30` | Do not seal for speed; application exception derivation is a reasonable extension point. | Service methods, Handler mapping, processor errors, tests. | +| `JsonRpcBindException`, `Serialization/JsonRpcSerializer.cs:176` | Technically sealable; preserve custom serializer exception compatibility. | Mapper, Utf8Json, JsmnSerializer, Handler type tests, hardening tests. | +| `JsonRpcStateAsync`, `JsonRpcStateAsync.cs:10` | Technically sealable; compatibility-only, no sync benchmark gain. | Processor continuation, Newtonsoft forwarding helpers, classic ASP.NET handler. | +| `InProcessClient`, `Client/InProcessJsonRpcClient.cs:10` | Obsolete wrapper; sealing/static conversion is an API decision, not a performance project. | No local call to Invoke; public external callers remain possible. | +| `JsonRpcOptions`, `AspNetCore/JsonRpcOptions.cs:8` | Technically sealable; preserve options extensibility unless a major API cleanup is approved. | DI/options configuration, endpoint, connection handler, host tests. | +| `JsonRpcConnectionHandler`, `AspNetCore/JsonRpcConnectionHandler.cs:16` | Technically sealable; overriding OnConnectedAsync may be useful. At most saves dispatch once per connection. | DI registration, Kestrel UseConnectionHandler, both transport benchmarks, host tests. | +| `JsonRpcService`, `JsonRpcService.cs:6` | Must remain abstract/unsealed. | Calculator/test/Wasm/application services derive from it. | +| `JsonRpcSerializer`, `Serialization/JsonRpcSerializer.cs:17` | Must remain abstract/unsealed. | Three built-in adapters plus external/custom serializers; tests exercise extension behavior. | +| `JsonRpcRequestReader`, `Serialization/JsonRpcSerializer.cs:120` | Must remain abstract/unsealed despite one shipped implementation. | Serializer.CreateReader extension contract and processor/handler/invoker consumers. | + +Already sealed: `JsmnTokenizer`, `JsmnRequestReader`, `JsmnSerializer`, `PooledByteBufferWriter`, +`Utf8KeyTable<T>`/its Entry, `ExceptionInfo`, `RpcMethod`, `RpcParameter`, `SMDServiceCollection`, +both RPC attributes, `JsmnMapper.MemberPlan`/TypePlan, processor Scratch, and Handler.InvocationState. +Also sealed: `SystemTextJsonRpcSerializer`, its DiscardingBufferWriter and TypeInfo Entry, +all five public STJ converter/factory classes, and all eleven private numeric converter classes. +Also sealed: `NewtonsoftJsonRpcSerializer`, its Scratch, `Utf8CharReader`, `BufferWriterTextWriter`, +`JsonArrayPool`, `CountingBufferWriter`, `JsonRpcServiceRegistration`, and `JsonRpcBinderHostedService`. +All other production helper classes in scope are static; there is no overlooked internal unsealed class to fix. + +### Struct decisions + +| Type | Decision and reason | +| --- | --- | +| `Utf8KeyTable.Entry` | P6's readonly struct in a flat snapshot is the strongest candidate; a struct cannot retain the current recursive Next-by-value shape. | +| `InvocationState` | A mutable thread-static struct or two thread-static references could remove one per-thread object, not a per-request allocation. Keep the class unless measured; taking a local value copy would break shared exception updates. | +| `MemberPlan` | Could become a readonly struct with six reference fields, but copies are large and FindMember currently uses null as its miss sentinel. Prefer immutable sealed class first; benchmark array locality before changing representation. | +| `RpcParameter` | Could be an internal readonly value record, but public Parameters exposes RpcParameter[] and a class-to-struct change is breaking. Startup-only allocation; do not prioritize. | +| `TypePlan` | Keep sealed class: large, shared, cached, reference-rich plan. A struct would copy many fields and complicate atomic cache publication. | +| `JsonRpcContext` | A readonly struct could save the estimated 24 B allocated by each Current call; it changes public type/null/identity semantics. Needs decision; Handler.RpcContext already provides an allocation-free object accessor. | +| `SMDResult`, `ParameterDefaultValue` | Plausible tiny readonly structs in a new internal metadata model, but public class/array/serialization compatibility makes conversion unjustified here. | +| `CountingBufferWriter` | Do not convert at the existing interface boundary; use P13 or a by-ref generic internal pipeline. | +| `JsonRpcServiceRegistration` | Keep class: DI's class-constrained registration and boxed service storage remove the practical benefit of a struct. | +| STJ `TypeInfo<T>.Entry` | Keep immutable class if retained: options/info are published atomically as one reference. A mutable two-field struct is not an equivalent publication mechanism. | +| Scratch/readers/tokenizer/writers | Keep reference types: reusable ownership, shared state, virtual/interface contracts, and async transport storage. | +| `JsmnCursor`, `JsmnMapper.cs:16` | Already a ref struct. Existing public mutable fields/ref delegate signature rule out a silent readonly conversion. Internal code does not need heap storage. | +| `JsmnToken`, `JsmnTokenizer.cs:22` | Already a struct; must remain mutable while parsing sets End, Size, and flags. | +| DTOs/errors/options/services | Keep classes: mutation, polymorphism, application identity, or exception inheritance is part of their role. | + +Token layout deserves a separate experiment: four ints followed by Type/Escaped/IsKey imply a 20-byte +sequential layout rather than the present likely 24 bytes with padding. Confirm with `Unsafe.SizeOf<JsmnToken>()`. +That saves roughly 256 bytes per 64-token array and 16.7% token-array traffic, not 16.7% request time. +Reordering this **public** struct's fields can affect interop/layout consumers; flag it under Needs decision. +Do not add a subtree-end int casually: it can erase the packing win and adds stores to every token. +`Skip` and `JsmnCursor.Next` revisit subtrees, but for tiny scalar requests a side index may cost more than it saves. +For deeply nested/large structured parameters, benchmark an internal end-index sidecar before changing the public token. + +### Fields that should become readonly + +These are all remaining clear production field candidates in scope; constructor refactoring is required where noted. +Readonly protects the reference, not the contents of an array/dictionary or the thread safety of its elements. + +| File:line | Fields / change | Caller or mutation audit | +| --- | --- | --- | +| `Handler.cs:20,22` | `_sessionHandlersMaster` and `_defaultSessionId` → static readonly. | Assigned only in static Handler constructor; registry contents still mutate. Remove volatile from the immutable ID field. | +| `JsonRpcStateAsync.cs:22,23` | `cb`, `asyncState` → readonly. | Assigned only by constructor; completion reads them and changes isCompleted separately. | +| `Invocation/RpcMethod.cs:27` | `RpcParameter.NameUtf8` → readonly through an internal constructor. | Build initializes it; Handler.BindMap/diagnostics only read. Keep public construction compatibility. | +| `Serialization/Utf8KeyTable.cs:15–18` | Entry Key, Hash, Value, Next → readonly constructor fields if retaining linked entries. | Insert constructs complete nodes; nodes are never edited after publication. P6 supersedes this layout. | +| `Jsmn/JsmnMapper.cs:581–586` | MemberPlan Name, NameUtf8, NameJson, Type, Get, Set → readonly constructor fields. | MakeMember is the only initializer; FindMember/ReadPoco/WriteObject read them. | +| `Jsmn/JsmnMapper.cs:593–603` | TypePlan Kind, ElementType, KeyType, Members, ReadableMembers, Create, Add, DictionaryAdd, Enumerate → readonly after Build finishes. | Build currently mutates a temporary plan; construct complete plans per branch. Remove unused Type rather than making it readonly. | + +Constructor-only auto-properties can also become get-only: Handler.SessionId, JsonRpcContext.Value, +SMDService.Method/transport/envelope/returns/parameters/defaultValues, SMDResult.__type, +ParameterDefaultValue.Name/Value, and RpcMethod's metadata/invoker/IdentityMap properties. +RpcParameter.Name/Type/HasDefault/DefaultValue can likewise become get-only through an internal constructor; +Build is their only initializer. Retain the existing public parameterless constructor for compatibility. +Preserve public setters and public mutable fields such as SMDService.dele, SMD metadata, DTOs, exceptions, +JsonRpcOptions, and JsmnToken/JsmnCursor. Removing those writes is an API change, not a readonly annotation. +Leave buffer arrays, cache-owner fields, thread-static slots, tokenizer counters/configuration, hooks, +InvocationState fields, CountingBufferWriter.Written, and async completion state mutable. +Existing literal arrays, serializer settings references, mapper cache dictionaries, encoder, DI dependencies, +and singleton converters/pools are already readonly/static readonly. + +### Virtual and interface calls on the request path + +Processor reader calls: TryParse, IsBatch, Count, Release; Handler calls: Select, IdKind, IdRaw, +VersionKind, HasMethod, ParamsKind, ParamCount, MethodUtf8, and named ParamNameUtf8. +All can have an internal exact-JsmnRequestReader path; their public abstract contract must remain. +`ReadParam<T>` and `serializer.Write<T>` are P1's high-value calls; boxed ReadParam/ParamsValue, +IdValue, Method, and non-generic serializer calls belong mainly to hooks, errors, or compatibility paths. +`Lenient` is virtual once per parse; MaxDepth is read when constructing the default reader, not per parameter. +`CreateReader` is normally a cache miss operation; optimize churn under P7 rather than devirtualizing startup work. +`IBufferWriter.GetSpan/Advance` are P3; GetMemory matters to adapters/transport, not the built-in numeric writer. +PipeReader.ReadAsync/AdvanceTo and PipeWriter.FlushAsync remain legitimate polymorphic transport boundaries. +Newtonsoft's TextReader/TextWriter and STJ's converter calls belong to those libraries' extension contracts. +Do not bypass configured converters merely because their default implementation is sealed. +Reference-type generic constraints alone do not guarantee specialization; exact receiver types/direct helpers do. +PGO may already guard and inline virtual/interface/delegate calls, so verify residual calls in optimized disassembly. +This is consistent with the runtime team's discussion of guarded devirtualization and bounds checks. +[.NET 10 performance engineering](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/) + +## 4. Memory and stack + +### Remaining allocation inventory + +| Request shape | Allocation site and shape | Action / limit | +| --- | --- | --- | +| Four numeric sync shapes | No steady-state managed allocation expected from the built-in path after warm-up. | Preserve the README's zero; startup token/plan/delegate objects do not count as per request. | +| `StringMe("Foo")` | `JsmnMapper.ReadString:141` → Encoding.GetString; one three-character string, estimate 32 B. | The application signature requires a CLR string. No unsafe raw-byte echo shortcut. Mixed five-shape average is about 6.4 B/RPC, not zero. | +| String processor API | `JsonRpcProcessor.ProcessSync:155` creates response string; pooled input transcodes request. | Byte API already avoids the response string. | +| Standalone serializer string adapters | `JsonRpcSerializer.cs:49–79`: Deserialize allocates the UTF-8 byte array; Serialize allocates a writer object and result string. | Convenience APIs, separate from streaming invocation; callers already have byte/span alternatives. | +| netstandard2.0 string decoding | `Utf8Json.ToStringUtf8:463` uses `utf8.ToArray()` before Encoding.GetString. | Extra byte array on that target; the modern span overload avoids it. Do not claim identical allocation counts across TFMs. | +| Byte-array return API | `ProcessBytes:82` → PooledByteBufferWriter.ToArray allocates byte[]. | Intentional ownership transfer; use caller writer to avoid it. | +| Task string API | `JsonRpcProcessor:123–127` creates Tuple plus Task; output string also remains. | Scheduling semantics are a decision, not part of sync tuning. | +| Legacy async-state API | Processor:100 captures async in a continuation, with delegate/closure/continuation Task. | Keep compatibility; no benefit to sync benchmark. | +| JsonRpcContext.Current | `JsonRpcContext.cs:41` creates a wrapper each call, estimate 24 B. | Service code needing only Value can use Handler.RpcContext. | +| Hooks | Handler:312/438/519 creates/materializes IDs, method strings, params, JsonRequest/JsonResponse; value results box. | Hooks intentionally select this path; do not secretly pool mutable objects handed to user code. | +| Modified hook request | Handler:468 creates a writer and reader, serializes then reparses replacement request. | Compatibility work; preserve post-hook dispatch behavior. | +| Ordinary RPC errors | JsonRpcException construction, optional formatting strings; thrown binding/method errors add stack/exception costs. | A non-thrown constructed error has different cost from throwing it. | +| Exception data | Handler:703/707 → ExceptionInfo DTO; detailed mode can create stack strings and inner chain DTOs. | Preserve redaction/details policy; do not pool exceptions shared with hooks. | +| POCO numeric fields | Mapper:474 boxes ReadObject values; :570 getter returns object and boxes value properties. | Typed member reader/writer plans could remove roughly 24 B per boxed int/double, estimate; not exercised by current benchmark. | +| `int[]`/`List<int>` parameter | Mapper:417/432 boxes each value; Array.SetValue or object-typed Add consumes it. | Typed collection factories/readers can remove boxes; result array/list itself remains necessary. | +| Value collections returned | Mapper:522/551 uses non-generic IDictionary/IEnumerable. | Enumerator and value boxing; use typed generated paths only for measured common collection shapes. | +| Dictionary parameter | Mapper:636/650 creates object[2] for each Add, in addition to keys/values/boxes. | P8. | +| Dynamic object parameter | Mapper:377/389 creates List<object>/Dictionary<string,object>, keys, boxed numbers. | Object-model semantics require these representations unless API changes. | +| Date/char/Guid/TimeSpan/base64 | Mapper:238/250/306/312/318/332; writes :513/514/516 allocate formatted strings. | P14; typed Utf8Formatter/Base64 paths are follow-ups, with exact-format tests. | +| Newtonsoft numeric read/write | New JsonTextReader at serializer:93 per parameter; object-based internal conversions and WriteCore box values. | Pooled char buffers do not make this adapter zero-allocation; retain custom settings/converters. | +| STJ mixed options | New TypeInfo Entry at serializer:269; writer recreation at :137 on option changes. | P10. | +| HTTP | CountingBufferWriter at endpoint:58; async infrastructure may allocate on suspension. | P13 removes only the explicit 32 B wrapper estimate. | +| Batch | Reader index arrays grow at RequestReader:87/195; token/output arrays grow with demand. | Usually warm allocations, not one DTO per member. Large token arrays >4096 are deliberately released after each document. | +| Reentrant/serializer-switch calls | Scratch:251/276, fresh JsmnTokenizer, reader arrays and rentals. | P7. | + +The processor retains input/output capacities per thread; the reader retains parameter/request/name/ID arrays. +JsmnTokenizer.Release shrinks unusually large token/stack storage, but JsmnSerializer.ReturnTokenizer does not call Release. +Choose a documented high-water retention policy before adding pools; “return to ArrayPool” does not mean process memory immediately shrinks. +Do not put request objects/strings in global caches to manufacture zero-allocation benchmark results. + +### Stack, spans, bounds, delegates, and diagnostics + +- Stackalloc is appropriate for P11's bounded escape buffer and P14's short char buffer; never size it directly from arbitrary request length. +- Existing STJ numeric/date buffers are only 35–48 bytes and WriteChar uses one char; these are appropriate stack allocations. +- A stack parameter map requires an internal span-aware delegate signature; the public int[] invoker cannot consume stack memory. +- Keep scratch per invocation depth, not a single thread-static mutable map: nested methods can invalidate their caller's map. +- A `ref readonly JsmnToken` local can avoid token copies in read-only helpers; current `in JsmnToken` Slice/RawJson already express that intent. +- Ref-returning a token across token-array growth is unsafe logically even in managed code: it points to the old array. Acquire refs after allocation/growth. +- Do not turn a reader containing ReadOnlyMemory into a ref struct: it is cached as a class and participates in virtual dispatch. +- Public span slices validate their ranges; reserve once and use a local bounded span inside tight formatting loops before considering Unsafe.Add. +- Tokenizer field-loop restructuring is P2; ParseString, hashing, and span equality already use simple local loops or runtime primitives. +- `Skip` and `JsmnCursor.Next` have matching subtree-walk algorithms; a shared helper can reduce duplication, but measure inlining before merging. +- `[SkipLocalsInit]` is not a primary recommendation. It does not eliminate heap-object or array initialization. +- For tiny numeric buffers its benefit is likely 0–2 ns per formatting call, estimate, and may be zero after JIT optimization. +- If tested, apply it to a narrow internal method on supported TFMs, prove every emitted byte initialized, and inspect assembly before/after. +- Never apply it broadly to parser state or use it to justify reading beyond the initialized prefix of a rented/stack buffer. +- RpcMethod expression closures and Jsmn nullable delegates are created at registration/type initialization, not per numeric request. +- TypePlan list/dictionary accessor closures are cached; the dictionary argument array is per entry and is the real P8 allocation. +- The sync harness has one closure/output writer per worker, not per RPC; the stop flag read and input rotation are included in elapsed time. +- SessionSelector/ContextFactory/host route delegates are cached delegates; user callback bodies may allocate, but merely invoking them does not create a closure. +- LINQ in ServiceBinder, RpcMethod reflection selection, TypePlan.BuildMembers, SMD metadata, and converter setup is cold work. +- There is no LINQ enumeration on the normal scalar Handler fast path. Do not advertise removing registration LINQ as a 227 ns improvement. +- Avoid source-level `(T)(object)value` rewrites without disassembly; generic value-type specialization may remove boxing, but reference/shared cases differ. +- Bind errors use string.Format in Handler:559/565/599/623/626/628; mapper Bind:259–260 decodes/truncates then concatenates text. +- Depth errors concatenate the depth in RequestReader:68 and JsmnSerializer:90. These matter under malformed traffic, not successful benchmark traffic. +- STJ Wire.Bind:223 copies the entire token to an array before truncating the resulting string at :231; bound diagnostic decoding first for large bad values. +- Preserve the same first 64 decoded characters and UTF-8 boundary handling if changing that diagnostic path; do not silently change error text. +- For frequent framework-generated errors without hooks, an internal `(code,message,data)` envelope helper could avoid constructing JsonRpcException. +- Keep fresh exception objects when an error hook receives them; changing identity/mutability or sharing thrown instances is not an allocation optimization. +- Strict string/integer IDs already echo raw bytes without materializing an object. The decimal/exponent ID policy must not change for speed. +- Built-in numeric parsing already uses Utf8Parser; writing already uses Utf8Formatter except the deliberate netstandard2.0 float fallback. +- Do not replace whole-float `.0` preservation with default STJ formatting, or DateTime formatting with `O`: both change bytes. +- DateTime formatting already writes directly into output and trims fractions; GetDateParts/TryFormat alternatives are secondary experiments outside the current workload. + +## 5. Conciseness + +These recommendations distinguish safe internal removal from public API decisions and preserve the hardening work. +Private/internal deletion candidates were searched with tree-sitter and cross-checked in source; public absence means only “unused locally.” + +| File:line | Remove or merge | Behavior / affected callers | +| --- | --- | --- | +| `Json-Rpc/Basic.cs:1` | Delete this wholly commented-out MetadataService file and unused usings. | No compiled behavior and no callers; the referenced Handler.Current is historical text. | +| `JsonRpcProcessor.cs:165` | Remove EmptyBatchError. | No reads; avoids one unused startup byte array. Actual empty-batch error text at :191 remains. | +| `Utf8KeyTable.cs:71` | Remove ReplaceWith. | No callers; direct service mutations now go through SMDServiceCollection and update the table immediately. | +| `Utf8KeyTable.cs:83` | Remove Clone's unused TValue argument; change its Set/Remove calls. | No behavior change. | +| `Utf8KeyTable.cs:22,25` | Remove unused exposed Count/_count if no new builder uses it; carry rebuild count locally instead. | Only assigned/read by its own unused property; no SMD consumer. CountEntries still supports current Insert until refactored. | +| `JsmnMapper.cs:594,623` | Remove TypePlan.Type and its initializer. | No reads; plan cache key is already Type. | +| `JsmnMapper.cs:739` | Remove `.Where(f => !f.IsInitOnly || true)`. | Always true; readonly fields still serialize, setter gating at :746 remains. | +| `JsmnMapper.cs:764` | Remove unused MakeMember owner argument and update BuildMembers' field/property calls. | No behavior change; MakeGetter/MakeSetter still require owner. | +| `TestServer_Console/Benchmark.cs:380` | Remove PrintFinalIterationStats. | No callers; active chart/progress output uses other methods. | +| `SMDService.cs:434` | Replace `Where(...).Count() > 0` with Any. | Equivalent startup metadata query; no request throughput claim. | +| `JsmnTokenizer.cs:487`, `JsmnMapper.cs:34` | Consider one internal token-subtree helper for Skip/Next. | Same algorithm; callers are RequestReader and mapper collection/POCO traversal. Keep public methods as forwarding wrappers. | +| `JsonFramer.cs:18,77` | Share lexical-state transitions between sequence/span scanners where it simplifies P12. | FindDocumentEnd has no local callers but is public; malformed-prefix behavior currently differs and must remain explicit. | +| `Utf8Json.cs:179`, `SystemTextJson/JsonRpcConverters.cs:313` | Share internal span-number formatting/decimal-suffix rules as part of P3. | Companion assembly access needs a deliberate internal/shared-source arrangement; do not broaden public API just to save ten lines. | +| `JsmnMapper.cs:484` | Public WriteObject's declaredType parameter is unused. | Keep public signature; remove parameter only from a new private recursive core if useful. Every recursive caller currently passes a type. | +| `PooledByteBufferWriter.cs:66` | Remove stale “unwrap a single-response batch” rationale. | RemoveAt itself has no local callers but is public; deletion needs an API decision. Batch-array behavior stays fixed. | +| `JsonRpcSerializer.cs:163`, `JsmnRequestReader.cs:298` | ParamIsNull is unused by current invokers. | Public abstract contract: do not delete without compatibility review; custom readers may implement/use it. | +| `ServiceBinder.cs:65–73`, `SMDService.cs:293` | Legacy delegate duplicates compiled invocation machinery. | Only stored in public SMDService.dele locally; retain until public compatibility decision. Removing it saves registration work, not RPC time. | +| `Client/InProcessJsonRpcClient.cs:10` | Obsolete InProcessClient forwards directly to Process. | No local callers, but published API; mark for a deliberate major-version cleanup instead of silently removing it. | +| `Config.cs`, `ServiceBinder.cs`, `JsonRpcProcessor.cs` | Keep current public overload families. | Concrete users include tests, hosts, old ASP.NET state adapter, Newtonsoft helpers, and samples. No proof that an overload is globally unnecessary. | +| `JsonRpcContext.cs:1`, `JsonRpcStateAsync.cs:1`, `Client/InProcessJsonRpcClient.cs:1` | Remove unused collections/LINQ/text/IO/threading imports as applicable. | No runtime or API behavior. | + +Comments worth trimming: ServiceBinder:29/53/58 repeat dictionary/default/return assignments; +SMDService:328/336 narrate straightforward storage construction; Benchmark:353/358/367 merely label header/fields/footer. +Keep comments explaining ID scratch lifetime, output rollback, writer detachment, nested context restoration, +single-response batch arrays, notification suppression, and overload ambiguity: those document non-obvious constraints. +Correct RequestReader base comments saying method/name bytes keep escapes: the shipped reader returns decoded UTF-8 for escaped names. +Do not remove JsonRpcRequestReader merely because it has one shipped implementation; CreateReader is an explicit extension point. +Do not merge the boxed and streaming invocation bodies wholesale: their allocation and hook semantics intentionally differ. +Do not replace the two SMD lookup representations without preserving mutable IDictionary snapshots and immediate dispatch updates. + +## 6. Things checked and found fine + +- `RpcMethod.FromMethod` uses a compiled direct call; expression construction/reflection is registration work, not per-request MethodInfo.Invoke. +- Typed numeric and nullable binding avoids object[] and numeric result boxing on the normal streaming path. +- Positional parameters with exact count use IdentityMap; default constants are prepared at registration, not converted on each request. +- `JsmnRequestReader` binds built-in parameters from existing tokens; it does not tokenize each scalar again. +- Custom serializers receive raw value slices; using a custom reader/serializer remains meaningful even with a built-in fast path. +- Most performance-relevant concrete classes are already sealed, including the tokenizer, reader, serializer, writer, and invoker metadata. +- Tokenizer now maintains an explicit open-container stack, validates grammar/UTF-8, and enforces depth; the prior quadratic closing bug is not a new finding. +- Shared safe primitives already use spans, in token parameters, ref token access, Utf8Parser, and Utf8Formatter. +- Method lookup avoids string allocation on ordinary hits; misses/custom-reader fallback may decode Method and take the locked string table path. +- ID echo bypasses numeric formatting/object conversion for valid ordinary IDs; normalized lenient IDs have independent storage. +- InvocationState is one lazy object per thread with saved/restored fields, not one newly allocated frame per RPC. +- Scratch `_inUse` protects active byte buffers from reentrancy; P7 changes reuse economics, not the need for isolation. +- Output staging is intentional: the processor can rewind a partial result after serializer/service failure before copying to the caller. +- ReadOnlyMemory and single-segment sequence input avoid input copies; span and multi-segment inputs explicitly copy into pooled contiguous storage. +- Response staging adds one output copy; deleting it would lose rollback for arbitrary IBufferWriter destinations. +- Batch punctuation/rollback is correct for one response, mixed notifications, and all notifications; preserve the previous fixes. +- SMDServiceCollection updates UTF-8 dispatch on supported mutation; do not reinstate a stale successful-lookup cache. +- Newtonsoft pools char storage and retains writer state with failure handling; it is intentionally not a zero-allocation value adapter. +- STJ failed-write detachment, user converter precedence, and immutable effective options should survive all proposed optimizations. +- DateTime output already matches the documented fractional/Kind conventions; changing to default round-trip formatting is not equivalent. +- Raw connection processing flushes once per read-loop group, not once per RPC; do not move FlushAsync into the document loop. +- HTTP consumes BodyReader input after processing, and both transports enforce request-byte limits. +- Server GC and disabled concurrent GC are explicit harness settings; compare like-for-like when testing a change. +- The sync benchmark rotates five requests equally, uses ReadOnlyMemory and reused writers, and reports allocations outside its timed loop. +- Its hot loop has no per-request response validation; responses are printed during allocation profiling. Add correctness checks outside timing when measuring changes. +- Kestrel HTTP/TCP runs include loopback/client work; prefix validation is not full result/ID validation, especially for HTTP batches. +- The 16-thread README result is aggregate throughput; 488 ns is per-thread work time, not end-to-end p99 latency. +- The README's TCP 14.3–14.8 M already exceeds a 7–10 M process-wide target; a one-thread target is a separate engineering requirement. +- Native simdjson adoption remains unsupported by the repository's own evaluation; no new dependency is justified by this review. + +Verification performed: + +```text +git branch --show-current / git rev-parse --short HEAD + finish-netstandard-upgrade / ccce23c +git log --oneline -40 + Read, including parser/dispatch/serializer hardening and subsequent benchmark changes. +dotnet build AustinHarris.JsonRpc.sln -c Release + Exit 1; printed 0 warnings, 0 errors, no actionable diagnosis. +dotnet build AustinHarris.JsonRpc.sln -c Release --no-restore -m:1 -v:minimal + Core/serializer package assets, AspNetCore, tests and TestServer_Console compiled. + Solution failed in samples/WasmHost: MSB4216 ComputeWasmBuildAssets task host, + followed by MSB4027 disposed MetadataLoadContext; 10 warnings, 2 errors. +dotnet test AustinHarris.JsonRpcTestN -c Release --no-build --no-restore -f net8.0 -v:minimal + Passed 745; failed 0; skipped 0. +dotnet test AustinHarris.JsonRpcTestN -c Release --no-build --no-restore -f net10.0 -v:minimal + Passed 745; failed 0; skipped 0. +dotnet run -c Release --no-build --project TestServer_Console -- --sync 2 + Stopped before timing: System.Management.ManagementException, Access denied, + in Hardware.Info at Program.cs:19. +dnx dotnet-inspect -y -- member System.IO.Pipelines.PipeWriter --aspnetcore --oneline -30 + Could not load NuGet service index; used installed reference XML and official docs instead. +``` + +No code was changed for tests, benchmarks, instrumentation, or build workarounds. +The successful tests establish current regression coverage, not that the proposed changes work or meet their estimates. +I did not measure new throughput, emitted machine code, cache misses, p99 latency, or per-shape CPU attribution. +For implementation verification, use a dedicated machine/run window; warm each path, randomize A/B order, +report medians/spread, allocations and code size, and retain unmodified baseline output bytes as fixtures. +Do not repeatedly select the best `--sync 2` run or claim transport throughput scales linearly with core nanoseconds saved. + +## 7. Needs decision + +1. **Target definition:** 7–10 M per process is already demonstrated; 7–10 M per thread requires 143–100 ns/RPC. + Recommendation: accept a first milestone of roughly 5–6 M/thread, then profile the remaining budget before attempting a scanner/dispatch fusion. +2. **Specialization versus code size:** one built-in fast path can reduce indirection; a cross-product of serializers, arities, and types will grow maintenance/JIT cost. + Recommendation: P1 for existing primitive shapes, fallback for everything else; keep the public serializer/reader contracts. +3. **Public sealing/removal:** externally derivable classes and locally unused helpers can still have consumers. + Recommendation: seal only private-constructor Handler/JsonRpcContext now; defer other public sealing, class-to-struct conversions, and helper removals. +4. **Token ABI/layout:** packing JsmnToken can improve density; changing a public sequential struct's layout is observable outside managed field access. + Recommendation: benchmark a private representation or obtain an explicit layout-compatibility decision before reordering fields. +5. **Struct context versus wrapper compatibility:** readonly JsonRpcContext would remove per-access allocation but change reference/null semantics. + Recommendation: preserve Current's API and document/use Handler.RpcContext for latency-sensitive service code. +6. **Scratch retention:** nested reuse and two recent serializer slots improve repeat workloads but retain more memory per worker. + Recommendation: bounded depth/cache count and measured capacity trimming; no unbounded per-thread serializer dictionary. +7. **Notification result serialization:** Handler:355–379 currently serializes a result then rewinds it for a notification. + Recommendation: consider an internal invoke-with-discard delegate for notification-heavy traffic only after deciding whether result getters/converters and serialization-error hooks must still execute. + The wire remains empty either way, but observable application side effects differ; no gain is included in the sync estimate. +8. **HTTP output ownership:** P13 assumes the endpoint owns writes/flushes while processing a request. + Recommendation: if services may manipulate HttpResponse directly, retain the wrapper or add an internal written-count bridge without changing public JsonRpcProcessor signatures. +9. **Task overload scheduling:** Task.FromResult(ProcessSync(...)) would remove a hop but run service code synchronously on the caller and change exception/scheduler behavior. + Recommendation: keep existing scheduling; use the existing byte/sync APIs where the host already owns execution. Any public Processor/Config/ServiceBinder API change requires a separate decision. +10. **Public extension points versus conciseness:** removing JsonRpcRequestReader, legacy invoker delegates, or ServiceBinder/Config overloads would simplify code but break real integration contracts. + Recommendation: remove the verified private/internal dead code first; do not trade public behavior for cosmetic shrinkage. +11. **Wire compatibility:** numeric suffixes, non-finite quoting, DateTime Kind/fractions, ID bytes, error data, hook mutations, and batch envelopes stay fixed. + Recommendation: every fast path falls back when it cannot prove equivalence; none of the headline estimates assumes a wire-format change. + +Only this review deliverable was authored by this reviewer. No commits, stash, branch changes, +Linear writes, knowledge-graph writes, messages to other agents, or production-source edits were performed. + +## 8. Implementation record (2026-09-23, same branch) + +What landed from this review, measured with `TestServer_Console --sync 2 1` (seven runs each, sorted; the machine was +about 15 % slower than when the README tables were taken, so only the A/B is meaningful): + +| Step | 1-thread RPC/s, seven runs | +| --- | --- | +| baseline (ccce23c) | 3.20, 3.39, 3.42, 3.61, 3.87, 3.90, 4.14 M | +| + sealing/readonly/dead code, P1, P3, P4, P5, P6, P8, P9 | 3.66, 3.77, 3.89, 3.92, 3.94, 3.95, 4.16 M | +| + P2 (tokenizer with local scanner state) | 4.33, 4.42, 4.45, 4.52, 4.52, 4.61, 4.64 M | +| + hash over 8-byte words, bounded string reservation, nested-scratch return | 3.99, 4.19, 4.28, 4.30, 4.35, 4.42, 4.48, 4.68, 4.78 M (nine runs; noise, not a regression) | + +Tests: 745/745 on net8.0 and net10.0 after every step; all four 2.0.0 packages pack. + +- **P1 + P3 done.** `RpcMethod` compiles a third invoker (`JsmnInvoker`) used when the reader is the built-in + `JsmnRequestReader` owned by `JsmnSerializer`: parameters are read through static helpers on the reader + (`ReadInt32Param` and friends, nullable primitives as a null test plus the value read, everything else through + `JsmnReader<T>`), and the result is written through new `Utf8Json` overloads that take the concrete + `PooledByteBufferWriter`. The point is that expression-compiled delegates are dynamic methods, which get no tiered + compilation and therefore no PGO devirtualization, so every virtual, delegate and interface call inside them was a + real indirect call. The public `StreamingInvoker` path is unchanged and still serves custom serializers and + readers. `WriteNull`'s reflection lookup now names its parameter type. +- **P2 done.** `JsmnTokenizer.Parse` keeps position, token count, parent, depth and the arrays in locals and publishes + them once on exit; the string, primitive and bare-key scanners are static and return an end index or an error; + token growth is a cold method; digit tests use `(uint)(b - '0') <= 9`. Grammar, UTF-8, escape, surrogate and depth + checks are unchanged. This was the largest single gain. +- **P4 done.** Thread-local last-session-id/handler pair by reference, dropped whenever the local snapshot is rebuilt. +- **P5 done.** Envelope keys are selected by length (and first byte for the two six-byte names) before comparison. +- **P6 done.** `Utf8KeyTable` is one immutable snapshot: `int[]` bucket heads into a contiguous `Entry[]` of readonly + structs; mutations rebuild and publish a new snapshot, no per-entry objects, no repeated `CountEntries`. +- **P8 done.** Dictionary `Add` is compiled once per plan; no `MethodInfo.Invoke` and no `object[2]` per entry. +- **P9 done.** Named parameters supplied in declaration order return the identity map, gated by a per-method + `HasUniqueNames` flag set at registration; every other named case keeps the validated map. +- **Hash.** `Utf8Json.Hash` now consumes eight bytes per step (FNV-style multiply-xor over 64-bit words) with a byte + tail; the method table is the only consumer. +- **P11 partly.** Strings longer than 512 chars are written in chunks (never splitting a surrogate pair), so the + worst-case six-bytes-per-char reservation is bounded; short strings keep the single reservation. +- **P7 partly.** A nested (re-entrant) scratch returns its input array and pooled writer when released instead of + leaving them to the GC. No per-thread stack of scratches. +- **Sealing and layout.** `Handler` and `JsonRpcContext` are sealed (private constructors, nothing derives). Other + public classes stay open, per section 7 item 3. `JsmnToken` puts its three byte-sized fields first, 24 -> 20 bytes; + this reorders a public struct's fields, which only matters to unsafe or interop code, of which there is none. +- **Readonly.** `Handler._sessionHandlersMaster` and `_defaultSessionId` are static readonly; `JsonRpcStateAsync` + callback/state and `RpcParameter.NameUtf8` (now set by an internal constructor) are readonly; `TypePlan.Type` is gone. +- **Conciseness.** `Basic.cs` (commented-out class), `EmptyBatchError`, `Utf8KeyTable.ReplaceWith`/`Count`, the + always-true `Where`, `MakeMember`'s owner argument, `PrintFinalIterationStats`, unused usings and the comments that + restated the code are removed; `Where(...).Count() > 0` is `Any`; the reader base comments say names are decoded. + Public members flagged as locally unused (`RemoveAt`, `ParamIsNull`, `FindDocumentEnd`, `InProcessClient`, the legacy + `dele` delegate, the overload families) are kept, per section 7 item 10. +- **Not done, and why.** P10 (STJ one-entry type-info cache): a miss costs one small allocation only when two options + instances alternate; left for a decision. P12 (incremental framer state): transport-level, small documents complete + in one read, not worth the state machine yet. P13 (`PipeWriter.UnflushedBytes`): the wrapper costs two interface + calls per HTTP document, below noise. P14 (date/char decode without temporary strings): not on the benchmark path. + The reader's virtual members in `Handler.HandleRequest` are left virtual: that method is tiered code where dynamic + PGO already guards and inlines the single implementation, and a concrete duplicate of the dispatch would cost more + in maintenance than the type check it saves. diff --git a/docs/reviews/2026-09-23_codex-astra-review-2.0.md b/docs/reviews/2026-09-23_codex-astra-review-2.0.md new file mode 100644 index 0000000..af90753 --- /dev/null +++ b/docs/reviews/2026-09-23_codex-astra-review-2.0.md @@ -0,0 +1,439 @@ +**Do not ship** — The shared parser permits unbounded work and invalid wire data, and reproduced failures in dispatch, hooks, and serializer reuse remain outside the passing suite. + +# Independent review of the 2.0 rewrite + +Reviewed branch: `finish-netstandard-upgrade`, initially `8301ff5`, against `master`. +Original scope: 14 commits, 58 changed files, 8,222 insertions and 834 deletions. +During review the checkout advanced through four additional benchmark commits to `3621c76`. +I also read that delta: README, console benchmark, and console GC settings; none of the findings' source files changed. +Final inspected scope: 18 commits, 58 changed files, 8,363 insertions and 834 deletions. +Review date: 2026-09-23; finding line numbers apply at both revisions. + +I read the branch changes file by file, including the required core files, companion packages, +documentation, tests, project files, workflows, and benchmark changes. +I used tree-sitter symbol searches, usage tracing, and structural analysis, and queried Memory +collections `json-rpc.net_code` and `json-rpc.net_docs` for design context. +The findings below are grounded in the checked-out source and local execution, not inferred from those indexes. +Some protocol behaviors are deliberately inherited; they are identified as compatibility decisions. + +Reproduction conventions: `ping()` returns `7`, `echo(string s)` returns `s`, +`accept(object o)` returns `7`, and `optional(int a = 9)` returns `a`. +These were registered in an isolated temporary session. +“All three” means built-in jsmn, Newtonsoft, and System.Text.Json with their default settings. +Temporary repro tests were removed after execution; their essential inputs and observations follow. + +## Findings, ranked by severity + +### 1. blocker — Unbounded nesting makes parsing quadratic and leaves recursive binding unprotected + +Location: `Json-Rpc/Jsmn/JsmnTokenizer.cs:107`; related `Json-Rpc/Jsmn/JsmnMapper.cs:380`. +Verification: **verified by test** for accepted depths and timing; **verified by reading** for the unbounded recursion. +Claim: A small, deeply nested request can consume disproportionate CPU before method dispatch, and the built-in reader has no input recursion limit. + +Repro: send `{"method":"ping","unused":` followed by N opening brackets, `0`, +N closing brackets, and `,"id":1}`. +Depths 1,000, 2,000, 4,000, and 8,000 all returned success; observed Debug times were approximately +2.6, 5.5, 23.3, and 98 milliseconds, respectively, for at most about 16 KB of input. +Each closing delimiter restarts from the last token and walks its parent chain; deeply nested input therefore takes quadratic work. +The shared envelope parser exposes every serializer to this behavior, including nesting in unused members. +An `accept(object)` call also accepted 1,024 nested arrays; `ReadDynamic` recursively descends them without a guard. +`MaxDepth = 64` in the mapper protects writing only, so sufficiently deep binding can exhaust the process stack. +I did not deliberately crash the test runner with a stack overflow. + +Suggested fix: enforce a documented nesting and token budget during tokenization, before hooks or binding; +close containers through the current open-container chain without rescanning closed descendants; +and apply a consistent recursion limit to every recursive reader. +The transport byte limit alone does not bound this CPU cost. + +### 2. major — The shared envelope parser accepts invalid JSON and echoes invalid UTF-8 + +Location: `Json-Rpc/Jsmn/JsmnTokenizer.cs:157`, `Json-Rpc/Jsmn/JsmnTokenizer.cs:227`, `Json-Rpc/Jsmn/JsmnTokenizer.cs:269`. +Verification: **verified by test**, all three serializers. +Claim: “Strict” parsing does not validate complete JSON grammar or UTF-8, and raw ID echo can turn accepted bad input into an invalid response. + +Observed inputs and results: + +| Input | Observed result | +| --- | --- | +| `{"method":"ping","id":1,}` | Dispatches and returns `7` | +| `{"method":"ping","id":1}{}` | Dispatches the first document | +| `{"method":"ping","ignored":truX,"id":1}` | Dispatches and returns `7` | +| `{"method":"ping","id":01}` | Emits the invalid number `"id":01` | +| `{"method":"ping","id":nxxx}` | Emits the invalid literal `"id":nxxx` | +| Quoted ID containing raw byte `0xff` | Copies `0xff` into the response; strict UTF-8 decoding throws | + +Primitive scanning checks delimiters rather than literal/number grammar; separator handling lacks a grammar state; +the reader does not reject trailing roots; string scanning accepts non-ASCII bytes without UTF-8 validation. +`Utf8Json.ClassifyId` and `Handler.WriteIdRaw` then trust these tokens. +Suggested fix: validate separators, literals, number syntax, a single complete root, and UTF-8 before dispatch. +Keep explicitly supported lenient syntax separate from validation needed for safe output. + +### 3. major — A failed System.Text.Json write poisons a later call using different options + +Location: `AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs:123`; related line `136`. +Verification: **verified by test**. +Claim: The cached writer retains pending bytes after an exception, then flushes them into an obsolete destination when its options change. + +Repro on one thread: serializer A uses defaults; serializer B uses a new `JsonSerializerOptions` instance. +Call `A.Serialize(new Explodes())`, where `Good` returns `1` and the next property getter throws. +Catch that failure, then call `B.Serialize(7)`. +The second call throws `NullReferenceException` from `PooledByteBufferWriter.Advance`, reached through +`Utf8JsonWriter.Flush`, `Dispose`, and `RentWriter` at line 123. +The first `Serialize` has already disposed its pooled output; `ReturnWriter` only clears the in-use flag. + +Suggested fix: discard/reset pending writer state and detach the old destination on exceptional exit, +before the owner can rewind or dispose that destination. +Do not evict a failed writer by flushing it into its previous output. +Cover failure followed by both same-options and different-options reuse, including nested writers. + +### 4. major — Pre-process mutations no longer control the invoked request + +Location: `Json-Rpc/Handler.cs:339`; related lines `327`, `351`, and `359`. +Verification: **verified by test** and comparison with the old dispatch path. +Claim: The boxed path exposes a mutable request to pre-processing but subsequently resolves and binds from the original reader. + +Repro: install a pre-handler that sets `request.Method = "ping"` and `request.Params = null`, returning no error. +Send `{"method":"echo","params":["original"],"id":1}`. +The response is `"result":"original"`, proving the original `echo` call still ran, rather than the replacement returning `7`. +Likewise, a hook that sanitizes/replaces parameters can inspect changed data while dispatch consumes the original input. +The old implementation dispatched from the request object after the hook; the upgrade notes do not list this change. + +Suggested fix: make the boxed path resolve and bind the post-hook request, with appropriate revalidation. +If mutation is intentionally being removed, make that an explicit API break rather than silently ignoring it; +recommended decision: preserve the existing hook behavior because existing sanitizers and routers depend on it. + +### 5. major — Nested processing overwrites the outer context and consumes its error + +Location: `Json-Rpc/Handler.cs:550`; related lines `119`, `299`, `318`, and `554`. +Verification: **verified by test**. +Claim: Thread-static context and exception state are single slots, so a same-thread nested call corrupts its caller's invocation state. + +Repro: an outer method receives context `"outer"`, calls `RpcSetException(-32001, "outer failure")`, +then synchronously processes an inner `ping` with context `"inner"` and ID `2`. +After the nested call, `RpcContext()` is null; the inner response contains the outer `-32001` error. +The outer response succeeds instead of reporting that error. +`Scratch.Rent` protects byte buffers against this reentrancy, but it does not protect execution context. + +Suggested fix: push an invocation frame containing both context and exception state, initialize the nested frame, +and restore the previous frame in `finally` on every path. +If asynchronous service invocation is added, explicitly define context flow across continuations as well. + +### 6. major — Default internal-error responses disclose exception messages and stack traces + +Location: `Json-Rpc/Serialization/ExceptionInfo.cs:23`; related `Json-Rpc/Handler.cs:545` and `Json-Rpc/Handler.cs:517`. +Verification: **verified by test**. +Claim: An ordinary service exception is sent to the client with its private message, source assembly, and full stack trace by default. + +Repro: a method throws `InvalidOperationException("private database /server/secret")`. +The `-32603` response contains that exact message and the throwing method's stack frame; +the Debug run also exposes the local source path and line number through its symbols. +Exception normalization makes the shape portable; it does not redact the information. +Inner exception details are copied recursively as well. + +Suggested fix: make internal-error wire data safe by default and send diagnostics to a server-side logging hook. +Needs decision: preserve legacy diagnostic disclosure vs require explicit opt-in; +recommended opt-in because the new HTTP/TCP hosting package makes this a network-facing default. +Keep explicitly authored `JsonRpcException` application data under a separately documented policy. + +### 7. major — Installing a hook moves deserialization failures outside the error boundary + +Location: `Json-Rpc/Handler.cs:327`; related lines `535` and `562`. +Verification: **verified by test**. +Claim: Materializing the request for hooks can throw out of `JsonRpcProcessor` instead of producing a JSON-RPC error. + +Repro: with System.Text.Json, call `accept(object)` with a parameter containing 70 nested arrays. +Without hooks, the value-conversion exception becomes the library's current `-32603` response. +Add a no-op pre-handler and send the identical request: `System.Text.Json.JsonException` escapes the processor. +`reader.ParamsValue` runs before the protected invocation; error-hook construction repeats the same risky conversion. +In a transport this can terminate processing of the HTTP request or connection, including the rest of a batch. + +Suggested fix: cover materialization, hook execution, binding, and invocation with an explicit error boundary. +When conversion itself failed, error reporting must not depend on successfully repeating that conversion. +Use a safe request representation or unavailable-params marker for that error path. + +### 8. major — Lenient string IDs alias scratch storage used for method decoding + +Location: `Json-Rpc/Jsmn/JsmnRequestReader.cs:229`; related `Json-Rpc/Handler.cs:249`. +Verification: **verified by test**, lenient jsmn and default Newtonsoft. +Claim: A normalized single-quoted ID is overwritten while resolving an escaped method name. + +Repro input: `{method:'\u0065cho',params:['x'],id:'abc'}`. +Actual output: `{"jsonrpc":"2.0","result":"x","id":echo"}`. +`IdRaw` returns a span into `_scratch`; `HandleRequest` keeps it while `MethodUtf8` writes the decoded name +into the same array, then emits the stale span as the ID. +Escaped parameter-name decoding uses that array too. + +Suggested fix: give normalized IDs stable storage distinct from transient name decoding, +or normalize/copy them at a point where no subsequent reader operation can overwrite them. +Add cases that combine escaped method/parameter names with short and long lenient IDs. + +### 9. major — A write-only POCO member causes invalid JSON output + +Location: `Json-Rpc/Jsmn/JsmnMapper.cs:565`. +Verification: **verified by test**. +Claim: The built-in POCO writer bases comma placement on member index instead of the number of emitted members. + +Repro type: `class Shape { public int Hidden { set { } } public int Visible => 3; }`. +The built-in serializer emits `{,"Visible":3}`; Newtonsoft and System.Text.Json emit `{"Visible":3}`. +`BuildMembers` retains the write-only property, the writer skips it because its getter is null, +and the following readable member receives a leading comma. +An RPC returning this shape therefore produces an invalid result document without any exception to trigger rewind. + +Suggested fix: track whether a readable member has actually been written, or precompute a separate readable-member list. +Cover skipped members before, between, and after readable fields/properties. + +### 10. major — Non-finite floats violate both JSON syntax and serializer parity + +Location: `Json-Rpc/Serialization/Utf8Json.cs:118`; related `AustinHarris.JsonRpc.SystemTextJson/JsonRpcConverters.cs:301`. +Verification: **verified by test** for NaN; **verified by reading** for the corresponding infinity branches. +Claim: Built-in and System.Text.Json output bare non-finite symbols while default Newtonsoft writes JSON strings. + +Repro: serialize `double.NaN`. +Built-in and System.Text.Json produce `NaN`; Newtonsoft produces `"NaN"`. +The same code paths write bare `Infinity` and `-Infinity`. +Consequently an RPC returning a non-finite number produces JSON that strict clients cannot parse; +the comment claiming this matches default Json.NET behavior is incorrect. + +Suggested fix: use the legacy default's quoted representation consistently, or explicitly reject non-finite values +and return a valid mapped error; recommended quoted values if byte compatibility is the release contract. +Do not feed non-JSON tokens to `WriteRawValue(..., skipInputValidation: true)`. + +### 11. major — DI skips binding a JsonRpcService subclass to the configured session + +Location: `AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs:95`. +Verification: **verified by test**. +Claim: The self-binding shortcut ignores `JsonRpcOptions.SessionId` and can leave a service exposed in the default session instead of the configured one. + +Repro: `AddJsonRpc(o => o.SessionId = "tenant")`, then `AddJsonRpcService<AutoService>()`, +where `AutoService : JsonRpcService` uses the default base constructor. +After starting the hosted binder, calling its method in `tenant` returns `-32601`. +The constructor binds to the default session, but the binder skips it because registration-level `SessionId` is null. +The effective session computed on line 92 is never applied. + +Suggested fix: base the shortcut on the actual effective binding, not just the presence of a registration override. +Define how constructor binding and DI binding cooperate, including subclasses using the explicit-session base constructor. +Recommended decision: provide one deliberate binding owner for DI services so configuring isolation cannot silently expose a second session. + +### 12. major — Removed metadata services remain callable through the cached lookup + +Location: `Json-Rpc/SMDService.cs:86`; related public `Services` property at line `32`. +Verification: **verified by test**. +Claim: Direct edits to the public service dictionary do not invalidate successful UTF-8 cache hits, contrary to the stated compatibility behavior. + +Repro: bind `ping`, execute `handler.MetaData.Services.Remove("ping")`, then send a normal `ping` request. +It still returns `7`. +`Find(ReadOnlySpan<byte>)` returns a cached hit before checking dictionary count. +A same-count replacement also cannot be detected by that count check, even after a miss. +An application removing a method through the retained public dictionary has therefore not actually revoked dispatch access. + +Suggested fix: make all supported mutations versioned and update the dispatch table atomically, +or validate cache hits against the public dictionary while this mutable API remains supported. +If the dictionary becomes read-only, document the breaking change and provide a supported mutation API. + +### 13. major — Errors from valid notifications are sent back to the client + +Location: `Json-Rpc/Handler.cs:274`; related lines `266`, `281`, `296`, and `303`. +Verification: **verified by test**, all three serializers. +Claim: Response suppression applies only to successful notifications. + +Repro: `{"jsonrpc":"2.0","method":"missing"}` produces a `-32601` response with `"id":null`. +This is a valid notification whose method is unavailable, not an invalid request object. +The same structure writes responses for binding and invocation errors and adds those responses to batches. +Clients using notifications receive unsolicited messages; HTTP notification status also depends on success. +The protocol requires no response to notifications, including those in batches. [JSON-RPC specification](https://www.jsonrpc.org/specification#notification) + +Suggested fix: after distinguishing a valid notification from an invalid request, suppress its response on every dispatch outcome. +Preserve server-side error hooks/logging without emitting a wire response. +Needs decision: compatibility vs conformance; recommend compliant defaults with an explicit legacy mode if required. + +### 14. major — A batch with one response loses its array wrapper + +Location: `Json-Rpc/JsonRpcProcessor.cs:211`. +Verification: **verified by test**, all three serializers. +Claim: Response shape is selected by response count rather than whether the input was a batch. + +Repro: `[{"jsonrpc":"2.0","method":"ping","id":1}]` returns +`{"jsonrpc":"2.0","result":7,"id":1}` instead of a one-element array. +The same branch also unwraps a larger batch containing one call and successful notifications. +A client deserializing batch replies as arrays fails despite receiving a successful method result. +The protocol uses an array for a batch with responses. [JSON-RPC specification](https://www.jsonrpc.org/specification#batch) + +Suggested fix: retain brackets whenever a batch produces at least one response; emit nothing when it produces none. +This is explicitly retained legacy behavior, also asserted by `AustinHarris.JsonRpcTestN/Test.cs:1496`. +Needs decision: legacy shape vs protocol shape; recommend fixing the default in the major release and documenting migration. + +### 15. major — Escaped envelope member names are not recognized + +Location: `Json-Rpc/Jsmn/JsmnRequestReader.cs:114`. +Verification: **verified by test** for method; **verified by reading** for the same ID lookup path. +Claim: Envelope member matching compares encoded token bytes without decoding JSON string escapes. + +Repro: `{"m\u0065thod":"ping","id":1}` returns `-32600`, “Missing property 'method'”. +The decoded member name is `method`, so this is the same request as the normally spelled form. +An ID spelled `"\u0069d"` is likewise missed and the request is treated as having no ID. +Changing value serializers cannot fix this because all three share this reader. + +Suggested fix: keep the direct ASCII comparison for unescaped names, but decode escaped keys before matching them. +Apply the same semantic lookup to every envelope field and test escaped names in both standalone and batch requests. + +### 16. major — Async return types are serialized as task objects + +Location: `Json-Rpc/Invocation/RpcMethod.cs:137`. +Verification: **verified by test** for `ValueTask<int>`; **verified by reading** for dispatch's lack of awaitable handling. +Claim: Registration accepts awaitable-returning methods but passes their awaitables directly to value serialization. + +Repro: a registered method returns `new ValueTask<int>(7)`. +All three serializers return an object containing `IsCompleted`, `IsCompletedSuccessfully`, +`IsFaulted`, `IsCanceled`, and `Result:7`, rather than the intended result `7`. +The compiled path only distinguishes `void` from every other return type; the Task-returning processor API +does not make a service method asynchronous. + +Suggested fix: either await and unwrap supported return types through an asynchronous dispatch pipeline, +or reject `Task`, `Task<T>`, `ValueTask`, and `ValueTask<T>` at registration with an actionable error. +Needs decision: implement async methods now vs explicitly support synchronous methods only; +recommended rejection for this release if a correct async pipeline is out of scope. + +### 17. major — The release workflow never publishes the new companion packages + +Location: `.github/workflows/build_publish_master.yml:34`; related project matrix at line `14`. +Verification: **verified by reading**; no publishing was attempted. +Claim: The configured master release pushes only the core package even though the 2.0 installation instructions require separately published companions. + +Failure scenario: merge/version the four packages as `2.0.0` and rely on the existing master workflow. +Its push glob is `Json-Rpc/bin/Release/*.nupkg`; Newtonsoft, System.Text.Json, and AspNetCore packages live elsewhere. +Their locally generated packages are never selected, so the documented companion install commands cannot obtain +this release through that workflow. +The PR workflow has the same core-only publishing selection. + +Suggested fix: pack and validate all four release packages and explicitly publish each package from the approved release job. +Build the whole solution so companion netstandard assets are checked in CI too. +If publishing is deliberately manual, record that release procedure and ownership before opening the PR. + +### 18. minor — Ordinary mutable structs cannot be deserialized by the built-in mapper + +Location: `Json-Rpc/Jsmn/JsmnMapper.cs:685`. +Verification: **verified by test**. +Claim: The POCO plan rejects value types whose default constructor is implicit, even though `MakeCreator` supports them. + +Repro: `struct Pair { public int X { get; set; } }`, then deserialize `{"X":7}`. +Built-in jsmn throws `JsonRpcBindException` saying the type has no parameterless constructor; +Newtonsoft and System.Text.Json return a value with `X == 7`. +Switching an existing struct parameter to the new default serializer therefore converts a working call into an error. + +Suggested fix: allow `IsValueType` through the creation gate and use the existing value-type creator and boxed setters. +Cover mutable struct fields/properties, nullable structs, and structs nested inside containers. + +### 19. minor — Unknown named parameters can silently become defaults + +Location: `Json-Rpc/Handler.cs:423`. +Verification: **verified by test**, all three serializers. +Claim: Named binding checks supplied count and missing required parameters but never verifies that every supplied name matched. + +Repro: `optional(int a = 9)` receives `{"method":"optional","params":{"typo":4},"id":1}`. +The response is `9`; the supplied argument is silently discarded. +This contradicts the documented `-32602` handling of extra parameters and conceals client spelling mistakes. +Existing extra-parameter tests cover excess counts, which do not exercise this case. + +Suggested fix: reject unmatched supplied names independently of total parameter count, +then fill defaults only for genuinely absent optional parameters. +Define duplicate-name behavior alongside that validation. + +### 20. minor — The null-context Process overload trap remains + +Location: `Json-Rpc/JsonRpcProcessor.cs:97` and `Json-Rpc/JsonRpcProcessor.cs:121`. +Verification: **verified by test** using a compile-only repro. +Claim: `JsonRpcProcessor.Process(json, null)` is still ambiguous after the serializer-first overload fix. + +Repro: compile `void Repro(string json) { JsonRpcProcessor.Process(json, null); }`. +The compiler reports CS0121 between the `(string, JsonRpcStateAsync, object, JsonRpcSerializer)` +and `(string, string, object, JsonRpcSerializer)` overloads. +Both admit two arguments, and both second-argument reference types are more specific than `object`. +This is a remaining public API usability problem, not evidence that the serializer-first change failed its narrower purpose. + +Suggested fix: disambiguate both session overload families, using explicit session-oriented names or required arguments, +and document the migration; making only the string-session context mandatory is insufficient. +Add compile fixtures for default/session calls with null, string, and object contexts and explicit serializers. + +## What I checked and found sound + +- The architecture split is real: core package references contain no JSON library; value conversion is delegated, + while the shared reader, binding, dispatch, and envelope formatting remain in core. +- The native processor accepts `ReadOnlySequence<byte>` and `IBufferWriter<byte>`. + Both Kestrel entry points call it directly; string overloads transcode at their boundaries. + Multi-segment input is copied into pooled scratch, and responses are staged before copying to the destination; + “byte-first” is accurate, but it is not a claim of zero copying for every input. +- Serializer selection follows per-call, session, global, built-in precedence. + `Config` stores its global override in a volatile reference; I found no missing visibility barrier there. +- The normal scratch-buffer copy precedes scratch reuse; reader release is in `finally`. + `Rewind` and `RemoveAt` bounds/overlap handling are sound in themselves; the defects above concern their callers or retained writer state. +- Positional identity maps, `-1` optional-default entries, reordered named parameters, custom parameter names, + `void` results, and the special trailing `ref JsonRpcException` have coherent compiled paths and passing coverage. +- Existing tests cover envelope order, ordinary numeric/null/string shapes, default values, and DateTime fraction trimming. + I found the DateTime follow-up consistent with those intended wire conventions; finding 10 limits the general float-parity claim. +- Newtonsoft's reader disposal and decoder/encoder state handling, including the netstandard branches, were inspected. + Existing long-string, Unicode, settings/converter, and successful reentrant-converter tests pass. + System.Text.Json appends missing converters after user converters; its type-info cache stores an options/info pair, + not an ever-growing dictionary keyed by every options instance. +- HTTP method rejection, normal JSON error bodies, successful-notification 204, ordinary DI construction, + TCP concatenation/split-document handling, and context exposure pass the existing host tests. + Reading found byte-limit checks for complete and incomplete HTTP bodies and complete/incomplete TCP frames. + A separate approximately 2 MiB request returned over HTTP and began returning over TCP; + I did not reproduce the suspected large-request backpressure deadlock. +- Package versions and declared frameworks align at 2.0.0: core and serializers target netstandard2.0, + netstandard2.1, net8.0, net10.0; AspNetCore targets net8.0/net10.0. + The full build compiled those assets; no unavailable netstandard API was found in the inspected conditional branches. + +## Verification record and limits + +The requested initial `dotnet build AustinHarris.JsonRpc.sln -c Debug` exited 1 without useful diagnostics +(it printed zero warnings/errors). The serial no-restore build below succeeded, including after repro removal. +Its four warnings were existing unreachable-code/unused-variable warnings in the test project. + +```text +dotnet build AustinHarris.JsonRpc.sln -c Debug --no-restore -m:1 -v:minimal + Succeeded: all solution target assets, 0 errors, 4 warnings. +dotnet test AustinHarris.JsonRpcTestN --no-build -f net8.0 + Passed: 544; failed: 0; skipped: 0. +dotnet test AustinHarris.JsonRpcTestN --no-build -f net10.0 + Passed: 544; failed: 0; skipped: 0. +dotnet build TestServer_Console -c Debug --no-restore -m:1 -v:minimal + Succeeded at final inspected tip 3621c76: 0 errors, 0 warnings. +``` + +Fourteen temporary exploratory tests exercised the counterexamples and host probe on net10.0. +Their assertions/output established the observations above; “passed” for those repros meant the bad behavior was reproduced, +not that the implementation met the desired contract. +The overload repro separately produced the expected CS0121 compiler failure. +I changed no production source, project, package version, SDK pin, or existing test. +No performance benchmark was rerun to certify 273 ns or zero allocation, and no crash-inducing depth test was attempted. +The netstandard assets were compiled and read, not loaded into a separate older-runtime test host. + +## Questions for the author + +1. Which protocol deviations are contractual legacy behavior for 2.0? + In addition to findings 13–14, tests observed `jsonrpc:"9.0"` being dispatched, numeric ID `1e3` rejected, + and a valid primitive root `1` reported as parse error rather than invalid request. + `JsmnRequestReader.cs:115` never validates `jsonrpc`; `Utf8Json.ClassifyId` restricts numeric syntax. + Needs decision: explicit legacy mode vs strict 2.0 defaults; recommend strict defaults and a migration table. + Version and ID requirements are defined by the [JSON-RPC specification](https://www.jsonrpc.org/specification#request_object). +2. What resource budget is intended beyond request bytes: depth, token count, batch entries, response size, + and retained per-thread scratch capacity? The code has no single policy governing these. + Framing also rescans an incomplete document from its beginning on each read (`JsonFramer.cs:18`). + I did not run a sustained slow-fragment or thousands-of-large-results load test. +3. Is lenient Newtonsoft input promised over raw connections as well as HTTP? + `JsonFramer.cs:55` tracks double-quoted strings only; the envelope reader also accepts single quotes. + Needs decision: align supported framing syntax or explicitly limit raw-connection syntax; recommend alignment if leniency is a transport-independent promise. +4. Are service registries expected to support concurrent creation/destruction/rebinding during traffic, + and must DI binding precede arbitrary application hosted services? + Normal startup is covered; I did not establish lifecycle linearizability or every custom hosted-service ordering. + Define the intended lifecycle before treating concurrent mutation as supported. +5. Which additional method signatures are supported: generic methods, general `ref`/`out`, and expanded `params` arrays? + `RpcMethod.cs:107` constructs generic readers from parameter types, with special handling only for trailing `ref JsonRpcException`; + the current surface needs an explicit supported-signature contract and registration diagnostics. +6. Are the public implementation helpers (`RpcMethod`, invoker delegates, `Utf8Json`, and pooled writer) intentional long-term API? + Many public members lack XML summaries. Decide their compatibility commitment before publishing 2.0, + and expand “Upgrading from 1.x” to cover the decisions and behavior changes resolved from this review. + +The working tree initially contained an untracked `.claude/` directory; it was left untouched. +This review document is the only retained file created by the review. No commits, branch changes, pushes, +memgraph writes, or Linear writes were performed. diff --git a/docs/serializers.md b/docs/serializers.md new file mode 100644 index 0000000..5e17f81 --- /dev/null +++ b/docs/serializers.md @@ -0,0 +1,140 @@ +# Serializers: how they plug in and how they are configured + +JSON-RPC.Net 2.0 splits the work in two. The **core** owns the JSON-RPC envelope: it finds `method`, +`params` and `id`, resolves the method, binds parameters, invokes, and writes +`{"jsonrpc":"2.0","result":…,"id":…}` or the error object. A **serializer** owns values only: it turns +the raw bytes of one JSON value into a CLR value, and a CLR value into JSON bytes. Nothing from a +JSON library leaks into the core, so Json.NET, System.Text.Json and the built-in serializer are +interchangeable and each ships as its own package. + +| Package | Serializer | Default? | Notes | +|---|---|---|---| +| `AustinHarris.JsonRpc` | `Jsmn.JsmnSerializer` | yes | no dependencies; span port of the jsmn tokenizer plus a reflection mapper with cached type plans; primitives and `Nullable<T>` bind without boxing | +| `AustinHarris.JsonRpc.Newtonsoft` | `Newtonsoft.NewtonsoftJsonRpcSerializer` | | Json.NET 13; lenient input; honours `JsonSerializerSettings`; the compatibility choice for code that relied on Json.NET behaviour | +| `AustinHarris.JsonRpc.SystemTextJson` | `SystemTextJson.SystemTextJsonRpcSerializer` | | `Utf8JsonReader`/`Utf8JsonWriter` directly on the request bytes; honours `JsonSerializerOptions` | + +## The contract + +```csharp +public abstract class JsonRpcSerializer +{ + public abstract string Name { get; } + public virtual bool Lenient => false; // envelope reader accepts 'single quotes', bare keys, trailing commas + public virtual JsonRpcRequestReader CreateReader(); // envelope cursor; default is the jsmn tokenizer + public abstract T Read<T>(ReadOnlySpan<byte> utf8Json); // exactly one JSON value in, T out + public abstract object Read(ReadOnlySpan<byte> utf8Json, Type type); + public abstract void Write<T>(IBufferWriter<byte> output, T value); + public abstract void Write(IBufferWriter<byte> output, object value, Type type); + // string adapters: Deserialize<T>(string), Serialize<T>(T) – transcoding conveniences +} +``` + +`JsonRpcRequestReader` is the envelope cursor. It parses a document once and hands the core +*slices* of the request (`MethodUtf8`, `IdRaw`, `ParamRaw(i)`, `ParamNameUtf8(i)`), so nothing is +materialised until a parameter is bound. Serializers may override `CreateReader()` with their own +scanner, but the default reader already runs at the speed of the tokenizer and calls back into the +serializer's `Read<T>` for each parameter. + +Compiled invokers (one expression tree per registered method) call `reader.ReadParam<T>(i)` per +parameter and `serializer.Write<T>(output, result)` for the return value. Nothing is boxed on that +path; `object[]` and `DynamicInvoke` are gone. + +## Choosing a serializer: three levels + +Resolution order for every call is: **per-call argument** → **session** → **global** → built-in. + +```csharp +// 1. Global default (process-wide; volatile, takes effect for subsequent calls) +Config.SetSerializer(new SystemTextJsonRpcSerializer()); +Config.Serializer = null; // back to the built-in serializer + +// 2. Per session (a Handler is a session) +Handler.GetSessionHandler("legacy-clients").Serializer = new NewtonsoftJsonRpcSerializer(settings); +Config.SetSerializer("legacy-clients", serializer); // same thing + +// 3. Per call (transport decides; wins over both) +string json = JsonRpcProcessor.ProcessSync(sessionId, request, context, serializer); +JsonRpcProcessor.Process(sessionId, requestBytes, output, context, serializer); +``` + +Use per-session when different endpoints of one process serve different clients (a strict +System.Text.Json API next to a lenient Json.NET one for old clients). Use per-call when the transport +negotiates it (a header, a route, a protocol version). The global default is for the common case of +one serializer everywhere. + +Serializers must be thread-safe and are meant to be long-lived: construct one, share it. The +synchronous processor pools an envelope reader per thread. `ProcessAsync` uses separate transferable +leases and a bounded shared pool; a fresh serializer per call defeats reader reuse. A custom reader +must keep the selected request and its backing storage valid until `Release`, which can run on a +continuation thread after the operation terminates. Reads and writes remain synchronous individual +calls: only the service invocation is awaited. No span is carried across an await. + +## Library-specific options + +Each package accepts its own library's options in its constructor and nowhere else: + +| Serializer | Options type | Constructor | Notes | +|---|---|---|---| +| jsmn | `bool lenient`, `int maxDepth` | `new JsmnSerializer(lenient: true, maxDepth: 64)` | `lenient` accepts `'single quotes'`, unquoted keys and trailing commas in the request; `maxDepth` bounds nesting (default 64) | +| Json.NET | `JsonSerializerSettings` | `new NewtonsoftJsonRpcSerializer(settings)` | one `JsonSerializer` is created from the settings and reused; `Lenient` is always on | +| System.Text.Json | `JsonSerializerOptions` | `new SystemTextJsonRpcSerializer(options)` | the package adds its wire-format converters (see below) to a copy of your options when they are missing | + +### Nesting depth + +Every serializer exposes `MaxDepth` (virtual on `JsonRpcSerializer`, default 64). The envelope reader +rejects a request deeper than that with `-32700` before any hook or binding runs, so recursive parameter +conversion is bounded by the same number the JSON library itself enforces: for jsmn it is the constructor +argument, for System.Text.Json it is `JsonSerializerOptions.MaxDepth` and for Json.NET it is +`JsonSerializerSettings.MaxDepth` (both 64 when unset). The root object and the `params` container each +count as one level. + +The 1.x `JsonSerializerSettings` parameter on `JsonRpcProcessor.Process*` is gone from the core +because the core no longer references Json.NET. The Newtonsoft package provides the same overloads as +static helpers that build a `NewtonsoftJsonRpcSerializer(settings)` and forward to the core. + +## What the core fixes and what the serializer decides + +| Fixed by the core (identical for every serializer) | Decided by the serializer | +|---|---| +| envelope member order `jsonrpc, result|error, id`; compact output | how a result value / parameter / `error.data` is written and read | +| error object `{"code":…,"message":…,"data":…}` with `data` always present (null when absent) | POCO member naming and ordering (all three follow declaration order, PascalCase, nulls written) | +| id echoed byte-for-byte; `null` when the request had none or was invalid | numeric formatting (all three write whole float/double/decimal with `.0`) | +| batch shape: an array of responses whenever the batch produced one (a single response stays wrapped); a batch of notifications only produces nothing | coercions (number→bool, number→char, integer→float/decimal, ISO string→DateTime) | +| notifications (no `id`) never get a response, whatever the outcome; an invalid request object is not a notification and gets `-32600` with `"id":null` | leniency of the *values* (Json.NET accepts single-quoted strings; the others do not) | +| error codes: -32700 parse, -32600 invalid request/id, -32601 method (`data = {"method":…}`), -32602 params (missing/extra/count, unknown or repeated named parameter, or a value the serializer could not convert: `data = {"reason":"conversion","parameter":…,"index":…,"expectedType":…}`), -32603 method exception or a type the serializer cannot handle | how `JsonRequest.Params` looks to pre/post handlers (`JObject`/`JArray`, `JsonElement`, or `Dictionary<string,object>`/`List<object>`) | +| what counts as "could not convert": `JsonRpcBindException`, `FormatException`, `OverflowException`, `InvalidCastException` and any `JsonException` family (System.Text.Json's, Json.NET's) thrown while reading an argument | which values convert at all (Json.NET and the built-in serializer read `7` as the string `"7"` and `true` as `1`; System.Text.Json refuses the number) | +| `Exception` in `error.data` normalised to `ExceptionInfo {ClassName, Message, Source, StackTraceString, HResult, InnerException}`; `Source`, `StackTraceString`, `HResult` and `InnerException` are null/0 unless `Config.IncludeExceptionDetails` is true | | +| the error boundary: a hook, the materialisation of `JsonRequest.Params` for a hook, binding and the method itself all fail into a JSON-RPC error (`-32602` for an argument the serializer refused, `-32603` otherwise, unless the error handler maps it); nothing throws out of `JsonRpcProcessor` | | +| the request id as a method sees it (`Handler.RpcRequestId()` and friends): the reader's own JSON of the id, so a lenient single-quoted `'x'` reads as `"x"` | | +| case-insensitive envelope keys (`Method`, `ID`); exact-name matching of named parameters | case-insensitive member names inside POCOs (all three do this) | + +The built-in serializer reproduces Json.NET's conventions so that a switch is invisible on the +wire; the test suite runs all 163 cases against each serializer to keep that true. + +## Bytes in, bytes out + +The native entry points take what a `PipeReader` gives you and write to what a `PipeWriter` or +`HttpResponse.BodyWriter` is: + +```csharp +void Process(string sessionId, in ReadOnlySequence<byte> request, IBufferWriter<byte> output, object context = null, JsonRpcSerializer serializer = null); +void Process(string sessionId, ReadOnlyMemory<byte> request, IBufferWriter<byte> output, …); +void Process(string sessionId, ReadOnlySpan<byte> request, IBufferWriter<byte> output, …); +``` + +Nothing is written for a notification. Responses are rendered into a per-thread pooled buffer (so a +half-written result can be discarded when a method throws) and copied once into `output`. +`JsonFramer.TryReadDocument` slices complete documents out of a pipe buffer for raw-connection +transports. The `string` overloads (`ProcessSync`, `Task<string> Process`) transcode into the same +pooled buffers at the edge. + +## Migrating from 1.x + +- Replace `Process(…, JsonSerializerSettings settings)` with either `Config.SetSerializer(new NewtonsoftJsonRpcSerializer(settings))` or the helper overloads in the Newtonsoft package. +- `JsonRequest`, `JsonResponse`, `JsonRpcException` no longer carry Json.NET attributes; they are plain DTOs. `JsonRequest.Params` is whatever the active serializer's object model is; cast to `JObject`/`JArray` only when the Json.NET serializer is active. +- `SMD.Types` is now `Dictionary<int, Dictionary<string, object>>` and is a process-wide registry (it used to be reset every time a session was created). +- `Handler.Handle(JsonRequest)` still works; it round-trips the request through the serializer and the boxed path. +- The empty-batch error code is now the spec's `-32600` (it was `3200`). +- Batches: a trailing notification no longer leaves a dangling comma; a batch consisting only of notifications returns an empty string. +- A value the serializer cannot convert to the parameter's type is `-32602` with structured `data` (it was `-32603` carrying the exception); the serializers' own conversion exceptions are recognised without any change to a custom serializer, which may also throw `JsonRpcBindException` to say "the client's value is wrong". +- A type the built-in serializer does not support, or a class without a parameterless constructor, throws `NotSupportedException` from `Read` (it was `JsonRpcBindException`) and stays `-32603` on the wire. diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/samples/WasmHost/.gitignore b/samples/WasmHost/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/samples/WasmHost/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/samples/WasmHost/Program.cs b/samples/WasmHost/Program.cs new file mode 100644 index 0000000..dc3d995 --- /dev/null +++ b/samples/WasmHost/Program.cs @@ -0,0 +1,129 @@ +using System.Buffers; +using System.Diagnostics; +using System.Runtime.InteropServices.JavaScript; +using AustinHarris.JsonRpc; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.JSInterop; + +// A JSON-RPC server that runs inside the browser. There is no HTTP: JavaScript hands request text to +// JsonRpcProcessor through JS interop and gets the response text back, on the same thread, in the page. +// Useful for running the same service code in the browser and on the server, for offline tools, and for +// sandboxing untrusted-input handling in WebAssembly. + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +// Constructing a JsonRpcService registers it with the default session, exactly as it does on a server. +_ = new CalculatorService(); + +await builder.Build().RunAsync(); + +public class CalculatorService : JsonRpcService +{ + [JsonRpcMethod] // "add" + private double add(double l, double r) => l + r; + + [JsonRpcMethod("echo")] + public string Echo(string s) => s; + + [JsonRpcMethod("platform")] + public string Platform() => $"{Environment.OSVersion} / {System.Runtime.InteropServices.RuntimeInformation.OSArchitecture}"; +} + +/// <summary> +/// The JavaScript-facing surface. Three ways in, so the page can benchmark one against the other: +/// <list type="bullet"> +/// <item><see cref="Process"/> / <see cref="ProcessExported"/>: a JSON-RPC document as a string in, the response +/// string out. Any method the service exposes is reachable through this one entry point, and a batch is one call.</item> +/// <item><see cref="ProcessBytes"/>: the same through two pinned byte buffers that JavaScript writes and reads +/// directly (UTF-8 in WebAssembly memory), so no string is marshalled or transcoded in either direction. This is +/// the byte-first entry point the Kestrel package uses.</item> +/// <item><see cref="Add"/> / <see cref="AddExported"/>: plain Blazor interop, one exported method per operation +/// with typed arguments that the runtime marshals itself.</item> +/// </list> +/// <c>[JSInvokable]</c> methods are called as <c>DotNet.invokeMethod('WasmHost', name, ...)</c> (or +/// <c>invokeMethodAsync</c>); Blazor serialises the argument array and the result with System.Text.Json. +/// <c>[JSExport]</c> methods are reached through <c>getAssemblyExports('WasmHost.dll')</c> and marshal each +/// parameter directly, without a JSON round trip. +/// </summary> +public static partial class JsonRpcInterop +{ + // ---- JSON-RPC as strings: one entry point for every method, and for batches ---- + + [JSInvokable("Process")] + public static string Process(string json) => JsonRpcProcessor.ProcessSync(json); + + [JSExport] + public static string ProcessExported(string json) => JsonRpcProcessor.ProcessSync(json); + + /// <summary> + /// Runs the same request <paramref name="count"/> times inside .NET and returns the elapsed milliseconds: + /// the cost of the JSON-RPC server itself in this runtime, with no interop in the loop. + /// </summary> + [JSInvokable("ProcessMany")] + public static double ProcessMany(string json, int count) + { + var sw = Stopwatch.StartNew(); + for (int i = 0; i < count; i++) JsonRpcProcessor.ProcessSync(json); + return sw.Elapsed.TotalMilliseconds; + } + + // ---- JSON-RPC as bytes: JavaScript writes UTF-8 into the input buffer and reads the output buffer ---- + + private const int BufferSize = 1 << 20; + // Pinned so the MemoryViews handed to JavaScript stay valid for the life of the page. + private static readonly byte[] _input = GC.AllocateArray<byte>(BufferSize, pinned: true); + private static readonly byte[] _output = GC.AllocateArray<byte>(BufferSize, pinned: true); + private static readonly FixedBufferWriter _outputWriter = new FixedBufferWriter(_output); + private static readonly string _defaultSession = Handler.DefaultSessionId(); + + /// <summary>JavaScript side of the buffer hand-off: receives views of the two pinned arrays.</summary> + [JSImport("buffersReady", "wasmhost")] + private static partial void BuffersReady([JSMarshalAs<JSType.MemoryView>] ArraySegment<byte> input, [JSMarshalAs<JSType.MemoryView>] ArraySegment<byte> output); + + /// <summary>Hands JavaScript zero-copy views of the request and response buffers. Call once after start-up.</summary> + [JSExport] + public static void ExposeBuffers() => BuffersReady(new ArraySegment<byte>(_input), new ArraySegment<byte>(_output)); + + /// <summary> + /// Processes the first <paramref name="length"/> bytes of the input buffer (one document or a batch, UTF-8) + /// and returns how many bytes of response were written to the output buffer (0 for a notification). + /// </summary> + [JSExport] + public static int ProcessBytes(int length) + { + _outputWriter.Reset(); + JsonRpcProcessor.Process(_defaultSession, new ReadOnlySpan<byte>(_input, 0, length), _outputWriter); + return _outputWriter.Written; + } + + /// <summary>True when the app was AOT-compiled (published with the wasm-tools workload).</summary> + [JSExport] + public static bool IsAot() => +#if WASM_AOT + true; +#else + false; +#endif + + // ---- plain Blazor interop: one exported method per operation ---- + + [JSInvokable("Add")] + public static double Add(double l, double r) => l + r; + + [JSExport] + public static double AddExported(double l, double r) => l + r; + + /// <summary>An <see cref="IBufferWriter{T}"/> over a fixed array; throws if a response would not fit.</summary> + private sealed class FixedBufferWriter : IBufferWriter<byte> + { + private readonly byte[] _buffer; + public FixedBufferWriter(byte[] buffer) => _buffer = buffer; + public int Written { get; private set; } + public void Reset() => Written = 0; + public void Advance(int count) => Written += count; + public Memory<byte> GetMemory(int sizeHint = 0) => Check(sizeHint) ? _buffer.AsMemory(Written) : throw Overflow(); + public Span<byte> GetSpan(int sizeHint = 0) => Check(sizeHint) ? _buffer.AsSpan(Written) : throw Overflow(); + private bool Check(int sizeHint) => _buffer.Length - Written >= Math.Max(sizeHint, 1); + private static Exception Overflow() => new InvalidOperationException("The response does not fit in the output buffer."); + } +} diff --git a/samples/WasmHost/Properties/launchSettings.json b/samples/WasmHost/Properties/launchSettings.json new file mode 100644 index 0000000..fd61a5f --- /dev/null +++ b/samples/WasmHost/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "WasmHost": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "http://localhost:5199", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/WasmHost/README.md b/samples/WasmHost/README.md new file mode 100644 index 0000000..adb6adb --- /dev/null +++ b/samples/WasmHost/README.md @@ -0,0 +1,101 @@ +# WasmHost: JSON-RPC.Net running in the browser + +A standalone Blazor WebAssembly app that hosts a JSON-RPC.Net server inside the page. There is no HTTP +and no Kestrel: JavaScript passes request text to `JsonRpcProcessor.ProcessSync` through JS interop and +gets the response text back. + +``` +dotnet run --project samples/WasmHost +``` + +Open http://localhost:5199, type a request or send the sample batch, and read the response. The "Ask where +it runs" button calls a method that reports the OS and architecture the code sees (`Browser / Wasm`). + +The relevant pieces: + +- [Program.cs](Program.cs): a `JsonRpcService` with three methods, constructed at startup so it binds to + the default session, and the `JsonRpcInterop` class that JavaScript calls into. +- [wwwroot/index.html](wwwroot/index.html): starts the runtime with `Blazor.start()`, calls + `DotNet.invokeMethod('WasmHost', 'Process', json)`, and contains the benchmark below. + +Only the core package is referenced. It targets `net8.0`/`net10.0` (and `netstandard2.0`/`2.1`), has no +JSON library dependency, and does not use reflection emit, so it runs under the WebAssembly interpreter and +under AOT (`<RunAOTCompilation>true</RunAOTCompilation>` with the `wasm-tools` workload). + +## Benchmark: JSON-RPC vs plain Blazor interop + +The page has a "Run benchmark" button that performs the same `add(1, 2)` through every way JavaScript can +reach .NET here, and reports calls per second, microseconds per call, and RPCs per second: + +| Path | What it exercises | +|---|---| +| plain interop, `DotNet.invokeMethod('WasmHost','Add',1,2)` | one `[JSInvokable]` method per operation, the usual Blazor way; the runtime serialises the argument array and the result with System.Text.Json | +| plain interop, `invokeMethodAsync` | the same through the promise-returning call | +| plain interop, `exports.JsonRpcInterop.AddExported(1,2)` | one `[JSExport]` method per operation (`System.Runtime.InteropServices.JavaScript`), which marshals each parameter directly with no JSON | +| JSON-RPC, `invokeMethod('WasmHost','Process',request)` | one `[JSInvokable]` entry point for every method: a request document in, the response document out | +| JSON-RPC, `invokeMethodAsync` | the same through the promise-returning call | +| JSON-RPC, `exports.JsonRpcInterop.ProcessExported(request)` | the same entry point as a `[JSExport]`, strings marshalled by the runtime | +| JSON-RPC, `ProcessBytes`, UTF-8 buffers | JavaScript writes the request bytes into a pinned buffer in WebAssembly memory (a `MemoryView` handed over once at start-up), calls a `[JSExport]` with the length, and reads the response bytes back; no string crosses the boundary and the server runs its byte-first entry point, the one the Kestrel package uses | +| JSON-RPC, batch of 100 per call | the three interop flavours with a 100-request batch document per call | +| JSON-RPC in a .NET loop | `ProcessMany` runs the string request N times inside .NET: the cost of the server itself in this runtime, no interop | + +Measured on an 8-core desktop in Chrome 152 with .NET 10, 20,000 RPCs per row, once under the interpreter +(`dotnet run`, no AOT) and once AOT-compiled (`dotnet publish -c Release` with the `wasm-tools` workload; the +better of two runs per row): + +| Path | interpreter, µs per call | RPC/s | AOT, µs per call | RPC/s | +|---|---:|---:|---:|---:| +| plain interop, `invokeMethod Add` | 63.7 | 15,700 | 15.0 | 66,600 | +| plain interop, `invokeMethodAsync Add` | 68.8 | 14,500 | 16.9 | 59,300 | +| plain interop, `[JSExport] AddExported` | 0.35 | 2,860,000 | 0.31 | 3,230,000 | +| JSON-RPC, `invokeMethod Process` | 168.0 | 6,000 | 29.9 | 33,500 | +| JSON-RPC, `invokeMethodAsync Process` | 175.0 | 5,700 | 27.8 | 36,000 | +| JSON-RPC, `[JSExport] ProcessExported` | 58.4 | 17,100 | 6.6 | 151,600 | +| JSON-RPC, `[JSExport] ProcessBytes`, UTF-8 buffers | 52.6 | 19,000 | 7.0 | 142,000 | +| JSON-RPC, `invokeMethod Process`, batch of 100 | 7,706 per batch | 13,000 | 733 per batch | 136,000 | +| JSON-RPC, `[JSExport] ProcessExported`, batch of 100 | 3,998 per batch | 25,000 | 539 per batch | 185,500 | +| JSON-RPC, `[JSExport] ProcessBytes`, batch of 100, UTF-8 buffers | 3,706 per batch | 27,000 | 475 per batch | 210,500 | +| JSON-RPC in a .NET loop, no interop | 56.9 | 17,600 | 5.9 | 169,800 | + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="../../benchmarks/charts/wasm-interop-dark.svg"> + <img alt="add(1, 2) operations per second for every interop path, interpreter and AOT, on a log axis" src="../../benchmarks/charts/wasm-interop.svg"> +</picture> + +What the numbers say: + +- **`[JSInvokable]` interop is the expensive part, not the RPC.** A plain `invokeMethod` call that adds two + numbers costs about 64 µs, because the JSON marshalling of the argument array and result that the runtime + does runs in the interpreter. Sending a whole JSON-RPC document through `[JSExport]` (58 µs) is cheaper + than that. +- **Bytes beat strings.** The UTF-8 buffer path (53 µs) is faster than the string `[JSExport]` (58 µs) and + faster than the string request in a pure .NET loop (57 µs): with no string marshalled and no UTF-16 to UTF-8 + transcoding, the interop is gone and what remains is the parse, dispatch and response write themselves. +- **One entry point, batched, beats one interop call per operation.** A batch of 100 over the byte path + gets to 27,000 RPC/s (37 µs per request), well above the single-call `[JSInvokable]` rate for a bare add. + If the page has many calls to make at once, batch them. +- **`[JSExport]` with typed arguments is the fastest way to call one method** (0.35 µs) when the method has a + fixed signature, and it is fast for one reason: there is no JSON anywhere. The generated stub takes the two + doubles out of a fixed argument buffer in WebAssembly memory, calls the method and writes the double back; + the interpreter executes a handful of instructions. JSON-RPC pays for its generality: any method, any + parameters, batches, errors, and the same service code as the server, through one exported function. +- **The interpreter is the bottleneck, and AOT removes most of it.** AOT-compiled, the JSON-RPC rows are 7 to + 9× faster (a document through the byte path drops from 53 µs to 7 µs, the .NET loop from 57 µs to 5.9 µs, a + batch of 100 over bytes reaches 210,000 RPC/s) and the `[JSInvokable]` rows 3 to 4×, while the typed + `[JSExport]` add, which had almost no interpreted code to begin with, stays at 0.3 µs. Under AOT the interop + costs about 1 µs of the 7 (compare the byte path with the .NET loop); the rest is the server itself in + WebAssembly. The same request takes about 225 ns on the .NET 10 JIT (see the top-level README), so AOT + WebAssembly is still some 25× off native, down from 250× interpreted. + +How to get the most out of it, in order of payoff: + +1. **AOT-compile the app.** `dotnet workload install wasm-tools` (needs an elevated prompt on Windows), then + `dotnet publish -c Release` produces an AOT build under `bin/Release/net10.0/publish/wwwroot` (the project + turns `RunAOTCompilation` on for publish; pass `-p:Aot=false` to publish interpreted). Serve that folder + with any static server (`python -m http.server --directory <that folder>` will do) and the page reports + "AOT-compiled" next to "ready". The typed add stub barely changes; the JSON-RPC rows, which are .NET code, + get 7 to 9× faster, as the AOT columns above show. +2. **Enter through `[JSExport]` with UTF-8 buffers, not `invokeMethod`.** Same document, 3× faster, no + loss of generality: this is `ProcessBytes` above. +3. **Batch.** Per-document setup is amortised. +4. **Typed `[JSExport]` stubs for the two or three hottest methods**, JSON-RPC for everything else. diff --git a/samples/WasmHost/WasmHost.csproj b/samples/WasmHost/WasmHost.csproj new file mode 100644 index 0000000..0cef71b --- /dev/null +++ b/samples/WasmHost/WasmHost.csproj @@ -0,0 +1,27 @@ +<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> + + <PropertyGroup> + <TargetFramework>net10.0</TargetFramework> + <Nullable>disable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <IsPackable>false</IsPackable> + <!-- The JSON-RPC server runs inside the browser: no ASP.NET Core, no Kestrel, just the core package + compiled to WebAssembly and called from JavaScript through JS interop. --> + <!-- [JSExport]/[JSImport] methods (the direct-marshalling interop paths benchmarked in index.html) need the generator's unsafe code. --> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + <!-- `dotnet publish -c Release` AOT-compiles the app (needs `dotnet workload install wasm-tools`); `dotnet run` + and `dotnet build` stay on the interpreter and need no workload. Pass -p:Aot=false to publish interpreted. --> + <Aot Condition="'$(Aot)' == '' and '$(_IsPublishing)' == 'true'">true</Aot> + <RunAOTCompilation Condition="'$(Aot)' == 'true'">true</RunAOTCompilation> + <DefineConstants Condition="'$(Aot)' == 'true' and '$(_IsPublishing)' == 'true'">$(DefineConstants);WASM_AOT</DefineConstants> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\Json-Rpc\AustinHarris.JsonRpc.csproj" /> + </ItemGroup> + +</Project> diff --git a/samples/WasmHost/wwwroot/index.html b/samples/WasmHost/wwwroot/index.html new file mode 100644 index 0000000..bb496c1 --- /dev/null +++ b/samples/WasmHost/wwwroot/index.html @@ -0,0 +1,206 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>JSON-RPC.Net in WebAssembly + + + + +

JSON-RPC.Net in the browser

+

+ The JSON-RPC server is C# running in this page as WebAssembly. Nothing is sent to a server: + the request text goes to JsonRpcProcessor through JS interop and the response comes back. +

+ +

+ + + + loading .NET runtime… +

+

+
+    

Benchmark: JSON-RPC vs plain Blazor interop

+

+ Every row performs the same add(1, 2). The plain-interop rows call one exported .NET method + per operation, the way Blazor apps usually do; the JSON-RPC rows send a request document to the single + Process entry point. invokeMethod is the [JSInvokable] path (Blazor + serialises arguments and result with System.Text.Json); getAssemblyExports is the + [JSExport] path (direct marshalling); the "UTF-8 buffers" rows write the request bytes straight + into WebAssembly memory and read the response bytes back, with no string marshalled in either direction. + The last row runs the server in a .NET loop with no interop at all, which is the floor for the JSON-RPC rows. +

+

+ + + +

+ + + + + + + + + + +