diff --git a/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj b/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj
index ccbffb3..0ca9106 100644
--- a/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj
+++ b/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj
@@ -6,6 +6,7 @@
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
+ https://github.com/Astn/JSON-RPC.NET/blob/master/CHANGELOG.md
$(VersionSuffix)
Austin Harris
https://github.com/Astn/JSON-RPC.NET
diff --git a/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs b/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs
index 8b6d528..1cd0c04 100644
--- a/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs
+++ b/AustinHarris.JsonRpc.AspNetCore/JsonRpcServiceCollectionExtensions.cs
@@ -88,17 +88,14 @@ public JsonRpcBinderHostedService(IServiceProvider provider, IEnumerableJson-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
+ https://github.com/Astn/JSON-RPC.NET/blob/master/CHANGELOG.md
$(VersionSuffix)
Austin Harris
https://github.com/Astn/JSON-RPC.NET
diff --git a/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj b/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj
index 04c3f9c..5035c86 100644
--- a/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj
+++ b/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj
@@ -6,6 +6,7 @@
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
+ https://github.com/Astn/JSON-RPC.NET/blob/master/CHANGELOG.md
$(VersionSuffix)
Austin Harris
https://github.com/Astn/JSON-RPC.NET
diff --git a/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs
index c9cad93..3df78b3 100644
--- a/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs
+++ b/AustinHarris.JsonRpcTestN/AspNetCoreTests.cs
@@ -194,7 +194,7 @@ public async Task Tcp_DocumentSplitAcrossWrites_IsReassembled()
// ------------------------------------------------------------------ DI binding honours JsonRpcOptions.SessionId
- /// A JsonRpcService subclass built by DI: its base constructor binds it to the default session.
+ /// A JsonRpcService subclass built by DI: its base constructor binds it to the default session, and the host binds it to the configured one as well.
public class TenantAutoService : JsonRpcService
{
[JsonRpcMethod("tenant.ping")]
@@ -251,6 +251,46 @@ public async Task Http_JsonRpcServiceSubclass_IsBoundToTheConfiguredSession()
}
}
+ /// A JsonRpcService subclass that does not bind itself: the host is its only binder.
+ public class TenantUnboundService : JsonRpcService
+ {
+ public TenantUnboundService() : base(false) { }
+
+ [JsonRpcMethod("tenant.unbound")]
+ public int Unbound() => 9;
+ }
+
+ [Test]
+ public async Task Http_JsonRpcServiceSubclass_WithoutAutoBind_IsBoundOnlyToTheConfiguredSession()
+ {
+ const string session = "aspnetcore-tenant-unbound";
+ 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();
+
+ 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) };
+ var response = await http.PostAsync("/rpc", new StringContent(@"{""jsonrpc"":""2.0"",""method"":""tenant.unbound"",""id"":1}", Encoding.UTF8, "application/json"));
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":9,\"id\":1}", await response.Content.ReadAsStringAsync());
+
+ Assert.IsTrue(Handler.GetSessionHandler(session).MetaData.Services.ContainsKey("tenant.unbound"));
+ Assert.IsFalse(Handler.DefaultHandler.MetaData.Services.ContainsKey("tenant.unbound"), "base(false) keeps it off the default session");
+ }
+ finally
+ {
+ await app.StopAsync();
+ await app.DisposeAsync();
+ Handler.DestroySession(session);
+ }
+ }
+
private static async Task ReadUntilAsync(NetworkStream stream, string terminator)
{
var sb = new StringBuilder();
diff --git a/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs
index 82f9173..f21c5e7 100644
--- a/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs
+++ b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs
@@ -413,9 +413,20 @@ public async Task Cancellation_DiscardsDocument_AndWaitsForTerminalState(int pha
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);
+ Exception seen = null;
+ Handler.GetSessionHandler(_session).SetErrorHandler((request, error) => { seen = error.data as Exception; return error; });
+ try
+ {
+ var result = await Run(Request("run"));
+ Error(result, -32603);
+ StringAssert.Contains("\"data\":null", result, "an unhandled exception is redacted on the wire");
+ Assert.IsInstanceOf(seen, "but it is an ordinary error, not a cancellation of the processing");
+ Assert.AreEqual("method-owned", seen.Message);
+ }
+ finally
+ {
+ Handler.GetSessionHandler(_session).SetErrorHandler(null);
+ }
}
[TestCase(false)] [TestCase(true)]
diff --git a/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs b/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs
index 19c4f95..77d5632 100644
--- a/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs
+++ b/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs
@@ -39,6 +39,9 @@ private class HardeningService
[JsonRpcMethod("echo")]
public string Echo(string s) => s;
+ [JsonRpcMethod("unserializable")]
+ public Unserializable Unserializable() => new Unserializable();
+
[JsonRpcMethod("accept")]
public int Accept(object o) => 7;
@@ -103,6 +106,12 @@ private class SessionProbe
public string WhichSession() => _name;
}
+ /// A result every serializer fails to write: the getter throws.
+ public class Unserializable
+ {
+ public string Secret => throw new InvalidOperationException("private database /server/secret");
+ }
+
private class TaskReturningService
{
[JsonRpcMethod("asyncTask")]
@@ -261,8 +270,18 @@ public void PreProcessHandler_Throwing_IsAnInternalError(string name)
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(JTokenType.Null, response["error"]["data"].Type, "a hook failure is an unhandled exception: redacted like any other");
Assert.AreEqual(1, (int)response["id"]);
+ Config.IncludeExceptionDetails = true;
+ try
+ {
+ response = Parse(Run("{\"method\":\"ping\",\"id\":1}", null, serializer));
+ Assert.AreEqual("hook failed", (string)response["error"]["data"]["Message"]);
+ }
+ finally
+ {
+ Config.IncludeExceptionDetails = false;
+ }
}
finally
{
@@ -315,23 +334,45 @@ 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());
+ var raw = Run("{\"method\":\"throws\",\"id\":1}", null, serializer);
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal Error\",\"data\":null},\"id\":1}", raw,
+ "nothing about the exception leaves the process: no type name, no message");
+
+ raw = Run("{\"method\":\"throwsInner\",\"id\":1}", null, serializer);
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal Error\",\"data\":null},\"id\":1}", raw);
+ }
+
+ [TestCaseSource(nameof(Serializers))]
+ public void ExceptionDetails_ErrorHandlerStillSeesTheException(string name)
+ {
+ var serializer = SerializerCatalog.Create(name);
+ var handler = Handler.GetSessionHandler(Session);
+ Exception seen = null;
+ try
+ {
+ handler.SetErrorHandler((request, error) => { seen = error.data as Exception; return error; });
+ var response = Parse(Run("{\"method\":\"throws\",\"id\":1}", null, serializer));
+ Assert.IsInstanceOf(seen, "the handler gets the exception itself, redaction happens when the response is written");
+ Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type);
+
+ // a handler that replaces the error with its own data is not redacted: that data is authored
+ handler.SetErrorHandler((request, error) => new JsonRpcException(-32000, "Server error", "ticket 42"));
+ response = Parse(Run("{\"method\":\"throws\",\"id\":1}", null, serializer));
+ Assert.AreEqual("ticket 42", (string)response["error"]["data"]);
+ }
+ finally
+ {
+ handler.SetErrorHandler(null);
+ }
+ }
+
+ [TestCaseSource(nameof(Serializers))]
+ public void ExceptionDetails_ResultSerializationFailure_IsRedactedToo(string name)
+ {
+ var serializer = SerializerCatalog.Create(name);
+ var raw = Run("{\"method\":\"unserializable\",\"id\":1}", null, serializer);
+ Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal Error\",\"data\":null},\"id\":1}", raw,
+ "an exception thrown while writing the result is an unhandled exception as well");
}
[TestCaseSource(nameof(Serializers))]
diff --git a/AustinHarris.JsonRpcTestN/ErrorDataTests.cs b/AustinHarris.JsonRpcTestN/ErrorDataTests.cs
index 04f36e4..89b9f17 100644
--- a/AustinHarris.JsonRpcTestN/ErrorDataTests.cs
+++ b/AustinHarris.JsonRpcTestN/ErrorDataTests.cs
@@ -154,11 +154,25 @@ 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"]);
+ Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type, "with details off an unhandled exception is data:null");
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"]);
+ Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type);
+ StringAssert.DoesNotContain("from the method", response.ToString());
+ StringAssert.DoesNotContain("FormatException", response.ToString());
+
+ try
+ {
+ Config.IncludeExceptionDetails = true;
+ response = Run("{\"method\":\"ed.throwsFormat\",\"id\":1}", s);
+ Assert.AreEqual("System.FormatException", (string)response["error"]["data"]["ClassName"]);
+ Assert.AreEqual("from the method", (string)response["error"]["data"]["Message"]);
+ }
+ finally
+ {
+ Config.IncludeExceptionDetails = false;
+ }
}
[TestCaseSource(nameof(Serializers))]
@@ -271,9 +285,12 @@ public void UnsupportedType_StaysInternal()
try
{
ServiceBinder.BindMethod(session, "takesDelegate", new Func(a => 1));
+ Exception seen = null;
+ Config.SetErrorHandler(session, (request, error) => { seen = error.data as Exception; return error; });
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"]);
+ Assert.AreEqual(JTokenType.Null, response["error"]["data"].Type, "redacted on the wire");
+ Assert.IsInstanceOf(seen, "the handler sees why");
}
finally
{
diff --git a/AustinHarris.JsonRpcTestN/SessionAndConfigTests.cs b/AustinHarris.JsonRpcTestN/SessionAndConfigTests.cs
new file mode 100644
index 0000000..2bc01e1
--- /dev/null
+++ b/AustinHarris.JsonRpcTestN/SessionAndConfigTests.cs
@@ -0,0 +1,258 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AustinHarris.JsonRpc;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+
+namespace AustinHarris.JsonRpcTestN
+{
+ ///
+ /// The request path never creates a session; registration paths still do. Per-session pre- and post-process
+ /// handlers are set through Config with names symmetric to the default-session setters. A JsonRpcService
+ /// subclass can opt out of binding itself.
+ ///
+ [TestFixture]
+ public class SessionAndConfigTests
+ {
+ private const string Session = "session-config";
+
+ private class Service
+ {
+ [JsonRpcMethod("sc.ping")]
+ public int Ping() => 7;
+ }
+
+ [OneTimeSetUp]
+ public void Bind()
+ {
+ ServiceBinder.BindService(Session, new Service());
+ }
+
+ [OneTimeTearDown]
+ public void Destroy()
+ {
+ Handler.DestroySession(Session);
+ }
+
+ // ------------------------------------------------------------------ unknown session ids
+
+ [Test]
+ public void UnknownSession_AnswersMethodNotFound_AndIsNotCreated()
+ {
+ string id = "never-registered-" + Guid.NewGuid().ToString("N");
+
+ var response = JObject.Parse(JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1}", null));
+ Assert.AreEqual(-32601, (int)response["error"]["code"]);
+ Assert.AreEqual("sc.ping", (string)response["error"]["data"]["method"]);
+ Assert.AreEqual(1, (int)response["id"]);
+
+ // a request with the unknown id did not create the session: the lookup is still a miss
+ Assert.IsFalse(Handler.TryGetSessionHandler(id, out _), "the request path must not create sessions");
+
+ // the default session's methods are not reachable through an unknown id either
+ var viaDefault = JObject.Parse(JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"add\",\"params\":[1,2],\"id\":2}", null));
+ Assert.AreEqual(-32601, (int)viaDefault["error"]["code"], "unknown ids do not fall back to the default session");
+ }
+
+ [Test]
+ public async Task UnknownSession_Async_AnswersMethodNotFound_AndIsNotCreated()
+ {
+ string id = "never-registered-async-" + Guid.NewGuid().ToString("N");
+ var response = JObject.Parse(await JsonRpcProcessor.ProcessAsync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1}"));
+ Assert.AreEqual(-32601, (int)response["error"]["code"]);
+ Assert.IsFalse(Handler.TryGetSessionHandler(id, out _));
+ }
+
+ [Test]
+ public void UnknownSession_ParseErrorsBatchesAndNotifications_BehaveAsUsual()
+ {
+ string id = "never-registered-shapes-" + Guid.NewGuid().ToString("N");
+ var parse = JObject.Parse(JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",", null));
+ Assert.AreEqual(-32700, (int)parse["error"]["code"]);
+
+ var batch = JArray.Parse(JsonRpcProcessor.ProcessSync(id, "[{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1},{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\"}]", null));
+ Assert.AreEqual(1, batch.Count, "the notification produces nothing, the call answers -32601");
+ Assert.AreEqual(-32601, (int)batch[0]["error"]["code"]);
+
+ Assert.AreEqual("", JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\"}", null));
+ Assert.IsFalse(Handler.TryGetSessionHandler(id, out _));
+ }
+
+ [Test]
+ public void RegistrationAfterAMiss_IsFoundByTheThreadThatMissed()
+ {
+ string id = "registered-after-miss-" + Guid.NewGuid().ToString("N");
+ // the miss puts nothing in this thread's snapshot or last-hit cache
+ var miss = JObject.Parse(JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1}", null));
+ Assert.AreEqual(-32601, (int)miss["error"]["code"]);
+ try
+ {
+ ServiceBinder.BindService(id, new Service());
+ var hit = JObject.Parse(JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":2}", null));
+ Assert.AreEqual(7, (int)hit["result"]);
+ }
+ finally
+ {
+ Handler.DestroySession(id);
+ }
+ var gone = JObject.Parse(JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":3}", null));
+ Assert.AreEqual(-32601, (int)gone["error"]["code"], "a destroyed session is unknown again");
+ }
+
+ [Test]
+ public void RegistrationRacingRequests_IsSeenByEveryThread()
+ {
+ // Threads hammer an id with requests while another thread registers it. After the registration no
+ // thread may keep answering -32601: the snapshot miss consults the master registry.
+ string id = "race-" + Guid.NewGuid().ToString("N");
+ var registered = new ManualResetEventSlim();
+ var stop = new ManualResetEventSlim();
+ var errorsAfterRegistration = 0;
+ var workers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
+ {
+ while (!stop.IsSet)
+ {
+ // read the flag before sending: a request that started before the registration finished may
+ // legitimately answer -32601
+ bool wasRegistered = registered.IsSet;
+ var r = JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1}", null);
+ if (wasRegistered && r.Contains("-32601")) Interlocked.Increment(ref errorsAfterRegistration);
+ }
+ })).ToArray();
+ try
+ {
+ Thread.Sleep(20);
+ ServiceBinder.BindService(id, new Service());
+ registered.Set();
+ Thread.Sleep(50);
+ // one more request from each worker after registration
+ stop.Set();
+ Task.WaitAll(workers);
+ for (int i = 0; i < 4; i++)
+ {
+ var r = JsonRpcProcessor.ProcessSync(id, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1}", null);
+ Assert.IsTrue(r.Contains("\"result\":7"), r);
+ }
+ Assert.AreEqual(0, errorsAfterRegistration, "no thread answered -32601 once the session existed");
+ }
+ finally
+ {
+ stop.Set();
+ Handler.DestroySession(id);
+ }
+ }
+
+ [Test]
+ public void GetSessionHandler_StillCreates_ForRegistrationPaths()
+ {
+ string id = "created-by-config-" + Guid.NewGuid().ToString("N");
+ try
+ {
+ Config.SetSerializer(id, null);
+ Assert.IsTrue(Handler.TryGetSessionHandler(id, out var handler));
+ Assert.AreSame(handler, Handler.GetSessionHandler(id));
+ }
+ finally
+ {
+ Handler.DestroySession(id);
+ }
+ }
+
+ // ------------------------------------------------------------------ per-session handler setters
+
+ [Test]
+ public void Config_SetsPreAndPostProcessHandlers_PerSession()
+ {
+ int pre = 0, post = 0, defaultPre = 0;
+ try
+ {
+ Config.SetPreProcessHandler((request, context) => { defaultPre++; return null; });
+ Config.SetPreProcessHandler(Session, (request, context) => { pre++; return null; });
+ Config.SetPostProcessHandler(Session, (request, response, context) => { post++; return null; });
+
+ var response = JObject.Parse(JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1}", null));
+ Assert.AreEqual(7, (int)response["result"]);
+ Assert.AreEqual(1, pre);
+ Assert.AreEqual(1, post);
+ Assert.AreEqual(0, defaultPre, "the default session's handler does not run for another session");
+
+ // null clears only that session's handler
+ Config.SetPreProcessHandler(Session, null);
+ Config.SetPostProcessHandler(Session, null);
+ JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":2}", null);
+ Assert.AreEqual(1, pre);
+ Assert.AreEqual(1, post);
+
+ JsonRpcProcessor.ProcessSync("{\"jsonrpc\":\"2.0\",\"method\":\"add\",\"params\":[1,2],\"id\":3}", context: null);
+ Assert.AreEqual(1, defaultPre, "the default session's own handler still runs");
+ }
+ finally
+ {
+ Config.SetPreProcessHandler(null);
+ Config.SetPreProcessHandler(Session, null);
+ Config.SetPostProcessHandler(Session, null);
+ }
+ }
+
+ [Test]
+ public void Config_SetBeforeProcessHandler_IsAnAliasOfSetPreProcessHandler()
+ {
+ int pre = 0;
+ try
+ {
+#pragma warning disable CS0618
+ Config.SetBeforeProcessHandler(Session, (request, context) => { pre++; return null; });
+#pragma warning restore CS0618
+ JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":1}", null);
+ Assert.AreEqual(1, pre);
+ Config.SetPreProcessHandler(Session, null);
+ JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.ping\",\"id\":2}", null);
+ Assert.AreEqual(1, pre, "the new name clears what the old name set");
+ }
+ finally
+ {
+ Config.SetPreProcessHandler(Session, null);
+ }
+ }
+
+ // ------------------------------------------------------------------ JsonRpcService(bool autoBind)
+
+ private sealed class BoundService : JsonRpcService
+ {
+ public BoundService() : base(true) { }
+ [JsonRpcMethod("sc.bound")] public int Bound() => 1;
+ }
+
+ private sealed class UnboundService : JsonRpcService
+ {
+ public UnboundService() : base(false) { }
+ [JsonRpcMethod("sc.unbound")] public int Unbound() => 2;
+ }
+
+ [Test]
+ public void JsonRpcService_AutoBindFalse_BindsNowhere_UntilBoundExplicitly()
+ {
+ var unbound = new UnboundService();
+ Assert.IsFalse(Handler.DefaultHandler.MetaData.Services.ContainsKey("sc.unbound"), "base(false) does not touch the default session");
+
+ _ = new BoundService();
+ try
+ {
+ Assert.IsTrue(Handler.DefaultHandler.MetaData.Services.ContainsKey("sc.bound"), "base(true) is the parameterless behaviour");
+
+ ServiceBinder.BindService(Session, unbound);
+ var response = JObject.Parse(JsonRpcProcessor.ProcessSync(Session, "{\"jsonrpc\":\"2.0\",\"method\":\"sc.unbound\",\"id\":1}", null));
+ Assert.AreEqual(2, (int)response["result"]);
+ Assert.IsFalse(Handler.DefaultHandler.MetaData.Services.ContainsKey("sc.unbound"), "explicit binding to another session leaves the default session alone");
+ }
+ finally
+ {
+ Handler.DefaultHandler.UnRegisterFunction("sc.bound");
+ Handler.GetSessionHandler(Session).UnRegisterFunction("sc.unbound");
+ }
+ }
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b1a90cd
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,64 @@
+# Changelog
+
+The four packages (`AustinHarris.JsonRpc`, `AustinHarris.JsonRpc.Newtonsoft`, `AustinHarris.JsonRpc.SystemTextJson`,
+`AustinHarris.JsonRpc.AspNetCore`) share one version number and are released together. This file is the record
+of what changed in each version; the README's [Upgrading from 1.x](README.md#upgrading-from-1x) explains how to
+move a 1.x server, and the package pages on NuGet link here.
+
+Versions follow [Semantic Versioning](https://semver.org/) for the public API and the documented wire
+behaviour: a breaking change to either means a new major version.
+
+## 2.0.0 (unreleased)
+
+### Added
+
+- `ServiceBinder.BindInterface` registers interface trees atomically, with contract naming, filtering, defaults and ownership-aware disposal (`RpcBinding`).
+- `ServiceBinder.BindMethod` registers any delegate as a method without attributes or a service class.
+- `JsonRpcProcessor.ProcessAsync` awaits `Task` and `ValueTask` methods with typed result writing, sequential batches and cooperative cancellation; `[JsonRpcCancellation]` injects the processor's token.
+- `RpcContextFlow` on `[JsonRpcMethod]`: the ambient context does not flow across awaits by default (`None`); `Flow` opts a method in.
+- Byte-first pipeline: `ReadOnlySequence` / `ReadOnlyMemory` / `ReadOnlySpan` in, `IBufferWriter` out; parameters bind straight from the request bytes through compiled invokers. The string overloads remain.
+- Pluggable serializers (`AustinHarris.JsonRpc.Serialization.JsonRpcSerializer`): the built-in jsmn serializer is the default; Json.NET and System.Text.Json ship as the `.Newtonsoft` and `.SystemTextJson` packages.
+- `AustinHarris.JsonRpc.AspNetCore`: `MapJsonRpc` endpoint (`PipeReader` in, `BodyWriter` out), a raw Kestrel `ConnectionHandler`, DI registration (`AddJsonRpcService`, `AddJsonRpcServicesFromAssembly`), `EnableAsyncMethods` for asynchronous HTTP and ordered raw-connection processing.
+- 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.
+- `Config.SetPreProcessHandler(sessionId, …)` and `Config.SetPostProcessHandler(sessionId, …)`, symmetric with the default-session setters. `Config.SetBeforeProcessHandler(sessionId, …)` remains as an obsolete alias.
+- `protected JsonRpcService(bool autoBind)`: a subclass constructed with `base(false)` binds itself nowhere, for services that a host or an explicit `BindService` call binds.
+- `SECURITY.md` (private vulnerability reporting) and this changelog.
+
+### Changed
+
+- The core no longer depends on Json.NET.
+- The request path looks sessions up without creating them. A request for a session id that was never registered answers `-32601` for every call and leaves the registry untouched; sessions are created by binding and by the per-session `Config` setters. Registration adds the session before publishing the registry version, so a thread that misses its snapshot consults the master registry and cannot answer `-32601` for a session that exists.
+- The AspNetCore host binds every registered service, `JsonRpcService` subclasses included, to its effective session (the registration's session, then `JsonRpcOptions.SessionId`, then the default). It no longer skips a subclass on the default session.
+- The core package's description says "no JSON library dependency" instead of "no dependencies". The session registry uses the framework's `ConcurrentDictionary`; the `NonBlocking` package reference is gone, so the core has no dependencies on `net8.0` and `net10.0` (measured with `SessionRegistryBenchmarks`: unknown-id lookups and register/destroy cycles got faster, stable lookups and dispatch are unchanged).
+- `SMD.Services` is an `SMDServiceCollection`; every mutation through it updates the dispatch table at once. `SMD.Types` is a process-wide registry.
+- The `jsonrpc` member is checked (`Config.VersionPolicy`, default `Lenient`): a missing member is accepted, `"jsonrpc":"1.0"` or a non-string value is `-32600`.
+- 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.
+- Named parameters are checked against the method's parameter list: an unknown or repeated name is `-32602`.
+- Batches: the empty-batch error is `-32600`; a batch made only of notifications produces nothing; a batch always answers with an array when it produces at least one response.
+- Notifications never get a wire response, whatever their outcome.
+- Dates and non-finite numbers are written the same way by every serializer (fraction only when non-zero, `Z`/offset/nothing by `Kind`; `NaN` and the infinities as quoted strings).
+
+### Removed
+
+- The `JsonSerializerSettings` overloads of `JsonRpcProcessor.Process*` (pass a serializer instead; the Newtonsoft package has settings-based helpers).
+- Json.NET attributes on `JsonRequest`, `JsonResponse` and `JsonRpcException`.
+
+### Fixed
+
+- A trailing notification in a batch no longer leaves a dangling comma.
+- `async void` methods are rejected at registration.
+
+### Security
+
+- With `Config.IncludeExceptionDetails` off (the default), an unhandled exception is answered as `-32603` with `data: null`: the exception's type name and message are no longer sent. Error handlers still receive the exception itself and can author what the client sees. The same applies to an exception thrown while writing a result. `ExceptionInfo.ForResponse` returns null when details are off.
+- The session registry no longer grows from untrusted session ids on the request path (see Changed).
+
+## 1.3.0 (2026-09-22)
+
+- Targets `netstandard2.0`, `netstandard2.1`, `net8.0` and `net10.0` (drops the end-of-life `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.
+- Packaging moved fully to the SDK-style project files: MIT license expression, README in the package, repository metadata.
+- Closes out the .NET Standard work from PR #90 / issue #89.
+
+Earlier versions were published without a changelog; their history is in the repository's commit log.
diff --git a/Json-Rpc/AustinHarris.JsonRpc.csproj b/Json-Rpc/AustinHarris.JsonRpc.csproj
index 8c92c49..a6b3721 100644
--- a/Json-Rpc/AustinHarris.JsonRpc.csproj
+++ b/Json-Rpc/AustinHarris.JsonRpc.csproj
@@ -4,7 +4,7 @@
Austin Harris
Austin Harris
Json-Rpc.Net Core
- 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.
+ 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 JSON library dependency: the built-in serializer needs nothing, and Json.NET or System.Text.Json plug in through 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
@@ -14,31 +14,7 @@
MIT
README.md
json-rpc;jsonrpc;json;rpc;server;netstandard;kestrel;pipelines;system.text.json;json.net;fast
-
- 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
-
+ https://github.com/Astn/JSON-RPC.NET/blob/master/CHANGELOG.md
netstandard2.0;netstandard2.1;net8.0;net10.0
latest
true
@@ -46,10 +22,8 @@
-
-
-
-
+
+
diff --git a/Json-Rpc/Config.cs b/Json-Rpc/Config.cs
index 5797b57..3fad4c5 100644
--- a/Json-Rpc/Config.cs
+++ b/Json-Rpc/Config.cs
@@ -86,39 +86,57 @@ public static void SetSerializer(string sessionId, JsonRpcSerializer serializer)
}
///
- /// Sets the the PreProcessing Handler on the default session.
+ /// Sets the pre-process handler of the default session only; other sessions do not inherit it.
///
- ///
+ /// The handler, or null to clear it.
public static void SetPreProcessHandler(PreProcessHandler handler)
{
Handler.DefaultHandler.SetPreProcessHandler(handler);
}
///
- /// Sets the the PostProcessing Handler on the default session.
+ /// Sets the post-process handler of the default session only; other sessions do not inherit it.
///
- ///
+ /// The handler, or null to clear it.
public static void SetPostProcessHandler(PostProcessHandler handler)
{
Handler.DefaultHandler.SetPostProcessHandler(handler);
}
///
- /// Sets the PreProcessing Handler on a specific session
+ /// Sets the pre-process handler of one session. Null clears that session's handler; no other session is affected.
///
- ///
- ///
- public static void SetBeforeProcessHandler(string sessionId, PreProcessHandler handler)
+ /// The session; it is created when it does not exist yet.
+ /// The handler, or null to clear it.
+ public static void SetPreProcessHandler(string sessionId, PreProcessHandler handler)
{
Handler.GetSessionHandler(sessionId).SetPreProcessHandler(handler);
}
+ ///
+ /// Sets the post-process handler of one session. Null clears that session's handler; no other session is affected.
+ ///
+ /// The session; it is created when it does not exist yet.
+ /// The handler, or null to clear it.
+ public static void SetPostProcessHandler(string sessionId, PostProcessHandler handler)
+ {
+ Handler.GetSessionHandler(sessionId).SetPostProcessHandler(handler);
+ }
+
+ /// The former name of .
+ [Obsolete("Use SetPreProcessHandler(sessionId, handler).")]
+ public static void SetBeforeProcessHandler(string sessionId, PreProcessHandler handler)
+ {
+ SetPreProcessHandler(sessionId, handler);
+ }
+
///
/// 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.
+ /// Applies to the default session only; other sessions do not inherit it.
///
- ///
+ /// The handler, or null to clear it.
public static void SetErrorHandler(Func handler)
{
Handler.DefaultHandler.SetErrorHandler(handler);
@@ -140,8 +158,9 @@ public static void SetErrorHandler(string sessionId, Func
- ///
+ /// The handler, or null to clear it.
public static void SetParseErrorHandler(Func handler)
{
Handler.DefaultHandler.SetParseErrorHandler(handler);
diff --git a/Json-Rpc/Handler.cs b/Json-Rpc/Handler.cs
index d2aa608..ae013d7 100644
--- a/Json-Rpc/Handler.cs
+++ b/Json-Rpc/Handler.cs
@@ -8,7 +8,7 @@ namespace AustinHarris.JsonRpc
using AustinHarris.JsonRpc.Invocation;
using AustinHarris.JsonRpc.Jsmn;
using AustinHarris.JsonRpc.Serialization;
- using NonBlocking;
+ using System.Collections.Concurrent;
public sealed partial class Handler
{
@@ -27,6 +27,7 @@ public sealed partial class Handler
private static readonly ConcurrentDictionary _sessionHandlersMaster = new ConcurrentDictionary();
private static readonly string _defaultSessionId = Guid.NewGuid().ToString();
+ private static readonly Handler _unknownSessionHandler = new Handler(null);
#endregion
#region Constructors
@@ -52,10 +53,27 @@ private Handler(string sessionId)
public static string DefaultSessionId() { return _defaultSessionId; }
///
- /// Gets a specific session
+ /// Gets a specific session, creating it when it does not exist yet. Registration paths (ServiceBinder,
+ /// the Config setters) use this; the request path uses , so a request
+ /// for an unknown session id never creates a session.
///
/// The sessionId of the handler you want to retrieve.
public static Handler GetSessionHandler(string sessionId)
+ {
+ if (TryGetSessionHandler(sessionId, out var handler)) return handler;
+ // Add first, publish the version second: a thread that refreshes its snapshot in between copies the
+ // new entry, so no thread can hold a current-looking snapshot that lacks it.
+ handler = _sessionHandlersMaster.GetOrAdd(sessionId, id => new Handler(id));
+ Interlocked.Increment(ref _sessionHandlerMasterVersion);
+ return handler;
+ }
+
+ ///
+ /// Looks a session up without creating it: this thread's last hit, then its snapshot of the registry, then
+ /// the master registry itself (a registration can land after the snapshot was taken). False for an id that
+ /// is not registered.
+ ///
+ internal static bool TryGetSessionHandler(string sessionId, out Handler handler)
{
if (_sessionHandlerMasterVersion != _sessionHandlerLocalVersion)
{
@@ -66,18 +84,25 @@ public static Handler GetSessionHandler(string sessionId)
}
else if (ReferenceEquals(sessionId, _lastSessionId))
{
- return _lastSessionHandler;
+ handler = _lastSessionHandler;
+ return true;
}
- if (_sessionHandlersLocal.TryGetValue(sessionId, out var local))
+ if (_sessionHandlersLocal.TryGetValue(sessionId, out handler) || _sessionHandlersMaster.TryGetValue(sessionId, out handler))
{
_lastSessionId = sessionId;
- _lastSessionHandler = local;
- return local;
+ _lastSessionHandler = handler;
+ return true;
}
- Interlocked.Increment(ref _sessionHandlerMasterVersion);
- return _sessionHandlersMaster.GetOrAdd(sessionId, id => new Handler(id));
+ return false;
}
+ ///
+ /// Serves requests whose session id is not registered. It has no methods, no hooks and no serializer or
+ /// version policy of its own (the global ones apply), so every call answers -32601, parse errors and
+ /// batches behave as usual, and nothing is allocated or kept per unknown id.
+ ///
+ internal static Handler UnknownSessionHandler => _unknownSessionHandler;
+
///
/// gets the default session
///
@@ -767,7 +792,8 @@ internal static void WriteErrorEnvelope(PooledByteBufferWriter output, JsonRpcSe
output.Write(MessageInfix);
Utf8Json.WriteString(output, error.message);
output.Write(DataInfix);
- Utf8Json.WriteString(output, Convert.ToString(error.data));
+ if (error.data is Exception && !Config.IncludeExceptionDetails) Utf8Json.WriteNull(output);
+ else Utf8Json.WriteString(output, Convert.ToString(error.data));
}
output.Write(ErrorIdInfix);
WriteIdRaw(output, idRaw);
@@ -797,7 +823,10 @@ private static void WriteErrorData(IBufferWriter output, JsonRpcSerializer
}
break;
case Exception ex:
- serializer.Write(output, ExceptionInfo.ForResponse(ex), typeof(ExceptionInfo));
+ // With details off nothing about an unhandled exception leaves the process, not even its type
+ // name or message. Error handlers already ran and saw the exception itself in error.data.
+ if (Config.IncludeExceptionDetails) serializer.Write(output, ExceptionInfo.From(ex), typeof(ExceptionInfo));
+ else Utf8Json.WriteNull(output);
break;
default:
serializer.Write(output, data, data.GetType());
diff --git a/Json-Rpc/JsonRpcProcessor.Async.cs b/Json-Rpc/JsonRpcProcessor.Async.cs
index 2a9fac3..ad4b018 100644
--- a/Json-Rpc/JsonRpcProcessor.Async.cs
+++ b/Json-Rpc/JsonRpcProcessor.Async.cs
@@ -84,7 +84,7 @@ private static Task StartAsyncDocument(string sessionId, ReadOnlyMemory do
try
{
token.ThrowIfCancellationRequested();
- var handler = Handler.GetSessionHandler(sessionId);
+ if (!Handler.TryGetSessionHandler(sessionId, out var handler)) handler = Handler.UnknownSessionHandler;
serializer = serializer ?? handler.Serializer ?? Config.Serializer;
scratch.DocumentLength = document.Length;
var reader = scratch.GetReader(serializer);
diff --git a/Json-Rpc/JsonRpcProcessor.cs b/Json-Rpc/JsonRpcProcessor.cs
index 1230642..cd5b530 100644
--- a/Json-Rpc/JsonRpcProcessor.cs
+++ b/Json-Rpc/JsonRpcProcessor.cs
@@ -164,7 +164,7 @@ public static string ProcessSync(string sessionId, string jsonRpc, object jsonRp
private static void ProcessCore(string sessionId, ReadOnlyMemory document, IBufferWriter destination, object context, JsonRpcSerializer serializer, Scratch scratch, bool destinationIsScratch = false)
{
- var handler = Handler.GetSessionHandler(sessionId);
+ if (!Handler.TryGetSessionHandler(sessionId, out var handler)) handler = Handler.UnknownSessionHandler;
serializer = serializer ?? handler.Serializer ?? Config.Serializer;
// Always render into the rewindable scratch buffer, then hand the bytes to the caller's writer.
diff --git a/Json-Rpc/JsonRpcService.cs b/Json-Rpc/JsonRpcService.cs
index d8ec961..9d5d8cf 100644
--- a/Json-Rpc/JsonRpcService.cs
+++ b/Json-Rpc/JsonRpcService.cs
@@ -1,22 +1,30 @@
-namespace AustinHarris.JsonRpc
+namespace AustinHarris.JsonRpc
{
///
- /// For routing use SessionId
+ /// A base class whose constructor binds the instance's [JsonRpcMethod] members. Any class can be bound
+ /// with ; deriving from this class only saves that call.
///
public abstract class JsonRpcService
{
- protected JsonRpcService()
+ /// Binds this instance to the default session.
+ protected JsonRpcService() : this(true)
{
- ServiceBinder.BindService(Handler.DefaultSessionId(), this);
}
///
- /// Routing by SessionId
+ /// Binds this instance to the default session when is true. Pass false for a
+ /// service that something else binds: the AspNetCore package binds every registered service to its
+ /// effective session, and binds to any session.
///
- ///
+ protected JsonRpcService(bool autoBind)
+ {
+ if (autoBind) ServiceBinder.BindService(Handler.DefaultSessionId(), this);
+ }
+
+ /// Binds this instance to session , creating it when needed.
protected JsonRpcService(string sessionID)
{
ServiceBinder.BindService(sessionID, this);
}
}
-}
\ No newline at end of file
+}
diff --git a/Json-Rpc/Serialization/ExceptionInfo.cs b/Json-Rpc/Serialization/ExceptionInfo.cs
index 3aff0be..8145acd 100644
--- a/Json-Rpc/Serialization/ExceptionInfo.cs
+++ b/Json-Rpc/Serialization/ExceptionInfo.cs
@@ -43,10 +43,14 @@ public static ExceptionInfo From(Exception ex, bool includeDetails)
};
}
- /// The description sent to a client in error.data, governed by .
+ ///
+ /// The description sent to a client in error.data: the full description when
+ /// is true, otherwise null, because an unhandled exception is
+ /// then answered with data: null.
+ ///
public static ExceptionInfo ForResponse(Exception ex)
{
- return From(ex, Config.IncludeExceptionDetails);
+ return Config.IncludeExceptionDetails ? From(ex, true) : null;
}
}
}
diff --git a/README.md b/README.md
index 16cd67f..1dfda2c 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ All four packages are MIT licensed and ship together with the same version numbe
The "Covers" column is what each target framework admits, not what is tested. CI runs the test suite on `net8.0` and `net10.0`; the WebAssembly sample is built in CI and run by hand. The other runtimes can load the `netstandard` assets but are not part of the test matrix. On .NET Framework, 4.7.2 or later avoids the binding redirects that 4.6.1 to 4.7.1 need for `netstandard2.0` libraries.
-Dependencies at 2.0.0: `NonBlocking` 2.1.2 (the lock-free dictionary behind the session registry) and, on `netstandard` only, `System.Memory` 4.6.3; `netstandard2.0` also references `System.Threading.Tasks.Extensions` 4.5.0 for `ValueTask`. The Newtonsoft package depends on Newtonsoft.Json 13.0.4 and the System.Text.Json package on System.Text.Json 10.0.3.
+Dependencies at 2.0.0: none on `net8.0` and `net10.0`; on `netstandard` only, `System.Memory` 4.6.3, and `netstandard2.0` also references `System.Threading.Tasks.Extensions` 4.5.0 for `ValueTask`. The Newtonsoft package depends on Newtonsoft.Json 13.0.4 and the System.Text.Json package on System.Text.Json 10.0.3.
The core uses no reflection emit, so it runs under the WebAssembly interpreter and under WebAssembly AOT (see [samples/WasmHost](samples/WasmHost)). It is not annotated for trimming: services and their `[JsonRpcMethod]` members are found by reflection, so keep those types rooted if you publish trimmed.
@@ -124,7 +124,7 @@ That is the whole server. The rest of this page is about exposing methods, putti
### Classes
-Any class works, not only `JsonRpcService` subclasses: bind an instance with `ServiceBinder.BindService(sessionId, instance)`. A `JsonRpcService` subclass binds itself to the default session in its constructor, even when a host later binds the same instance to another session as well.
+Any class works, not only `JsonRpcService` subclasses: bind an instance with `ServiceBinder.BindService(sessionId, instance)`. A `JsonRpcService` subclass binds itself to the default session in its parameterless constructor. Write `: base(false)` for a subclass that something else binds (the AspNetCore host binds every registered service to its effective session) and `: base(sessionId)` to bind to another session.
One instance serves every request on every thread, so a service must be thread-safe. When the AspNetCore package builds a service through DI it is a singleton created once at startup; see [Kestrel HTTP endpoint](#kestrel-http-endpoint).
@@ -238,16 +238,16 @@ The core runs inside the browser. [samples/WasmHost](samples/WasmHost) is a Blaz
### Exception disclosure
-By default (`Config.IncludeExceptionDetails = false`), an unhandled exception thrown by a method becomes `-32603 Internal error` and `error.data` carries the exception's fully qualified CLR type name and its `Message`. Source, stack trace, HResult and inner exceptions are omitted. This is limited disclosure, not complete redaction: exception messages must not contain secrets.
-
-Set `Config.IncludeExceptionDetails = true` only for trusted development clients; it adds `Source`, `StackTraceString`, `HResult` and the `InnerException` chain. To suppress the type and message as well, replace internal errors in an error handler. The handler sees the original `Exception` in `data` for this case, so filter on that rather than on the code:
+By default (`Config.IncludeExceptionDetails = false`), an unhandled exception thrown by a method, or thrown while its result is written, becomes `-32603 Internal Error` with `data: null`. Nothing about the exception leaves the process: not its type name, not its message. The redaction happens when the response is written, after the error handler ran, so a handler still sees the original `Exception` in `data` and can decide what the client gets instead:
```csharp
Config.SetErrorHandler((request, error) =>
- error.data is Exception ? new JsonRpcException(-32603, "Internal Error", null) : error);
+ error.data is Exception ex ? new JsonRpcException(-32000, "Server error", Log(ex)) : error);
```
-A `JsonRpcException` thrown by the application keeps the `data` it was given.
+Set `Config.IncludeExceptionDetails = true` only for trusted development clients: `data` then carries the full `ExceptionInfo` (`ClassName`, `Message`, `Source`, `StackTraceString`, `HResult` and the `InnerException` chain).
+
+A `JsonRpcException` thrown by the application, or returned by an error handler, keeps the `data` it was given; that data is authored, not redacted.
### Handlers
@@ -263,11 +263,11 @@ Config.SetPostProcessHandler((request, response, context) => null); // return
// Another session: other sessions do not inherit the default session's handlers
Config.SetErrorHandler("client-42", (request, exception) => exception);
Config.SetParseErrorHandler("client-42", (rawJson, exception) => exception);
-Config.SetBeforeProcessHandler("client-42", (request, context) => null);
-Handler.GetSessionHandler("client-42").SetPostProcessHandler((request, response, context) => null);
+Config.SetPreProcessHandler("client-42", (request, context) => null);
+Config.SetPostProcessHandler("client-42", (request, response, context) => null);
```
-The overloads without a session id set the **default session's** handler, not a process-wide one. A pre- or post-process handler moves its session onto a slower path, which builds `JsonRequest` and `JsonResponse` objects for the handler to see. Leave them unset unless you need them.
+The overloads without a session id set the **default session's** handler, not a process-wide one; the overloads with a session id create the session when it does not exist yet, and a null handler clears only that session's. A pre- or post-process handler moves its session onto a slower path, which builds `JsonRequest` and `JsonResponse` objects for the handler to see. Leave them unset unless you need them. (`Config.SetBeforeProcessHandler(sessionId, …)`, the 1.x name, still works and is marked obsolete.)
### Error codes
@@ -278,7 +278,7 @@ The errors the library raises itself carry structured `data`, identical for ever
| `-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 disclosure](#exception-disclosure) | `Exception` |
+| `-32603` Internal error: the method threw, its result could not be written, or a parameter's type is one the serializer cannot handle | `null`, or the full `ExceptionInfo` when `Config.IncludeExceptionDetails` is on, see [Exception disclosure](#exception-disclosure) | `Exception` |
In the second `-32602` row, "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).
@@ -363,7 +363,7 @@ string response = await JsonRpcProcessor.Process("client-42", request, context);
Handler.DestroySession("client-42");
```
-Sessions are stored in a process-wide registry. Looking up an unknown session id creates a session that remains until `Handler.DestroySession(sessionId)` is called, and each new id makes every thread refresh its copy of the registry on its next lookup. So a session selector must map to a fixed set of ids: validate and bound ids obtained from routes, headers or other untrusted input, never feed arbitrary client values into one, and destroy tenant- or connection-scoped sessions when their lifetime ends.
+Sessions are stored in a process-wide registry. Binding (`ServiceBinder.BindService`, `BindMethod`, `BindInterface`, a `JsonRpcService` constructor), the per-session `Config` setters and `Handler.GetSessionHandler(sessionId)` create a session; it remains until `Handler.DestroySession(sessionId)` is called. A request for a session id that was never registered creates nothing: every call in it answers `-32601` and the default session's methods are not reachable through it, so an id taken from a route or header cannot grow the registry. Each registration or destruction makes every thread refresh its copy of the registry on its next lookup, so register at startup or when a connection or tenant appears, not per request, and destroy tenant- or connection-scoped sessions when their lifetime ends.
Pass an arbitrary context object through to your methods and read it with `Handler.RpcContext()` or `JsonRpcContext.Current().Value` (the AspNetCore package passes the `HttpContext` or `ConnectionContext`):
@@ -405,7 +405,7 @@ Settings live on `Config`. They do not all reach every scope:
| Serializer | `serializer` argument | `Config.SetSerializer(sessionId, …)` | `Config.SetSerializer(…)` / `Config.Serializer` |
| `jsonrpc` version policy | | `Config.SetVersionPolicy(sessionId, …)` | `Config.VersionPolicy` |
| Exception details | | | `Config.IncludeExceptionDetails` |
-| Error, parse-error, pre- and post-process handlers | | yes (see [Handlers](#handlers)) | no: the overloads without a session id set the **default session's** handler |
+| Error, parse-error, pre- and post-process handlers | | `Config.Set…Handler(sessionId, …)` (see [Handlers](#handlers)) | no: the overloads without a session id set the **default session's** handler |
Where more than one scope applies, the narrowest one wins. Context and cancellation are supplied per call.
@@ -455,12 +455,12 @@ The default keeps tool harnesses that omit the member working while a client spe
What the library does by default:
-- **Exception details are off.** An unhandled exception reaches the client as `-32603` with its type name and message only; the message is always sent, so rewrite sensitive messages in an error handler. `Config.IncludeExceptionDetails = true` adds the stack trace, source, HResult and inner exceptions; use it in development only. See [Exception disclosure](#exception-disclosure).
+- **Exception details are off.** An unhandled exception reaches the client as `-32603` with `data: null`: no type name, no message. `Config.IncludeExceptionDetails = true` sends the type, message, stack trace, source, HResult and inner exceptions; use it in development only. See [Exception disclosure](#exception-disclosure).
- **Rejected values are not echoed.** A `-32602` conversion error names the parameter and the expected type, never the value sent.
- **Nesting is limited to 64 levels.** A deeper request is `-32700` before any of your code runs.
- **Request size is limited on the Kestrel host only.** `MaxRequestBytes` defaults to 4 MB: HTTP answers `413`, a raw connection is aborted. The core itself does not limit document length; that is the transport's job. There is no limit on how many requests a batch holds, no response-size limit and no request deadline; a batch runs sequentially, so a 4 MB batch of small requests ties up one request's worth of server time for all of them.
- **Every `[JsonRpcMethod]` is callable.** Visibility does not matter (private methods are exposed), and `AddJsonRpcServicesFromAssembly` exposes every class in the assembly that carries the attribute.
-- **Sessions are created on lookup and kept.** An unknown session id creates a session that lives until it is destroyed; see [Sessions and context](#sessions-and-context).
+- **Requests do not create sessions.** An unknown session id answers `-32601` and leaves the registry alone; sessions are created by binding and by the per-session `Config` setters, and live until destroyed; see [Sessions and context](#sessions-and-context).
- **Cancellation is cooperative.** It waits for a running method and cannot undo what the method already did.
What it leaves to you:
@@ -631,7 +631,7 @@ Most 1.x services run unchanged. Read the first list before you build, and the s
- **Parse errors.** 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.
- **Batches.** 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.
- **Notifications.** 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`.
-- **Exceptions.** Stack traces, sources, HResults and inner exceptions are omitted by default, but the exception type and message are still returned; see [Exception disclosure](#exception-disclosure).
+- **Exceptions.** An unhandled exception is `-32603` with `data: null` by default; 1.x sent the exception's type, message and stack trace. `Config.IncludeExceptionDetails = true` sends the full description; an error handler can author something in between. See [Exception disclosure](#exception-disclosure).
- **Conversion errors.** 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`).
- **Method not found.** `-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).
- **Named parameters.** They 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.
@@ -644,11 +644,19 @@ Most 1.x services run unchanged. Read the first list before you build, and the s
- **Binding.** `ServiceBinder.BindMethod(sessionId, name, delegate)` registers any delegate; it refuses a name that is already registered, unlike `Handler.RegisterFuction`, which keeps replacing silently.
- **Pre-process handlers.** 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.
- **Context.** `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.
+- **Sessions.** A request for a session id that was never registered no longer creates the session; it answers `-32601`. Bind services or call `Handler.GetSessionHandler(sessionId)` before serving a session. `Config.SetBeforeProcessHandler(sessionId, …)` is now `Config.SetPreProcessHandler(sessionId, …)` (the old name still compiles, with an obsolete warning), and `Config.SetPostProcessHandler(sessionId, …)` exists.
+- **`JsonRpcService`.** The AspNetCore host binds a subclass to the configured session even when that is the default session; a subclass can pass `base(false)` to skip binding itself.
- **`Handler.Handle(JsonRequest)`** still works; it round-trips the request through the serializer and the boxed path.
## Versioning and support
-The four 2.x packages are built from one repository and released together at one version number (2.0.0 at this writing); use matching versions. The targets that are tested are the ones listed under [Requirements](#requirements). Breaking changes are listed under [Upgrading from 1.x](#upgrading-from-1x) and in each package's release notes on NuGet. Questions and bugs go to [GitHub issues](https://github.com/Astn/JSON-RPC.NET/issues).
+- **Versioning.** The 2.x packages follow [Semantic Versioning](https://semver.org/) for the public API and the wire behaviour documented here: a breaking change to either arrives only in a new major version.
+- **Releases.** The four packages are built from one repository, carry one version number and are released together; use matching versions. There is no release cadence.
+- **Tested** means the `net8.0` and `net10.0` test runs on Windows and Linux listed under [Requirements](#requirements). Other runtimes can load the `netstandard` assets and are not tested.
+- **Trimming** is unsupported until the library is annotated and that is validated in CI.
+- **1.x** receives no further releases.
+- **Changes** are recorded per version in [CHANGELOG.md](CHANGELOG.md); the NuGet release notes link there.
+- **Vulnerabilities** are reported privately, see [SECURITY.md](SECURITY.md). Questions and bugs go to [GitHub issues](https://github.com/Astn/JSON-RPC.NET/issues).
## Building
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..f68a8f8
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,24 @@
+# Security
+
+## Reporting a vulnerability
+
+Report vulnerabilities privately through GitHub's private vulnerability reporting for this repository:
+[github.com/Astn/JSON-RPC.NET/security/advisories/new](https://github.com/Astn/JSON-RPC.NET/security/advisories/new).
+Do not open a public issue for a vulnerability.
+
+Reports are read and handled by the maintainer as time allows. There is no guaranteed response time and no bug
+bounty. A fix, when one is needed, ships as a new version of the affected package with the advisory published
+alongside it.
+
+## Supported versions
+
+| Version | Supported |
+| --- | --- |
+| 2.x | yes, on the tested targets (`net8.0` and `net10.0`; the `netstandard` assets are untested) |
+| 1.x | no: no further 1.x releases are planned |
+
+## What the library does and does not do
+
+The README's [Security](README.md#security) section lists what the library does by default (exception
+redaction, nesting limit, request-size limit on the Kestrel host) and what it leaves to the host: authentication,
+authorisation, transport security, rate limiting and deadlines.
diff --git a/benchmarks/Micro/README.md b/benchmarks/Micro/README.md
index 821f8ee..5123b60 100644
--- a/benchmarks/Micro/README.md
+++ b/benchmarks/Micro/README.md
@@ -13,6 +13,7 @@ Benchmark classes:
- `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.
+- `SessionRegistryBenchmarks`: the session registry on the request path (last hit, snapshot, unknown id, register/lookup/destroy, one request end to end), each quiet and with a background thread registering and destroying sessions (`Churn`), which forces the snapshot refresh on every lookup. Use it to judge a change to the registry or its dictionary type: the `Churn` rows show what registry changes cost requests.
- `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`, but only between runs on an idle machine or within one run: background load biases ratios as well as absolute numbers, which is why `BindingComparisonBenchmarks` puts both registrations in one process.
diff --git a/benchmarks/Micro/SessionRegistryBenchmarks.cs b/benchmarks/Micro/SessionRegistryBenchmarks.cs
new file mode 100644
index 0000000..fd13341
--- /dev/null
+++ b/benchmarks/Micro/SessionRegistryBenchmarks.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Text;
+using System.Threading;
+using AustinHarris.JsonRpc.Serialization;
+using BenchmarkDotNet.Attributes;
+
+namespace AustinHarris.JsonRpc.Micro
+{
+ ///
+ /// The session registry on the request path: the per-thread last hit, the per-thread snapshot, a miss that
+ /// falls through to the master registry, and lookups while another thread registers and destroys sessions
+ /// (every change makes each thread copy the master registry on its next lookup). The rows with "Churn" run
+ /// with that background thread active; compare them with the quiet rows to see what the master dictionary
+ /// costs the request path, which is what the choice of dictionary type is judged by.
+ ///
+ [MemoryDiagnoser(displayGenColumns: false)]
+ public class SessionRegistryBenchmarks
+ {
+ private const int Registered = 64;
+ private static readonly string Stable = "registry-stable";
+ private static readonly string Unknown = "registry-unknown-" + Guid.NewGuid().ToString("N");
+
+ private string[] _ids;
+ private int _next;
+ private PooledByteBufferWriter _out;
+ private ReadOnlyMemory _request;
+ private Thread _churn;
+ private volatile bool _stop;
+
+ public sealed class Service
+ {
+ [JsonRpcMethod] private int addInt(int l, int r) => l + r;
+ }
+
+ [Params(false, true)]
+ public bool Churn { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ ServiceBinder.BindService(Stable, new Service());
+ _ids = new string[Registered];
+ for (int i = 0; i < Registered; i++)
+ {
+ _ids[i] = "registry-" + i;
+ ServiceBinder.BindService(_ids[i], new Service());
+ }
+ _out = new PooledByteBufferWriter(256);
+ _request = Encoding.UTF8.GetBytes("{\"method\":\"addInt\",\"params\":[1,7],\"id\":2}");
+ if (Churn)
+ {
+ _stop = false;
+ _churn = new Thread(() =>
+ {
+ int n = 0;
+ while (!_stop)
+ {
+ string id = "registry-churn-" + (n++ & 1023);
+ ServiceBinder.BindService(id, new Service());
+ Handler.DestroySession(id);
+ Thread.SpinWait(200);
+ }
+ }) { IsBackground = true };
+ _churn.Start();
+ }
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _stop = true;
+ _churn?.Join();
+ Handler.DestroySession(Stable);
+ foreach (var id in _ids) Handler.DestroySession(id);
+ }
+
+ /// The same string instance every time: the reference-equality last-hit path.
+ [Benchmark(Baseline = true)]
+ public Handler Lookup_LastHit() => Handler.GetSessionHandler(Stable);
+
+ /// A different registered id every call: the per-thread snapshot dictionary.
+ [Benchmark]
+ public Handler Lookup_Rotating()
+ {
+ int i = _next++;
+ return Handler.GetSessionHandler(_ids[i & (Registered - 1)]);
+ }
+
+ /// An id that is not registered: snapshot miss, then master miss, no session created.
+ [Benchmark]
+ public bool Lookup_Unknown() => Handler.TryGetSessionHandler(Unknown, out _);
+
+ /// Register, look up from the request path and destroy: the master registry mutated per call.
+ [Benchmark]
+ public bool RegisterLookupDestroy()
+ {
+ string id = "registry-transient-" + (_next++ & 255);
+ Handler.GetSessionHandler(id);
+ bool found = Handler.TryGetSessionHandler(id, out _);
+ Handler.DestroySession(id);
+ return found;
+ }
+
+ /// One request end to end on the stable session, through the byte-first processor.
+ [Benchmark]
+ public int Process_Stable()
+ {
+ _out.Clear();
+ JsonRpcProcessor.Process(Stable, _request, _out);
+ return _out.WrittenCount;
+ }
+ }
+}
diff --git a/docs/serializers.md b/docs/serializers.md
index 82fa618..b31e000 100644
--- a/docs/serializers.md
+++ b/docs/serializers.md
@@ -105,7 +105,7 @@ count as one level.
- notifications (no `id`) never get a response, whatever the outcome; an invalid request object is not a notification and gets `-32600` with `"id":null`
- 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
- 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
-- `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
+- `Exception` in `error.data` written as `null` unless `Config.IncludeExceptionDetails` is true, in which case it is normalised to `ExceptionInfo {ClassName, Message, Source, StackTraceString, HResult, InnerException}`
- the error boundary: request, binding, handler and method failures become JSON-RPC errors (`-32602` for an argument the serializer refused, `-32603` otherwise, unless the error handler maps it). Invalid arguments (a null document), cancellation, and exceptions from your own error handler, custom reader or output writer reach the caller
- 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