New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow server the option to crash on exception thrown #74
Conversation
src/StreamJsonRpc/JsonRpc.cs
Outdated
@@ -77,6 +77,7 @@ public partial class JsonRpc : IDisposableObservable | |||
private bool disposed; | |||
private bool hasDisconnectedEventBeenRaised; | |||
private bool startedListening; | |||
private bool closeStreamOnException = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FxCop: Avoid field initializers that set the field to their default value. more info
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, this field doesn't appear to be set anywhere else, which makes me wonder why the field exists.
src/StreamJsonRpc/JsonRpc.cs
Outdated
/// Indicates whether the connection should be closed if the server throws an exception. | ||
/// </summary> | ||
/// <returns>A <see cref="bool"/> indicating if the streams should be closed.</returns> | ||
protected virtual bool ShouldCloseStreamOnException() => this.closeStreamOnException; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking the implementation might want to make its decision based on the Exception
object. Can we pass that in as a parameter?
src/StreamJsonRpc/JsonRpc.cs
Outdated
throw t.Exception.InnerException; | ||
} | ||
|
||
throw t.Exception; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rethrowing an Exception like this stomps on its Stacktrace property with a new one, blowing away the evidence of the original one. Instead, rethrowing should be done with ExceptionDispatchInfo.Capture(t).Throw()
.
src/StreamJsonRpc/JsonRpc.cs
Outdated
{ | ||
return CreateError(id, t.Exception); | ||
} | ||
|
||
if (t.IsFaulted && this.ShouldCloseStreamOnException()) | ||
{ | ||
if (t.Exception is AggregateException && t.Exception.InnerException != null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Task.Exception
is always an AggregateException
(it's actually typed as such). So you can drop that part of the check.
Then your comment mentions you want to strip off the TargetInvocationException
but in fact it appears you're not, since you're only removing the AggregateException
.
{ | ||
var streams = FullDuplexStream.CreateStreams(); | ||
var overloadRpc = new JsonRpcCrashesOnException(streams.Item1, streams.Item2, new Server()); | ||
overloadRpc.StartListening(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given the number of tests you added and the proportion of each one containing this boilerplate code, IMO this should be moved into its own test class with a constructor that contains the boilerplate code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, it appears you've doubled up on a lot of existing test methods. If it's necessary to achieve double-coverage for the different configuration, let's do that in a way that doesn't require maintaining double copies of every test. But I'm hoping that we only need to double-up on the tests that actually include the server throwing exceptions.
BTW, I don't see any code here to handle or suppress crashing. Where is it?
It seems to me that if the user decides to call
|
this.server.DelayAsyncMethodWithCancellation = true; | ||
|
||
// Repeat 10 times because https://github.com/Microsoft/vs-streamjsonrpc/issues/56 is a timing issue and we may miss it on the first attempt. | ||
for (int iteration = 0; iteration < 10; iteration++) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does repeating this test 10 times guarantee a hit for that bug?
{ | ||
this.clientStream.BeforeWrite = (stream, buffer, offset, count) => | ||
{ | ||
// Cancel on the first write, when the header is being written but the content is not yet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
content is not yet
? Is it me or does this sound like it's missing something or requires a different phrasing?
} | ||
catch (OperationCanceledException) | ||
{ | ||
// this is also an acceptable result. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is the exception sent by the server (iirc how this works), should we check for the integrity of the exception? Making sure it has the correct content, stack trace, and any other information that would be helpful to the user.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking better. The product changes look pretty good. The tests need a few touch-ups.
README.md
Outdated
@@ -34,6 +34,6 @@ Testing this library or users of this library can be done without any transport | |||
by using the [Nerdbank.FullDuplexStream][FullDuplexStream] library in your tests | |||
to produce the Stream object. | |||
|
|||
[JSONRPC]: http://json-rpc.org/ | |||
[JSONRPC]: http://jsonrpc.org/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a great change. We should get the docs fixed in the oldest servicing branch we have. Can you author a commit based on the v1.2 branch and send out a PR for that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can do. I was also wondering if it's worth adding more explicit documented examples of this new functionality, or if the tests are sufficient?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think a new topic under the doc
folder is a great idea.
} | ||
} | ||
|
||
internal class JsonRpcOverload : JsonRpc |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The term 'overload' is usually used with regard to multiple methods with the same name but different parameters. For derived types, a name that describes how it is different seems more appropriate. For example: JsonRpcWithFatalExceptions
.
Then perhaps renaming this test class to use something like that name instead of the overload term.
var result = await this.clientRpc.InvokeAsync<Foo>(nameof(Server.MethodThatAcceptsFoo), new { Bar = "bar", Bazz = 1000 }); | ||
Assert.NotNull(result); | ||
Assert.Equal("bar!", result.Bar); | ||
Assert.Equal(1001, result.Bazz); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added segment does not appear to be related to what the test method was previously testing, and doesn't seem to fit under the test method name. Why does it belong here? Should it be moved to its own method, or do we even need it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh sorry, I missed that. I added this check a while ago when I was first starting to look into the JsonRpc object being disposed/not disposed.
src/StreamJsonRpc/JsonRpc.cs
Outdated
@@ -612,6 +613,13 @@ protected virtual void Dispose(bool disposing) | |||
} | |||
} | |||
|
|||
/// <summary> | |||
/// Indicates whether the connection should be closed if the server throws an exception. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "when the server throws an exception" rather than "if the server throws an exception" IMO helps convey the idea that the method is invoked in the moment of the exception rather than at startup or something.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
src/StreamJsonRpc/JsonRpc.cs
Outdated
/// Indicates whether the connection should be closed if the server throws an exception. | ||
/// </summary> | ||
/// <param name="ex">The <see cref="Exception"/> thrown from server that is potentially fatal</param> | ||
/// <returns>A <see cref="bool"/> indicating if the streams should be closed.</returns> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think a <remarks>
section here seems appropriate. It would likely cover:
- Suggesting the use of
Environment.FailFast
if the process should crash is appropriate here. - That this method is invoked within the context of an exception filter may be useful to some folks.
- That the default implementation is to simply return false.
- That an override that returns true should probably take care to return false to certain expected exceptions such as
OperationCanceledException
.
src/StreamJsonRpc/JsonRpc.cs
Outdated
@@ -944,7 +952,7 @@ private JsonRpcMessage HandleInvocationTaskResult(JToken id, Task t) | |||
throw new ArgumentException(Resources.TaskNotCompleted, nameof(t)); | |||
} | |||
|
|||
if (t.IsFaulted) | |||
if (t.IsFaulted && !this.IsFatalException(t.Exception)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After this line in this method, we call Task<T>.Result
if it's a Task<T>
. This will throw an AggregateException
which is probably not the best exception to throw (we'd rather throw the AggregateException.InnerException
).
If t
is just Task
, we don't call it at all so this method won't throw.
I would suggest you add tests to verify that the exception thrown is of the expected type in both cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found that the final exception that's surfaced to the client is always TaskCanceledException
, but it's a good catch. I've updated it and added tests to ensure the exception that's passed through the filter is at least what's expected in either case. Thanks!
using Xunit.Abstractions; | ||
using static JsonRpcMethodAttributeTests; | ||
|
||
public class JsonRpcOverloadTests : TestBase |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would suggest you add only tests that focus on the behavior that you intend to change, or that is at particular risk of regressing with your change that don't have adequate coverage already.
I see some very specialized tests below (e.g. that test race conditions for cancellation) that don't seem at all relevant to your exception filter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's be sure to cover sync methods, async Task
methods as well as async Task<T>
methods, including cases that throw before and after their first yielding await
. If the nested Server
class you define here focuses on those scenarios, and test methods are named after those scenarios, I think it will be much clearer that we're covering the interesting scenarios.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I don't see any tests that assert that the streams are actually closed after the fatal exception. That's something of the core of your change, so I believe each test should assert that as well.
var exceptionMessage = "Exception from CloseStreamsOnSynchronousMethodException"; | ||
OperationCanceledException exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => this.clientRpc.InvokeAsync(nameof(Server.MethodThatThrowsUnauthorizedAccessException), exceptionMessage)); | ||
Assert.NotNull(exception.StackTrace); | ||
Assert.Equal(FaultException.InnerException.Message, exceptionMessage); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The exception that's passed into the filter is TargetInvocationException
in the synchronous case and AggregateException
in the asynchronous case. Would you rather I document that, or use the inner exception in the filter?
(I'm leaning towards documenting, myself)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm leaning toward sharing the original exception rather than the exception we add as an artifact of how we invoke the method.
For AggregateException
, is that thrown because Task.Exception
is thrown? What is the InnerException
? Is it a TargetInvocationException
?
I'd really like to get to a consistent exception type because I suspect folks who override this method will not anticipate that async vs. sync methods will need to be tested separately. If they're all TargetInvocationException
or AggregateException
that might be OK, but the mix of the two sounds like it's a recipe for overrides to be buggy. I'm guessing their first guess (at least in lieu of docs) would be that if they throw ArgumentException
from their server method, that they'll see an ArgumentException
in the exception filter. And IMO that seems like a reasonable goal for us, if we can reach it.
…on or AggregateException
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't fully reviewed all your latest changes yet, but I have to run so here's what I have so far.
doc/index.md
Outdated
|
||
protected override bool IsFatalException(Exception ex) | ||
{ | ||
if (ex.GetType() != typeof(OperationCanceledException)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefer ex is OperationCanceledException
over explicitly calling GetType()
and typeof
. In your case you'd need to flip the logic though, so either !(ex is OperationCanceledException)
or flip the blocks around.
doc/index.md
Outdated
{ | ||
if (ex.GetType() != typeof(OperationCanceledException)) | ||
{ | ||
Environment.FailFast(ex.message); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You definitely want to call the FailFast
overload that accepts ex
as a second parameter.
doc/index.md
Outdated
``` | ||
|
||
## Crashing the process on exception | ||
In some cases, you may want to immediately crash the server and client processes if certain exceptions are thrown. In this case, overriding the `IsFatalException` method will give you the desired functionality. `IsFatalException` provides an exception filter through which you can access and respond to exceptions as they are caught. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"...and client processes..."
I don't think that's the scenario we're designing for, nor what you demonstrate below. It's just the server that crashes in this design, right?
doc/index.md
Outdated
public async Task NotifyRemote() | ||
{ | ||
var target = new Server(); | ||
var rpc = new JsonRpcCrashesOnException(Console.OpenStandardOutput(), Console.OpenStandardInput(), target); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thoroughly confused by this. It appears you're working with console streams, which is a little odd in the first place. But then you're using the same instance of JsonRpc both to invoke the server method as a client and also to invoke it as the server itself. I'm pretty sure that doesn't work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm mostly trying to stay consistent with the rest of the documentation (although I see another example of using the constructor directly uses FullDuplexStream
). I do agree with your confusion though; these examples confused me when I first started looking into this and I got more value from looking at the unit tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok. I didn't thoroughly review the docs we have right now, which came from Tina. So feel free to improve on them in your addition, and possibly fix up the originals too. :)
src/StreamJsonRpc/JsonRpc.cs
Outdated
/// This method is invoked within the context of an exception filter and simply returns false by default. | ||
/// Certain exceptions (such as <see cref="OperationCanceledException"/>) are not fatal in most cases, therefore | ||
/// care should be taken to return false and keep the connection open. If the process should crash on an exception, | ||
/// calling Environment.FailFast will produce such behavior. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Environment.FailFast
-> <see cref="Environment.FailFast(string, Exception)" />
|
||
public class JsonRpcWithFatalExceptionsTests : TestBase | ||
{ | ||
internal static Exception FaultException; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
xunit can run test methods in parallel, so you should avoid statics to avoid unstable tests.
Instead, you can leave this as an instance property on your nested class since that's where it's determined anyway, and then your test methods can access that property since they instantiated the nested class in the first place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like it! Thanks!
this.clientStream = streams.Item2; | ||
|
||
this.messageHandler = new DisposingMessageHander(this.clientStream, this.serverStream); | ||
this.clientRpc = new JsonRpcWithFatalExceptions(this.messageHandler, this.server); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you look at the JsonRpcTests
class, you'll see we create instantiate JsonRpc twice: once for the client and once for the server. In this case, you appear to be hooking up the "server" to the clientRpc object, which looks wrong, and I can't see a second instance being created. So I imagine when you call this.clientRpc.Invoke
it will send it to the serverStream
but with no one listening to it, it will just hang there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good! I don't really know best practices, but there wasn't a hang with this implementation. When I update the docs I'll make sure they instantiate two instances like we do in the tests, as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting. That suggests that you've successfully formed a loopback connection with JsonRpc. I've never seen that 😄 .
If it worked, I probably wouldn't mind if you kept it that way, but using two JsonRpc instances has the advantage of resembling how customers will likely use it more closely, which increases our confidence that we're testing the right thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still agree we should change it! Especially since there will most likely be people like me who look to our unit tests for added documentation.
} | ||
} | ||
|
||
public class DisposingDuplexStream : FullDuplexStream |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If all you need is an IsDisposed
property, you could just add that to the FullDuplexStream
class defined within this project. It seems like it would be less work and possibly be useful to other tests in this project.
// Assert MessageHandler and Stream objects are not disposed | ||
Assert.False(((DisposingMessageHander)this.clientRpc.MessageHandler).IsDisposed); | ||
Assert.False(this.serverStream.IsDisposed); | ||
Assert.False(this.clientStream.IsDisposed); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verifying both the message handler and the streams seems overkill, IMO. Particularly since you're actually controlling the MessageHandler being used. I would probably just test MessageHandler's disposal. And then confirm that our message handler tests verify that when the MessageHandler is disposed it disposes of the streams.
src/StreamJsonRpc/JsonRpc.cs
Outdated
{ | ||
ExceptionDispatchInfo.Capture(t.Exception.InnerException).Throw(); | ||
} | ||
else if (this.IsFatalException(t.Exception)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems odd that you're calling IsFatalException
twice for ultimately the same exception. Can we get it to just be called once? (and then perhaps add assertions to your tests to ensure it's only invoked once so it doesn't regress).
public async Task AsyncMethodThatThrowsBeforeYield(string message) | ||
{ | ||
throw new Exception(message); | ||
await Task.Yield(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you get build warnings from dead code on this line, you can replace this method with:
public Task AsyncMethodThatThrowsBeforeYield(string message)
{
var tcs = new TaskCompletionSource<object>();
tcs.SetException(new Exception(message));
return tcs.Task;
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome, thanks!
} | ||
|
||
// Assert MessageHandler and Stream objects are not disposed | ||
Assert.False(((DisposingMessageHander)this.clientRpc.MessageHandler).IsDisposed); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why shouldn't they be disposed on cancellation? Your IsFatalException
override returns true unconditionally. So I read that to mean that TaskCanceledException
is fatal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I was thinking we only needed to worry about exceptions thrown explicitly from the server method (but this would miss the whole gambit of OperationCanceledExceptions
. Fixed in my next iteration
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking we only needed to worry about exceptions thrown explicitly from the server method
I would agree -- but when a server method is canceled, it will in fact throw this exception -- unless perhaps the request to invoke is canceled before we even invoke the method to begin with, in which case the exception might never be thrown, theoretically. And in that case, I guess I'd be ok with us not calling IsFatalException at all in that case since indeed the server method didn't throw it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regardless, I was missing the case where the server explicitly throws an OperationCanceledException
in an async method, so it was a great catch!
src/StreamJsonRpc/JsonRpc.cs
Outdated
/// <param name="ex">The <see cref="Exception"/> thrown from server that is potentially fatal</param> | ||
/// <returns>A <see cref="bool"/> indicating if the streams should be closed.</returns> | ||
/// <remarks> | ||
/// This method is invoked within the context of an exception filter and simply returns false by default. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess we shouldn't say it's called from an exception filter since that is not always the case. I see from your other change that we only call it in an exception filter in one of a few cases.
doc/index.md
Outdated
``` | ||
|
||
## Crashing the process on exception | ||
In some cases, you may want to immediately crash the server and client processes if certain exceptions are thrown. In this case, overriding the `IsFatalException` method will give you the desired functionality. `IsFatalException` provides an exception filter through which you can access and respond to exceptions as they are caught. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We may want to remove the "exception filter" reference since it is not always called from an exception filter.
…ssageHandler tests
doc/index.md
Outdated
var rpc = JsonRpc.Attach(Console.OpenStandardOutput(), Console.OpenStandardInput(), target); | ||
var streams = Nerdbank.FullDuplexStream.CreateStreams(); | ||
var clientRpc = JsonRpc.Attach(streams.Item1); | ||
var serverRpc = JsonRpc.Attach(streams.Item2, target); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In each of your updated samples (thanks for fixing them, BTW) I would focus it on either the client or the server side rather than show both in the same method. And it seems quite acceptable to use syntax that assumes the stream is handed to you rather than construct one or use the Console stream.
Reserve the FullDuplexStream sample for a "how to test this?" section, if we have one.
doc/index.md
Outdated
} | ||
``` | ||
|
||
## Crashing the process on exception |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Crashing may not be the best thing to advertise. It also leads to a sample where you return false or throw -- never returning true, which begs the question why we made it a bool returning method.
Instead, how about we document this as a "Close stream on fatal errors" and document as returning true or false. Then as a comment we can mention that Environment.FailFast might be used here to crash the entire process instead -- but I'm on the fence about whether we should even mention that.
|
||
public async Task MethodThatThrowsAsync(string message) | ||
{ | ||
await Task.Run(() => throw new Exception(message)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This leaves open a race condition that allows for MethodThatThrowsAsync
to return a completed task or an incomplete task. To ensure it is never completed, rewrite the body of this method to be:
await Task.Yield();
throw new Exception(message);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've already implemented that in AsyncMethodThatThrowsAfterYield. I'll remove this method and it's corresponding tests since it doesn't seem to be doing anything unique.
|
||
public async Task<string> AsyncMethodThatReturnsStringAndThrows(string message) | ||
{ | ||
await Task.Run(() => throw new Exception(message)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rewrite to this to guarantee the method is always asynchronous.
await Task.Yield();
throw new Exception(message);
var exceptionMessage = "Exception from CloseStreamOnAsyncMethodException"; | ||
await Assert.ThrowsAsync<TaskCanceledException>(() => this.clientRpc.InvokeAsync(nameof(Server.MethodThatThrowsAsync), exceptionMessage)); | ||
Assert.Equal(exceptionMessage, this.serverRpc.FaultException.Message); | ||
Assert.Equal(2, this.serverRpc.IsFatalExceptionCount); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd like to chat to better understand why this is 2
instead of just 1
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When I rethrew the exception we encountered it twice: once within the task handler and then once again when it was thrown from the task handler. I've changed it to close the streams within the task handler so there's no need to see the exception again.
src/StreamJsonRpc/JsonRpc.cs
Outdated
@@ -916,7 +930,7 @@ private static JsonRpcMessage CreateError(JToken id, Exception exception) | |||
|
|||
return await ((Task)result).ContinueWith(this.handleInvocationTaskResultDelegate, request.Id, TaskScheduler.Default).ConfigureAwait(false); | |||
} | |||
catch (Exception ex) | |||
catch (Exception ex) when (!this.IsFatalException(ex.InnerException ?? ex)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This redirection to the InnerException if it's defined makes me nervous that we might disregard an outer exception that is useful. I expect you're trying to strip off the TargetInvocationException
and/or AggregateException
, is that right? In that case, can we add new catch blocks above this one for those specific types and strip the outer exception just for those, leaving any other exceptions alone?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or perhaps since you want to strip it in other places as well that don't use catch blocks, you can write a helper method that does the type checking on the exception and strips it only when it's appropriate.
src/StreamJsonRpc/JsonRpc.cs
Outdated
@@ -946,11 +960,34 @@ private JsonRpcMessage HandleInvocationTaskResult(JToken id, Task t) | |||
|
|||
if (t.IsFaulted) | |||
{ | |||
if (t.Exception != null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is something you can assume without checking: Task.IsFaulted === (Task.Exception != null)
is a guarantee.
src/StreamJsonRpc/JsonRpc.cs
Outdated
return CreateError(id, t.Exception); | ||
} | ||
|
||
if (t.IsCanceled) | ||
{ | ||
if (this.IsFatalException(new OperationCanceledException())) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should manufacture an exception here. If the method we invoked doesn't actually throw an exception, but rather returns a Canceled task, that likely means that the server method went out of its way to skip an exception code path and we shouldn't artificially bring it back. Anyway, the method is called IsFatalException
and no exception occurred.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found that any time an asynchronous task was canceled (or if the server method threw OperationCanceledException
) it ended up in this handler as a canceled task. I agree though, this felt weird, and especially with there being no way to distinguish between an exception thrown or a canceled task returned. Still better to remove it and document that they don't need to filter for TaskCanceledException
after all?
doc/index.md
Outdated
var serverRpc = JsonRpc.Attach(streams.Item2, target); | ||
var myResult = await clientRpc.InvokeWithParameterObjectAsync<string>("test/InvokeTestMethod"); | ||
var rpc = JsonRpc.Attach(stream, target); | ||
var myResult = await rpc.InvokeWithParameterObjectAsync<string>("test/InvokeTestMethod"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having just one JsonRpc instance created looks much better (more real). Thanks.
However, one can't use the same JsonRpc instance that you passed the server to to also invoke methods on that server. You'd need the JsonRpc instance that the client created to invoke methods on the server.
Can the docs show how it looks on the server, and then as a separate code snippet show "and with this attribute on the server method, the client can now invoke this method with this special name, like this..."
doc/index.md
Outdated
## Crashing the process on exception | ||
In some cases, you may want to immediately crash the server process if certain exceptions are thrown. In this case, overriding the `IsFatalException` method will give you the desired functionality. Through `IsFatalException` you can access and respond to exceptions as they are observed. | ||
## Close stream on fatal errors | ||
In some cases, you may want to immediately close the streams if certain exceptions are thrown. In this case, overriding the `IsFatalException` method will give you the desired functionality. Through `IsFatalException` you can access and respond to exceptions as they are observed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the subject of closing the stream, we should think about how this fits with the new WebSocket
transport that we now support, where closing the connection involves sending an async message before actually disposing the connection. But as closing the stream on failure is not new functionality, reconciling these doesn't have to be in this PR, IMO.
@@ -219,7 +204,7 @@ public Task AsyncMethodThatThrowsBeforeYield(string message) | |||
{ | |||
await Task.Run(() => throw new Exception(message)); | |||
|
|||
return "never will return"; | |||
return await Task.FromResult("never will return"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious why you're using return await Task.FromResult(x)
instead of just return x
. I can't think of any difference that makes at runtime and it seems more verbose.
@@ -27,5 +27,10 @@ public enum DisconnectedReason | |||
/// The stream was disposed. | |||
/// </summary> | |||
Disposed, | |||
|
|||
/// <summary> | |||
/// A fatal exception was thrown from the server method. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since for most readers they probably have a distinct idea of which end the "server" refers to, and yet this actually always refers to the local side, perhaps we should rephrase this to:
A fatal exception was thrown in a local method that was requested by the remote party.
} | ||
|
||
private static Exception StripExceptionToInnerException(Exception exception) | ||
{ | ||
if (exception is TargetInvocationException || (exception is AggregateException && exception.InnerException != null)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This helper method looks much better. I'm a little concerned about inconsistent handling of AggregateException, which we can talk about in person since I find it hard to explain without illustrations.
Maybe I'll just push a couple new test methods to your branch to demonstrate what I believe may be a problem.
src/StreamJsonRpc/Resources.resx
Outdated
@@ -211,7 +211,7 @@ | |||
<comment>An error message when a stream passed in as an argument is incorrect.</comment> | |||
</data> | |||
<data name="SumOfTwoParametersExceedsArrayLength" xml:space="preserve"> | |||
<value>The {0] and {1} parameters exceed length of array.</value> | |||
<value>The {0 and {1} parameters exceed length of array.</value> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It appears you have a formatting string bug here.
src/StreamJsonRpc/JsonRpc.cs
Outdated
var exception = StripExceptionToInnerException(t.Exception); | ||
var e = new JsonRpcDisconnectedEventArgs( | ||
string.Format(CultureInfo.CurrentCulture, Resources.FatalExceptionWasThrown, exception.GetType(), exception.Message), | ||
DisconnectedReason.Unknown, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we using Unknown
as the reason when you've defined a new enum value specifically for this situation?
Fixes #69