-
Notifications
You must be signed in to change notification settings - Fork 145
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
Changes from 7 commits
de75371
43d9c91
d5f7435
226db0a
0bbc033
673d727
ef74c1a
8b9678c
8152cf4
3b1efda
5994ffb
747af41
5435f72
97f2c56
89a602e
dbde851
3d04487
12c6c54
22a1a68
a0477dd
ae643fa
984c20c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,253 @@ | ||
using System; | ||
using System.IO; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Microsoft.VisualStudio.Threading; | ||
using Nerdbank; | ||
using Newtonsoft.Json; | ||
using StreamJsonRpc; | ||
using Xunit; | ||
using Xunit.Abstractions; | ||
using static JsonRpcMethodAttributeTests; | ||
|
||
public class JsonRpcOverloadTests : TestBase | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's be sure to cover sync methods, There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
{ | ||
private readonly Server server; | ||
private readonly FullDuplexStream serverStream; | ||
private readonly FullDuplexStream clientStream; | ||
private readonly JsonRpc clientRpc; | ||
|
||
public JsonRpcOverloadTests(ITestOutputHelper logger) | ||
: base(logger) | ||
{ | ||
this.server = new Server(); | ||
var streams = FullDuplexStream.CreateStreams(); | ||
this.serverStream = streams.Item1; | ||
this.clientStream = streams.Item2; | ||
|
||
this.clientRpc = new JsonRpcOverload(this.clientStream, this.serverStream, this.server); | ||
this.clientRpc.StartListening(); | ||
} | ||
|
||
[Fact] | ||
public async Task CanInvokeMethodOnServer() | ||
{ | ||
string testLine = "TestLine1" + new string('a', 1024 * 1024); | ||
string result1 = await this.clientRpc.InvokeAsync<string>(nameof(Server.ServerMethod), testLine); | ||
Assert.Equal(testLine + "!", result1); | ||
} | ||
|
||
[Fact] | ||
public async Task CanInvokeMethodThatReturnsCancelledTask() | ||
{ | ||
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => this.clientRpc.InvokeAsync(nameof(Server.ServerMethodThatReturnsCancelledTask))); | ||
Assert.Equal(CancellationToken.None, ex.CancellationToken); | ||
} | ||
|
||
[Fact] | ||
public async Task CannotPassExceptionFromServer() | ||
{ | ||
OperationCanceledException exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => this.clientRpc.InvokeAsync(nameof(Server.MethodThatThrowsUnauthorizedAccessException))); | ||
Assert.NotNull(exception.StackTrace); | ||
|
||
await Assert.ThrowsAsync<ObjectDisposedException>(() => this.clientRpc.InvokeAsync<string>(nameof(Server.ServerMethod), "testing")); | ||
} | ||
|
||
[Fact] | ||
public async Task CanCallAsyncMethodThatThrows() | ||
{ | ||
Exception exception = await Assert.ThrowsAnyAsync<TaskCanceledException>(() => this.clientRpc.InvokeAsync<string>(nameof(Server.AsyncMethodThatThrows))); | ||
Assert.NotNull(exception.StackTrace); | ||
|
||
await Assert.ThrowsAnyAsync<ObjectDisposedException>(() => this.clientRpc.InvokeAsync<string>(nameof(Server.AsyncMethodThatThrows))); | ||
} | ||
|
||
[Fact] | ||
public async Task ThrowsIfCannotFindMethod_Overload() | ||
{ | ||
await Assert.ThrowsAsync(typeof(RemoteMethodNotFoundException), () => this.clientRpc.InvokeAsync("missingMethod", 50)); | ||
|
||
var result = await this.clientRpc.InvokeAsync<string>(nameof(Server.ServerMethod), "testing"); | ||
Assert.Equal("testing!", result); | ||
} | ||
|
||
// Covers bug https://github.com/Microsoft/vs-streamjsonrpc/issues/55 | ||
// Covers bug https://github.com/Microsoft/vs-streamjsonrpc/issues/56 | ||
[Fact] | ||
public async Task InvokeWithCancellationAsync_CancelOnFirstWriteToStream() | ||
{ | ||
// TODO: remove the next line when https://github.com/Microsoft/vs-threading/issues/185 is fixed | ||
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 commentThe reason will be displayed to describe this comment to others. Learn more. Does repeating this test 10 times guarantee a hit for that bug? |
||
{ | ||
using (var cts = new CancellationTokenSource()) | ||
{ | ||
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 commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
if (!cts.IsCancellationRequested) | ||
{ | ||
cts.Cancel(); | ||
} | ||
}; | ||
|
||
await Assert.ThrowsAsync<TaskCanceledException>(() => this.clientRpc.InvokeWithCancellationAsync<string>(nameof(Server.AsyncMethodWithCancellation), new[] { "a" }, cts.Token)).WithTimeout(UnexpectedTimeout); | ||
this.clientStream.BeforeWrite = null; | ||
} | ||
|
||
// Verify that json rpc is still operational after cancellation. | ||
// If the cancellation breaks the json rpc, like in https://github.com/Microsoft/vs-streamjsonrpc/issues/55, it will close the stream | ||
// and cancel the request, resulting in unexpected OperationCancelledException thrown from the next InvokeAsync | ||
string result = await this.clientRpc.InvokeAsync<string>(nameof(Server.ServerMethod), "a"); | ||
Assert.Equal("a!", result); | ||
} | ||
} | ||
|
||
[Fact] | ||
public async Task CancelMessageSentWhileAwaitingResponse() | ||
{ | ||
using (var cts = new CancellationTokenSource()) | ||
{ | ||
var invokeTask = this.clientRpc.InvokeWithCancellationAsync<string>(nameof(Server.AsyncMethodWithCancellation), new[] { "a" }, cts.Token); | ||
await this.server.ServerMethodReached.WaitAsync(this.TimeoutToken); | ||
cts.Cancel(); | ||
|
||
// Ultimately, the server throws because it was canceled. | ||
var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => invokeTask.WithTimeout(UnexpectedTimeout)); | ||
#if !NET452 | ||
Assert.Equal(cts.Token, ex.CancellationToken); | ||
#endif | ||
} | ||
|
||
var result = await this.clientRpc.InvokeAsync<string>(nameof(Server.ServerMethod), "testing"); | ||
Assert.Equal("testing!", result); | ||
} | ||
|
||
[Fact] | ||
public async Task InvokeWithCancellationAsync_CanCallCancellableMethodWithNoArgs() | ||
{ | ||
Assert.Equal(5, await this.clientRpc.InvokeWithCancellationAsync<int>(nameof(Server.AsyncMethodWithCancellationAndNoArgs))); | ||
|
||
using (var cts = new CancellationTokenSource()) | ||
{ | ||
Task<int> resultTask = this.clientRpc.InvokeWithCancellationAsync<int>(nameof(Server.AsyncMethodWithCancellationAndNoArgs), cancellationToken: cts.Token); | ||
cts.Cancel(); | ||
try | ||
{ | ||
int result = await resultTask; | ||
Assert.Equal(5, result); | ||
} | ||
catch (OperationCanceledException) | ||
{ | ||
// this is also an acceptable result. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
} | ||
} | ||
} | ||
|
||
[Fact] | ||
public async Task CancelMayStillReturnErrorFromServer() | ||
{ | ||
using (var cts = new CancellationTokenSource()) | ||
{ | ||
var invokeTask = this.clientRpc.InvokeWithCancellationAsync<string>(nameof(Server.AsyncMethodFaultsAfterCancellation), new[] { "a" }, cts.Token); | ||
await this.server.ServerMethodReached.WaitAsync(this.TimeoutToken); | ||
cts.Cancel(); | ||
this.server.AllowServerMethodToReturn.Set(); | ||
|
||
await Assert.ThrowsAsync<TaskCanceledException>(() => invokeTask); | ||
} | ||
} | ||
|
||
[Fact] | ||
public async Task AsyncMethodThrows() | ||
{ | ||
await Assert.ThrowsAsync<TaskCanceledException>(() => this.clientRpc.InvokeAsync(nameof(Server.MethodThatThrowsAsync))); | ||
} | ||
|
||
public class Server | ||
{ | ||
internal const string ThrowAfterCancellationMessage = "Throw after cancellation"; | ||
|
||
public bool DelayAsyncMethodWithCancellation { get; set; } | ||
|
||
public AsyncAutoResetEvent ServerMethodReached { get; } = new AsyncAutoResetEvent(); | ||
|
||
public AsyncAutoResetEvent AllowServerMethodToReturn { get; } = new AsyncAutoResetEvent(); | ||
|
||
public static string ServerMethod(string argument) | ||
{ | ||
return argument + "!"; | ||
} | ||
|
||
public Task ServerMethodThatReturnsCancelledTask() | ||
{ | ||
var tcs = new TaskCompletionSource<object>(); | ||
tcs.SetCanceled(); | ||
return tcs.Task; | ||
} | ||
|
||
public void MethodThatThrowsUnauthorizedAccessException() | ||
{ | ||
throw new UnauthorizedAccessException(); | ||
} | ||
|
||
public async Task AsyncMethodThatThrows() | ||
{ | ||
await Task.Yield(); | ||
throw new Exception(); | ||
} | ||
|
||
public async Task<string> AsyncMethodWithCancellation(string arg, CancellationToken cancellationToken) | ||
{ | ||
this.ServerMethodReached.Set(); | ||
|
||
// TODO: remove when https://github.com/Microsoft/vs-threading/issues/185 is fixed | ||
if (this.DelayAsyncMethodWithCancellation) | ||
{ | ||
await Task.Delay(UnexpectedTimeout).WithCancellation(cancellationToken); | ||
} | ||
|
||
await this.AllowServerMethodToReturn.WaitAsync(cancellationToken); | ||
return arg + "!"; | ||
} | ||
|
||
public async Task<int> AsyncMethodWithCancellationAndNoArgs(CancellationToken cancellationToken) | ||
{ | ||
await Task.Yield(); | ||
return 5; | ||
} | ||
|
||
public async Task<string> AsyncMethodFaultsAfterCancellation(string arg, CancellationToken cancellationToken) | ||
{ | ||
this.ServerMethodReached.Set(); | ||
await this.AllowServerMethodToReturn.WaitAsync(); | ||
if (!cancellationToken.IsCancellationRequested) | ||
{ | ||
var cancellationSignal = new AsyncManualResetEvent(); | ||
using (cancellationToken.Register(() => cancellationSignal.Set())) | ||
{ | ||
await cancellationSignal; | ||
} | ||
} | ||
|
||
throw new InvalidOperationException(ThrowAfterCancellationMessage); | ||
} | ||
|
||
public async Task MethodThatThrowsAsync() | ||
{ | ||
await Task.Run(() => throw new Exception()); | ||
} | ||
} | ||
|
||
internal class JsonRpcOverload : JsonRpc | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Then perhaps renaming this test class to use something like that name instead of the overload term. |
||
{ | ||
public JsonRpcOverload(Stream sendingStream, Stream receivingStream, object target = null) | ||
: base(sendingStream, receivingStream, target) | ||
{ | ||
} | ||
|
||
protected override bool IsFatalException(Exception ex) => true; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -165,6 +165,11 @@ public async Task CanPassExceptionFromServer() | |
RemoteInvocationException exception = await Assert.ThrowsAnyAsync<RemoteInvocationException>(() => this.clientRpc.InvokeAsync(nameof(Server.MethodThatThrowsUnauthorizedAccessException))); | ||
Assert.NotNull(exception.RemoteStackTrace); | ||
Assert.StrictEqual(COR_E_UNAUTHORIZEDACCESS.ToString(CultureInfo.InvariantCulture), exception.RemoteErrorCode); | ||
|
||
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 commentThe 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 commentThe 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. |
||
} | ||
|
||
[Fact] | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,6 +11,7 @@ namespace StreamJsonRpc | |
using System.IO; | ||
using System.Linq; | ||
using System.Reflection; | ||
using System.Runtime.ExceptionServices; | ||
using System.Text; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
|
@@ -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 commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
/// </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 commentThe reason will be displayed to describe this comment to others. Learn more. I think a
|
||
protected virtual bool IsFatalException(Exception ex) => false; | ||
|
||
/// <summary> | ||
/// Invokes the specified RPC method | ||
/// </summary> | ||
|
@@ -916,7 +924,7 @@ private async Task<JsonRpcMessage> DispatchIncomingRequestAsync(JsonRpcMessage r | |
|
||
return await ((Task)result).ContinueWith(this.handleInvocationTaskResultDelegate, request.Id, TaskScheduler.Default).ConfigureAwait(false); | ||
} | ||
catch (Exception ex) | ||
catch (Exception ex) when (!this.IsFatalException(ex)) | ||
{ | ||
return CreateError(request.Id, ex); | ||
} | ||
|
@@ -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 commentThe reason will be displayed to describe this comment to others. Learn more. After this line in this method, we call There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
{ | ||
return CreateError(id, 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 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.