Adds an opt-in default timeout for async JS calls (dotnet/extensions#1880)

* Adds the ability to configure a default timeout for JavaScript interop calls.
* Adds the ability to pass in a cancellation token to InvokeAsync that will be used instead of the default timeout.
  * A default cancellation token can be passed to signal no cancellation, but it is not recommended in remote interop scenarios.\n\nCommit migrated from e04faac7e4
This commit is contained in:
Javier Calvarro Nelson 2019-07-02 13:24:29 +02:00 committed by GitHub
parent 244ed54764
commit 5e4f1f5fb2
3 changed files with 169 additions and 32 deletions

View File

@ -59,7 +59,9 @@ namespace Microsoft.JSInterop
public abstract partial class JSRuntimeBase : Microsoft.JSInterop.IJSRuntime
{
protected JSRuntimeBase() { }
protected abstract void BeginInvokeJS(long asyncHandle, string identifier, string argsJson);
protected System.TimeSpan? DefaultAsyncTimeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
protected abstract void BeginInvokeJS(long taskId, string identifier, string argsJson);
public System.Threading.Tasks.Task<T> InvokeAsync<T>(string identifier, System.Collections.Generic.IEnumerable<object> args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; }
public System.Threading.Tasks.Task<T> InvokeAsync<T>(string identifier, params object[] args) { throw null; }
protected virtual object OnDotNetInvocationException(System.Exception exception, string assemblyName, string methodIdentifier) { throw null; }
}

View File

@ -3,6 +3,8 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Text.Json;
using System.Threading;
@ -20,8 +22,71 @@ namespace Microsoft.JSInterop
private readonly ConcurrentDictionary<long, object> _pendingTasks
= new ConcurrentDictionary<long, object>();
private readonly ConcurrentDictionary<long, CancellationTokenRegistration> _cancellationRegistrations =
new ConcurrentDictionary<long, CancellationTokenRegistration>();
internal DotNetObjectRefManager ObjectRefManager { get; } = new DotNetObjectRefManager();
/// <summary>
/// Gets or sets the default timeout for asynchronous JavaScript calls.
/// </summary>
protected TimeSpan? DefaultAsyncTimeout { get; set; }
/// <summary>
/// Invokes the specified JavaScript function asynchronously.
/// </summary>
/// <typeparam name="T">The JSON-serializable return type.</typeparam>
/// <param name="identifier">An identifier for the function to invoke. For example, the value <code>"someScope.someFunction"</code> will invoke the function <code>window.someScope.someFunction</code>.</param>
/// <param name="args">JSON-serializable arguments.</param>
/// <param name="cancellationToken">A cancellation token to signal the cancellation of the operation.</param>
/// <returns>An instance of <typeparamref name="T"/> obtained by JSON-deserializing the return value.</returns>
public Task<T> InvokeAsync<T>(string identifier, IEnumerable<object> args, CancellationToken cancellationToken = default)
{
var taskId = Interlocked.Increment(ref _nextPendingTaskId);
var tcs = new TaskCompletionSource<T>(TaskContinuationOptions.RunContinuationsAsynchronously);
if (cancellationToken != default)
{
_cancellationRegistrations[taskId] = cancellationToken.Register(() =>
{
tcs.TrySetCanceled(cancellationToken);
CleanupTasksAndRegistrations(taskId);
});
}
_pendingTasks[taskId] = tcs;
try
{
if (cancellationToken.IsCancellationRequested)
{
tcs.TrySetCanceled(cancellationToken);
CleanupTasksAndRegistrations(taskId);
return tcs.Task;
}
var argsJson = args?.Any() == true ?
JsonSerializer.Serialize(args, JsonSerializerOptionsProvider.Options) :
null;
BeginInvokeJS(taskId, identifier, argsJson);
return tcs.Task;
}
catch
{
CleanupTasksAndRegistrations(taskId);
throw;
}
}
private void CleanupTasksAndRegistrations(long taskId)
{
_pendingTasks.TryRemove(taskId, out _);
if (_cancellationRegistrations.TryRemove(taskId, out var registration))
{
registration.Dispose();
}
}
/// <summary>
/// Invokes the specified JavaScript function asynchronously.
/// </summary>
@ -31,36 +96,32 @@ namespace Microsoft.JSInterop
/// <returns>An instance of <typeparamref name="T"/> obtained by JSON-deserializing the return value.</returns>
public Task<T> InvokeAsync<T>(string identifier, params object[] args)
{
// We might consider also adding a default timeout here in case we don't want to
// risk a memory leak in the scenario where the JS-side code is failing to complete
// the operation.
var taskId = Interlocked.Increment(ref _nextPendingTaskId);
var tcs = new TaskCompletionSource<T>();
_pendingTasks[taskId] = tcs;
try
if (!DefaultAsyncTimeout.HasValue)
{
var argsJson = args?.Length > 0 ?
JsonSerializer.Serialize(args, JsonSerializerOptionsProvider.Options) :
null;
BeginInvokeJS(taskId, identifier, argsJson);
return tcs.Task;
return InvokeAsync<T>(identifier, args, default);
}
catch
else
{
_pendingTasks.TryRemove(taskId, out _);
throw;
return InvokeWithDefaultCancellation<T>(identifier, args);
}
}
private async Task<T> InvokeWithDefaultCancellation<T>(string identifier, IEnumerable<object> args)
{
using (var cts = new CancellationTokenSource(DefaultAsyncTimeout.Value))
{
// We need to await here due to the using
return await InvokeAsync<T>(identifier, args, cts.Token);
}
}
/// <summary>
/// Begins an asynchronous function invocation.
/// </summary>
/// <param name="asyncHandle">The identifier for the function invocation, or zero if no async callback is required.</param>
/// <param name="taskId">The identifier for the function invocation, or zero if no async callback is required.</param>
/// <param name="identifier">The identifier for the function to invoke.</param>
/// <param name="argsJson">A JSON representation of the arguments.</param>
protected abstract void BeginInvokeJS(long asyncHandle, string identifier, string argsJson);
protected abstract void BeginInvokeJS(long taskId, string identifier, string argsJson);
/// <summary>
/// Allows derived classes to configure the information about an exception in a JS interop call that gets sent to JavaScript.
@ -95,15 +156,19 @@ namespace Microsoft.JSInterop
BeginInvokeJS(0, "DotNet.jsCallDispatcher.endInvokeDotNetFromJS", args);
}
internal void EndInvokeJS(long asyncHandle, bool succeeded, JSAsyncCallResult asyncCallResult)
internal void EndInvokeJS(long taskId, bool succeeded, JSAsyncCallResult asyncCallResult)
{
using (asyncCallResult?.JsonDocument)
{
if (!_pendingTasks.TryRemove(asyncHandle, out var tcs))
if (!_pendingTasks.TryRemove(taskId, out var tcs))
{
throw new ArgumentException($"There is no pending task with handle '{asyncHandle}'.");
// We should simply return if we can't find an id for the invocation.
// This likely means that the method that initiated the call defined a timeout and stopped waiting.
return;
}
CleanupTasksAndRegistrations(taskId);
if (succeeded)
{
var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs);

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.JSInterop.Internal;
using Xunit;
@ -38,6 +39,69 @@ namespace Microsoft.JSInterop.Tests
});
}
[Fact]
public async Task InvokeAsync_CancelsAsyncTask_AfterDefaultTimeout()
{
// Arrange
var runtime = new TestJSRuntime();
runtime.DefaultTimeout = TimeSpan.FromSeconds(1);
// Act
var task = runtime.InvokeAsync<object>("test identifier 1", "arg1", 123, true);
// Assert
await Assert.ThrowsAsync<TaskCanceledException>(async () => await task);
}
[Fact]
public async Task InvokeAsync_CompletesSuccessfullyBeforeTimeout()
{
// Arrange
var runtime = new TestJSRuntime();
runtime.DefaultTimeout = TimeSpan.FromSeconds(10);
// Act
var task = runtime.InvokeAsync<object>("test identifier 1", "arg1", 123, true);
runtime.EndInvokeJS(2, succeeded: true, null);
// Assert
await task;
}
[Fact]
public async Task InvokeAsync_CancelsAsyncTasksWhenCancellationTokenFires()
{
// Arrange
using var cts = new CancellationTokenSource();
var runtime = new TestJSRuntime();
// Act
var task = runtime.InvokeAsync<object>("test identifier 1", new object[] { "arg1", 123, true }, cts.Token);
cts.Cancel();
// Assert
await Assert.ThrowsAsync<TaskCanceledException>(async () => await task);
}
[Fact]
public async Task InvokeAsync_DoesNotStartWorkWhenCancellationHasBeenRequested()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
var runtime = new TestJSRuntime();
// Act
var task = runtime.InvokeAsync<object>("test identifier 1", new object[] { "arg1", 123, true }, cts.Token);
cts.Cancel();
// Assert
await Assert.ThrowsAsync<TaskCanceledException>(async () => await task);
Assert.Empty(runtime.BeginInvokeCalls);
}
[Fact]
public void CanCompleteAsyncCallsAsSuccess()
{
@ -115,21 +179,19 @@ namespace Microsoft.JSInterop.Tests
}
[Fact]
public void CannotCompleteSameAsyncCallMoreThanOnce()
public async Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync()
{
// Arrange
var runtime = new TestJSRuntime();
// Act/Assert
runtime.InvokeAsync<string>("test identifier", Array.Empty<object>());
var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>());
var asyncHandle = runtime.BeginInvokeCalls[0].AsyncHandle;
runtime.OnEndInvoke(asyncHandle, true, null);
var ex = Assert.Throws<ArgumentException>(() =>
{
// Second "end invoke" will fail
runtime.OnEndInvoke(asyncHandle, true, null);
});
Assert.Equal($"There is no pending task with handle '{asyncHandle}'.", ex.Message);
runtime.OnEndInvoke(asyncHandle, true, new JSAsyncCallResult(JsonDocument.Parse("{}"), JsonDocument.Parse("{\"Message\": \"Some data\"}").RootElement.GetProperty("Message")));
runtime.OnEndInvoke(asyncHandle, false, new JSAsyncCallResult(null, JsonDocument.Parse("{\"Message\": \"Exception\"}").RootElement.GetProperty("Message")));
var result = await task;
Assert.Equal("Some data", result);
}
[Fact]
@ -204,6 +266,14 @@ namespace Microsoft.JSInterop.Tests
{
public List<BeginInvokeAsyncArgs> BeginInvokeCalls = new List<BeginInvokeAsyncArgs>();
public TimeSpan? DefaultTimeout
{
set
{
base.DefaultAsyncTimeout = value;
}
}
public class BeginInvokeAsyncArgs
{
public long AsyncHandle { get; set; }