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:
parent
244ed54764
commit
5e4f1f5fb2
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue