diff --git a/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj b/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj index b80da3b..67c1b83 100644 --- a/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj +++ b/AustinHarris.JsonRpc.AspNetCore/AustinHarris.JsonRpc.AspNetCore.csproj @@ -6,8 +6,8 @@ Json-Rpc.Net ASP.NET Core host ASP.NET Core hosting for AustinHarris.JsonRpc. Adds HTTP endpoints and raw Kestrel connections over TCP, Unix sockets and named pipes, with dependency injection. 2.0.0 - preview.2 - 2.0.0-preview.2, released with the core package. What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html + preview.3 + 2.0.0-preview.3, released with the core package: strong-named assemblies and the error-handler change. What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html Austin Harris https://astn.github.io/JSON-RPC.NET/aspnetcore.html https://github.com/Astn/JSON-RPC.NET @@ -26,6 +26,11 @@ true icon.png true + + true + $(MSBuildThisFileDirectory)..\AustinHarris.JsonRpc.snk AustinHarris.JsonRpc.AspNetCore diff --git a/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj b/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj index b52e426..5fcdfd4 100644 --- a/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj +++ b/AustinHarris.JsonRpc.Newtonsoft/AustinHarris.JsonRpc.Newtonsoft.csproj @@ -6,8 +6,8 @@ Json-Rpc.Net Json.NET serializer Json.NET serializer for AustinHarris.JsonRpc. Uses JsonSerializerSettings for converters, contract resolvers and lenient JSON input. 2.0.0 - preview.2 - 2.0.0-preview.2, released with the core package. What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html + preview.3 + 2.0.0-preview.3, released with the core package: strong-named assemblies and the error-handler change. What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html Austin Harris https://astn.github.io/JSON-RPC.NET/newtonsoft.html https://github.com/Astn/JSON-RPC.NET @@ -26,6 +26,11 @@ true icon.png true + + true + $(MSBuildThisFileDirectory)..\AustinHarris.JsonRpc.snk AustinHarris.JsonRpc.Newtonsoft diff --git a/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj b/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj index 26a6c26..b3e6866 100644 --- a/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj +++ b/AustinHarris.JsonRpc.SystemTextJson/AustinHarris.JsonRpc.SystemTextJson.csproj @@ -6,8 +6,8 @@ Json-Rpc.Net System.Text.Json serializer System.Text.Json serializer for AustinHarris.JsonRpc. Reads UTF-8 parameters and writes results directly to the response buffer, configured with JsonSerializerOptions. 2.0.0 - preview.2 - 2.0.0-preview.2, released with the core package. What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html + preview.3 + 2.0.0-preview.3, released with the core package: strong-named assemblies and the error-handler change. What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html Austin Harris https://astn.github.io/JSON-RPC.NET/systemtextjson.html https://github.com/Astn/JSON-RPC.NET @@ -25,6 +25,11 @@ true icon.png true + + true + $(MSBuildThisFileDirectory)..\AustinHarris.JsonRpc.snk AustinHarris.JsonRpc.SystemTextJson diff --git a/AustinHarris.JsonRpc.snk b/AustinHarris.JsonRpc.snk new file mode 100644 index 0000000..38e0c8f Binary files /dev/null and b/AustinHarris.JsonRpc.snk differ diff --git a/AustinHarris.JsonRpcTestN/AustinHarris.JsonRpcTestN.csproj b/AustinHarris.JsonRpcTestN/AustinHarris.JsonRpcTestN.csproj index 2d35f0e..90efc39 100644 --- a/AustinHarris.JsonRpcTestN/AustinHarris.JsonRpcTestN.csproj +++ b/AustinHarris.JsonRpcTestN/AustinHarris.JsonRpcTestN.csproj @@ -4,6 +4,9 @@ Austin Harris net8.0;net10.0 false + + true + $(MSBuildThisFileDirectory)..\\AustinHarris.JsonRpc.snk diff --git a/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs b/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs index c64545e..6aec056 100644 --- a/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs +++ b/AustinHarris.JsonRpcTestN/DispatchHardeningTests.cs @@ -385,14 +385,17 @@ public void ExceptionDetails_AreSentWhenEnabled(string name) { Config.IncludeExceptionDetails = true; var response = Parse(Run("{\"method\":\"throwsInner\",\"id\":1}", null, serializer)); + // the exception the method threw, with its whole cause chain: the library does not substitute the cause var data = (JObject)response["error"]["data"]; - Assert.AreEqual("System.ArgumentException", (string)data["ClassName"]); - Assert.AreEqual("inner message", (string)data["Message"]); + Assert.AreEqual("System.InvalidOperationException", (string)data["ClassName"]); + Assert.AreEqual("outer 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"]); + Assert.AreEqual(new InvalidOperationException().HResult, (int)data["HResult"]); + Assert.AreEqual("System.ArgumentException", (string)data["InnerException"]["ClassName"]); + Assert.AreEqual("inner message", (string)data["InnerException"]["Message"]); StringAssert.Contains("ThrowsInner", (string)data["InnerException"]["StackTraceString"]); + Assert.AreEqual("System.Exception", (string)data["InnerException"]["InnerException"]["ClassName"]); + Assert.AreEqual("innermost message", (string)data["InnerException"]["InnerException"]["Message"]); response = Parse(Run("{\"method\":\"throws\",\"id\":1}", null, serializer)); data = (JObject)response["error"]["data"]; diff --git a/AustinHarris.JsonRpcTestN/ErrorHandlerExceptionTests.cs b/AustinHarris.JsonRpcTestN/ErrorHandlerExceptionTests.cs new file mode 100644 index 0000000..0a101bc --- /dev/null +++ b/AustinHarris.JsonRpcTestN/ErrorHandlerExceptionTests.cs @@ -0,0 +1,166 @@ +using System; +using System.Buffers; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// What an error handler receives in error.data when a method throws: the exception the method threw, + /// with its own cause still attached. Only the wrappers the invocation machinery adds are stripped, a + /// and an holding one exception; an + /// inner exception is never substituted for the outer one, and an inner is not + /// promoted. The same rule on the synchronous and the asynchronous path. + /// + [TestFixture] + public sealed class ErrorHandlerExceptionTests + { + private string _session; + private Exception _seen; + + [SetUp] + public void SetUp() + { + _session = "error-handler-" + Guid.NewGuid().ToString("N"); + _seen = null; + Handler.GetSessionHandler(_session).SetErrorHandler((request, error) => { _seen = error.data as Exception; return error; }); + } + + [TearDown] + public void TearDown() => Handler.DestroySession(_session); + + private static string Request(string method, string id = "1") => "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + "\"" + (id == null ? "" : ",\"id\":" + id) + "}"; + + private string Sync(string json) + { + var output = new ArrayBufferWriter(); + JsonRpcProcessor.Process(_session, Encoding.UTF8.GetBytes(json).AsSpan(), output); + return Encoding.UTF8.GetString(output.WrittenSpan); + } + + private Task Async(string json) => JsonRpcProcessor.ProcessAsync(_session, json); + + private static JObject Error(string json, int code) + { + var response = JObject.Parse(json); + Assert.IsNotNull(response["error"], json); + Assert.AreEqual(code, (int)response["error"]["code"], json); + return response; + } + + /// + /// Binds as a synchronous method, a Task method that has already suspended, and a + /// ValueTask method that faults synchronously; "hooked" runs the synchronous method with a pre-process handler + /// installed, which takes the hook path through the request objects. + /// + private void Bind(Func thrower) + { + ServiceBinder.BindMethod(_session, "sync", new Func(() => throw thrower())); + ServiceBinder.BindMethod(_session, "hooked", new Func(() => throw thrower())); + ServiceBinder.BindMethod(_session, "async", new Func>(async () => { await Task.Yield(); throw thrower(); })); + ServiceBinder.BindMethod(_session, "valueTask", new Func>(() => new ValueTask(Task.FromException(thrower())))); + } + + private async Task Run(string path, string id = "1") + { + if (path == "hooked") + { + Config.SetPreProcessHandler(_session, (request, context) => null); + return Sync(Request(path, id)); + } + return path == "sync" ? Sync(Request(path, id)) : await Async(Request(path, id)); + } + + [TestCase("sync")] [TestCase("hooked")] [TestCase("async")] [TestCase("valueTask")] + public async Task ThrownException_ReachesTheHandler_WithItsCause(string path) + { + Bind(() => new InvalidOperationException("outer", new IOException("cause"))); + string response = await Run(path); + var error = Error(response, -32603); + Assert.AreEqual(JTokenType.Null, error["error"]["data"].Type, "redacted on the wire by default"); + Assert.IsInstanceOf(_seen, "the handler gets the exception that was thrown, not its cause"); + Assert.AreEqual("outer", _seen.Message); + Assert.IsInstanceOf(_seen.InnerException, "with its cause still attached"); + } + + [TestCase("sync")] [TestCase("hooked")] [TestCase("async")] [TestCase("valueTask")] + public async Task InnerJsonRpcException_IsNotPromoted(string path) + { + Bind(() => new InvalidOperationException("outer", new JsonRpcException(-32040, "authored inside", "data"))); + string response = await Run(path); + Error(response, -32603); + Assert.IsInstanceOf(_seen); + Assert.AreEqual("outer", _seen.Message); + } + + [TestCase("sync")] [TestCase("hooked")] [TestCase("async")] [TestCase("valueTask")] + public async Task AuthoredException_IsPassedAsThrown(string path) + { + Bind(() => new JsonRpcException(-32040, "authored", "ticket 42")); + string response = await Run(path); + var error = Error(response, -32040); + Assert.AreEqual("authored", (string)error["error"]["message"]); + Assert.AreEqual("ticket 42", (string)error["error"]["data"]); + } + + [TestCase("sync")] [TestCase("hooked")] [TestCase("async")] [TestCase("valueTask")] + public async Task TargetInvocationWrapper_IsStripped(string path) + { + Bind(() => new TargetInvocationException(new JsonRpcException(-32040, "authored", null))); + Error(await Run(path), -32040); + } + + [TestCase("sync", false)] [TestCase("sync", true)] [TestCase("hooked", false)] [TestCase("hooked", true)] + [TestCase("async", false)] [TestCase("async", true)] [TestCase("valueTask", false)] [TestCase("valueTask", true)] + public async Task Aggregate_OnlyASingleInner_IsStripped(string path, bool several) + { + var authored = new JsonRpcException(-32040, "authored", null); + Bind(() => several ? new AggregateException(authored, new Exception("second")) : new AggregateException(authored)); + Error(await Run(path), several ? -32603 : -32040); + if (several) Assert.IsInstanceOf(_seen, "an aggregate of several failures is passed whole"); + } + + [TestCase("sync")] [TestCase("hooked")] [TestCase("async")] [TestCase("valueTask")] + public async Task Notification_StillReachesTheHandler(string path) + { + Bind(() => new InvalidOperationException("outer", new IOException("cause"))); + Assert.AreEqual("", await Run(path, id: null), "a notification produces no response"); + Assert.IsInstanceOf(_seen, "but the handler still sees what was thrown"); + Assert.IsInstanceOf(_seen.InnerException); + } + + [TestCase("sync")] [TestCase("async")] + public async Task ConversionFailure_StaysInvalidParams(string path) + { + ServiceBinder.BindMethod(_session, "sync", new Func(n => n)); + ServiceBinder.BindMethod(_session, "async", new Func>(async n => { await Task.Yield(); return n; })); + string json = "{\"jsonrpc\":\"2.0\",\"method\":\"" + path + "\",\"params\":[\"many\"],\"id\":1}"; + string response = path == "sync" ? Sync(json) : await Async(json); + var error = Error(response, -32602); + Assert.AreEqual("conversion", (string)error["error"]["data"]["reason"], response); + } + + [TestCase("sync")] [TestCase("hooked")] [TestCase("async")] [TestCase("valueTask")] + public async Task WithDetailsOn_TheWireCarriesTheOuterExceptionAndItsCause(string path) + { + Bind(() => new InvalidOperationException("outer", new IOException("cause"))); + Config.IncludeExceptionDetails = true; + try + { + string response = await Run(path); + var data = (JObject)Error(response, -32603)["error"]["data"]; + Assert.AreEqual("outer", (string)data["Message"], response); + Assert.AreEqual("cause", (string)data["InnerException"]["Message"], response); + } + finally + { + Config.IncludeExceptionDetails = false; + } + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e42899..83ae069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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 (in preview: `2.0.0-preview.2`) +## 2.0.0 (in preview: `2.0.0-preview.3`) ### Added @@ -33,6 +33,8 @@ behaviour: a breaking change to either means a new major version. ### Changed +- The four assemblies are strong-named (preview.3). One key, `AustinHarris.JsonRpc.snk` at the repository root, signs the packages and the friend assemblies, and stays the same from here on: changing a strong-name key changes the assembly identity. Public key token `e6819c02cf4aec44`. A signed caller can now reference the packages without CS8002, and .NET Framework loads them from a signed assembly. +- An error handler receives the exception the method threw (preview.3). Previously the library replaced an exception that had an `InnerException` with that inner exception, and promoted an inner `JsonRpcException` to the response, so a handler saw the cause instead of the error and an authored outer error lost its message. Now only the wrappers the invocation machinery adds are stripped, a `TargetInvocationException` and a single-inner `AggregateException`, on the synchronous and asynchronous paths alike; everything else, inner exception included, reaches the handler as thrown. - `RpcMethod.FromMethod` is `RpcMethod.FromMethodInfo`; `RpcInterfaceMethod.Method` is `RpcInterfaceMethod.MethodInfo`. A `MethodInfo` is always spelled out; "method" means the JSON-RPC method. - The session parameter is spelled `sessionId` on every overload (`BindService`, `Handler.RegisterInstance` and the `JsonRpcService` constructor used `sessionID`). - `Handler.RegisterFuction` and `UnRegisterFunction` are obsolete; use `ServiceBinder.BindMethod` and `UnbindMethod` (which throw on a duplicate name instead of replacing it). diff --git a/Json-Rpc/AustinHarris.JsonRpc.csproj b/Json-Rpc/AustinHarris.JsonRpc.csproj index 2420d57..b6b5bfd 100644 --- a/Json-Rpc/AustinHarris.JsonRpc.csproj +++ b/Json-Rpc/AustinHarris.JsonRpc.csproj @@ -6,7 +6,7 @@ Json-Rpc.Net Core High-throughput JSON-RPC 2.0 server library for .NET Standard 2.0 through .NET 10: UTF-8 bytes in and out, no JSON library dependency, with Json.NET, System.Text.Json and ASP.NET Core hosting as companion packages. 2.0.0 - preview.2 + preview.3 Austin Harris https://astn.github.io/JSON-RPC.NET/ https://github.com/Astn/JSON-RPC.NET @@ -14,7 +14,7 @@ MIT README.md json-rpc;jsonrpc;json;rpc;server;netstandard;netstandard2.0;netstandard2.1;net8.0;net10.0;kestrel;pipelines;system.text.json;json.net;fast - 2.0.0-preview.2: document and batch limits on by default (JsonRpcLimits), reserved method names refused at registration, InProcessClient removed, diagnostic ids on the obsolete members, and the documentation site. Since preview.1 the request pipeline is built around UTF-8 bytes, the JSON serializer is pluggable and Task and ValueTask methods are supported again, at six times the throughput of 1.2.3 through the 1.x string API and twenty-four times through the byte entry points, measured on a 32-core Hugging Face Jobs host (see the README). What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html + 2.0.0-preview.3: the four assemblies are strong-named with one key that stays fixed from here on, and an error handler now receives the exception the method threw, with its cause attached, instead of that exception's inner exception. preview.2 brought document and batch limits on by default (JsonRpcLimits), reserved method names refused at registration, InProcessClient removed, diagnostic ids on the obsolete members, and the documentation site. Since preview.1 the request pipeline is built around UTF-8 bytes, the JSON serializer is pluggable and Task and ValueTask methods are supported again, at six times the throughput of 1.2.3 through the 1.x string API and twenty-four times through the byte entry points, measured on a 32-core Hugging Face Jobs host (see the README). What is new: https://astn.github.io/JSON-RPC.NET/changelog.html. Upgrading from 1.x: https://astn.github.io/JSON-RPC.NET/upgrading.html netstandard2.0;netstandard2.1;net8.0;net10.0 latest true @@ -25,13 +25,21 @@ true icon.png true + + true + $(MSBuildThisFileDirectory)..\AustinHarris.JsonRpc.snk + + 00240000048000001401000006020000002400005253413100080000010001006d0e583ea8b6342793e065255a721da27415b51e2f9c0e2df201cf0230a91d6b750cc9379ac14585d9b4aa22530fc82d50c9ef6dad4df75f9ed5efb80a7e00f51dc21706570c8811adacdc00b7e3209db94ee012f36714fa5da17e6da669b90b4166a39830f062dc415fa30291322cdf943bb0337516f3fcb77e9f760db917fd0f8e05368dacfd9a8f0a38374dd32d95a825bf04d7f26d71dc2e8c2bbb61a54c6a760c6b6b255e63e9e1025cc436b5d27070dfb693afd6de89a94dfd33a4b42358e143d148aade9f2c7223b86c7a0dd44888ed5fe35a9c6fec1db6117f43d030f0ca83264dc05ff1bbb9f89617e4383b65a8f2aa52a22b268a77b6939a56e7d1 - - + + diff --git a/Json-Rpc/Handler.Async.cs b/Json-Rpc/Handler.Async.cs index 31340bc..1301225 100644 --- a/Json-Rpc/Handler.Async.cs +++ b/Json-Rpc/Handler.Async.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using AustinHarris.JsonRpc.Invocation; @@ -234,38 +233,13 @@ private bool StreamingFailure(JsonRpcRequestReader reader, RpcMethod method, int 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); + error = error != null ? ProcessException(reader, error) : MapException(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) { @@ -298,7 +272,7 @@ private async ValueTask HandleBoxedAsync(JsonRpcRequestReader read catch (Exception ex) { var failed = new JsonRequest(method, null, id); - return PostProcess(failed, new JsonResponse { Error = MapAsyncException(failed, ex), Id = id }, context); + return PostProcess(failed, new JsonResponse { Error = MapException(failed, ex), Id = id }, context); } var request = new JsonRequest(method, parameters, id); JsonRpcException preError; @@ -335,7 +309,7 @@ private async ValueTask InvokeModifiedAsync(JsonRequest request, J 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 }; } + catch (Exception ex) { return new JsonResponse { Error = MapException(request, ex), Id = request.Id }; } finally { reader?.Release(); } } } @@ -390,9 +364,8 @@ private JsonResponse BoxedResponse(JsonRequest request, object result, JsonRpcEx 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 }; + return new JsonResponse { Error = error != null ? ProcessException(request, error) : MapException(request, ex), Id = request.Id }; } } } diff --git a/Json-Rpc/Handler.cs b/Json-Rpc/Handler.cs index 3a80a1c..83ffe12 100644 --- a/Json-Rpc/Handler.cs +++ b/Json-Rpc/Handler.cs @@ -913,16 +913,37 @@ private static object SafeIdValue(JsonRpcRequestReader reader) } } + /// + /// Maps what an invocation threw to the error the handler sees: an authored is + /// passed as it is, anything else becomes -32603 with the thrown exception as data. Only the two + /// wrapper types are stripped first (); the exception's own + /// is never substituted for it, so an error handler always receives + /// the object the method threw, with its cause still attached. + /// private JsonRpcException MapException(JsonRequest request, Exception ex) { - if (ex is TargetInvocationException tie && tie.InnerException != null) ex = tie.InnerException; + ex = UnwrapInvocation(ex); 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)); } + /// + /// Strips the two wrapper types that reflection and task machinery put around what a method threw: a + /// , and an holding exactly one + /// exception (a faulted task observed through Result or Wait). The decision is by type alone, + /// so a method that throws one of these itself is unwrapped the same way. An aggregate of several failures + /// is kept whole, since no single one of them is "the" error. Nothing else is unwrapped. + /// + private static Exception UnwrapInvocation(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; + } + } + /// /// 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 @@ -934,8 +955,8 @@ private JsonRpcException MapException(JsonRequest request, Exception ex) /// 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; + ex = UnwrapInvocation(ex); + if (ex is JsonRpcException) return null; if (!ParameterErrorInfo.IsConversionFailure(ex)) return null; var parameters = method.Parameters; for (int p = 0; p < parameters.Length; p++) diff --git a/Json-Rpc/README.md b/Json-Rpc/README.md index 58f566a..2785daa 100644 --- a/Json-Rpc/README.md +++ b/Json-Rpc/README.md @@ -32,7 +32,7 @@ The `#:sdk` and `#:package` directives select the web SDK and package. ```csharp #:sdk Microsoft.NET.Sdk.Web -#:package AustinHarris.JsonRpc.AspNetCore@2.0.0-preview.2 +#:package AustinHarris.JsonRpc.AspNetCore@2.0.0-preview.3 using AustinHarris.JsonRpc; using AustinHarris.JsonRpc.AspNetCore; diff --git a/README.md b/README.md index f4a6e59..ce156ec 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ The core uses no reflection emit. The WebAssembly sample runs under the configur dotnet add package AustinHarris.JsonRpc --prerelease ``` -2.0 is published as `2.0.0-preview.2`; without `--prerelease`, NuGet resolves to the last 1.x release. +2.0 is published as `2.0.0-preview.3`; without `--prerelease`, NuGet resolves to the last 1.x release. The four assemblies are strong-named with one key (public key token `e6819c02cf4aec44`) that is checked into the repository and does not change between releases, so a signed caller can reference them and .NET Framework loads them. Add `AustinHarris.JsonRpc.Newtonsoft` or `AustinHarris.JsonRpc.SystemTextJson` if you want that serializer, and `AustinHarris.JsonRpc.AspNetCore` to host in Kestrel. @@ -98,7 +98,7 @@ Save this as `server.cs`. It is a .NET 10 file-based app: one C# file with no pr ```csharp #:sdk Microsoft.NET.Sdk.Web -#:package AustinHarris.JsonRpc.AspNetCore@2.0.0-preview.2 +#:package AustinHarris.JsonRpc.AspNetCore@2.0.0-preview.3 using AustinHarris.JsonRpc; using AustinHarris.JsonRpc.AspNetCore; @@ -342,6 +342,8 @@ Set `Config.IncludeExceptionDetails = true` only for trusted development clients A `JsonRpcException` thrown by the application, or returned by an error handler, keeps the `data` it was given; that data is authored, not redacted. +The exception a handler receives is the one the method threw, with its own `InnerException` still attached; the library never substitutes the cause for the error. Only two wrapper types are stripped first, by type and wherever they came from: a `TargetInvocationException` (what reflection adds) and an `AggregateException` holding exactly one exception (what a faulted task observed through `Result` or `Wait` adds). An aggregate of several exceptions is passed whole. A `JsonRpcException` found only inside another exception's `InnerException` is not promoted: the outer exception is an internal error like any other. + ### Handlers Throw `JsonRpcException(code, message, data)` to return an error of your own. To reshape errors, or to inspect requests on the way in and out, register handlers. Each handler belongs to one session: diff --git a/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj b/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj index 097a4f8..e9aa951 100644 --- a/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj +++ b/benchmarks/Micro/AustinHarris.JsonRpc.Micro.csproj @@ -15,6 +15,9 @@ false false true + + true + $(MSBuildThisFileDirectory)..\..\\AustinHarris.JsonRpc.snk