Merge branch 'master' into merge/release/3.0-preview7-to-master\n\nCommit migrated from 609186745f

This commit is contained in:
Doug Bunting 2019-07-02 21:53:59 -07:00 committed by GitHub
commit 8402271cf3
6 changed files with 254 additions and 41 deletions

View File

@ -32,6 +32,7 @@ namespace Microsoft.JSInterop
}
public partial interface IJSRuntime
{
System.Threading.Tasks.Task<TValue> InvokeAsync<TValue>(string identifier, System.Collections.Generic.IEnumerable<object> args, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));
System.Threading.Tasks.Task<TValue> InvokeAsync<TValue>(string identifier, params object[] args);
}
public partial class JSException : System.Exception
@ -59,8 +60,11 @@ 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; }
}
}
namespace Microsoft.JSInterop.Internal

View File

@ -103,7 +103,7 @@ namespace Microsoft.JSInterop
else if (syncException != null)
{
// Threw synchronously, let's respond.
jsRuntimeBaseInstance.EndInvokeDotNet(callId, false, syncException);
jsRuntimeBaseInstance.EndInvokeDotNet(callId, false, syncException, assemblyName, methodIdentifier);
}
else if (syncResult is Task task)
{
@ -114,16 +114,17 @@ namespace Microsoft.JSInterop
if (t.Exception != null)
{
var exception = t.Exception.GetBaseException();
jsRuntimeBaseInstance.EndInvokeDotNet(callId, false, ExceptionDispatchInfo.Capture(exception));
jsRuntimeBaseInstance.EndInvokeDotNet(callId, false, ExceptionDispatchInfo.Capture(exception), assemblyName, methodIdentifier);
}
var result = TaskGenericsUtil.GetTaskResult(task);
jsRuntimeBaseInstance.EndInvokeDotNet(callId, true, result);
jsRuntimeBaseInstance.EndInvokeDotNet(callId, true, result, assemblyName, methodIdentifier);
}, TaskScheduler.Current);
}
else
{
jsRuntimeBaseInstance.EndInvokeDotNet(callId, true, syncResult);
jsRuntimeBaseInstance.EndInvokeDotNet(callId, true, syncResult, assemblyName, methodIdentifier);
}
}

View File

@ -1,7 +1,8 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.JSInterop
@ -19,5 +20,15 @@ namespace Microsoft.JSInterop
/// <param name="args">JSON-serializable arguments.</param>
/// <returns>An instance of <typeparamref name="TValue"/> obtained by JSON-deserializing the return value.</returns>
Task<TValue> InvokeAsync<TValue>(string identifier, params object[] args);
/// <summary>
/// Invokes the specified JavaScript function asynchronously.
/// </summary>
/// <typeparam name="TValue">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="TValue"/> obtained by JSON-deserializing the return value.</returns>
Task<TValue> InvokeAsync<TValue>(string identifier, IEnumerable<object> args, CancellationToken cancellationToken = default);
}
}

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,49 +96,58 @@ 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);
internal void EndInvokeDotNet(string callId, bool success, object resultOrException)
/// <summary>
/// Allows derived classes to configure the information about an exception in a JS interop call that gets sent to JavaScript.
/// </summary>
/// <remarks>
/// This callback can be used in remote JS interop scenarios to sanitize exceptions that happen on the server to avoid disclosing
/// sensitive information to remote browser clients.
/// </remarks>
/// <param name="exception">The exception that occurred.</param>
/// <param name="assemblyName">The assembly for the invoked .NET method.</param>
/// <param name="methodIdentifier">The identifier for the invoked .NET method.</param>
/// <returns>An object containing information about the exception.</returns>
protected virtual object OnDotNetInvocationException(Exception exception, string assemblyName, string methodIdentifier) => exception.ToString();
internal void EndInvokeDotNet(string callId, bool success, object resultOrException, string assemblyName, string methodIdentifier)
{
// For failures, the common case is to call EndInvokeDotNet with the Exception object.
// For these we'll serialize as something that's useful to receive on the JS side.
// If the value is not an Exception, we'll just rely on it being directly JSON-serializable.
if (!success && resultOrException is Exception)
if (!success && resultOrException is Exception ex)
{
resultOrException = resultOrException.ToString();
resultOrException = OnDotNetInvocationException(ex, assemblyName, methodIdentifier);
}
else if (!success && resultOrException is ExceptionDispatchInfo edi)
{
resultOrException = edi.SourceException.ToString();
resultOrException = OnDotNetInvocationException(edi.SourceException, assemblyName, methodIdentifier);
}
// We pass 0 as the async handle because we don't want the JS-side code to
@ -82,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]
@ -168,10 +230,50 @@ namespace Microsoft.JSInterop.Tests
Assert.Same(obj3, runtime.ObjectRefManager.FindDotNetObject(4));
}
[Fact]
public void CanSanitizeDotNetInteropExceptions()
{
// Arrange
var expectedMessage = "An error ocurred while invoking '[Assembly]::Method'. Swapping to 'Development' environment will " +
"display more detailed information about the error that occurred.";
string GetMessage(string assembly, string method) => $"An error ocurred while invoking '[{assembly}]::{method}'. Swapping to 'Development' environment will " +
"display more detailed information about the error that occurred.";
var runtime = new TestJSRuntime()
{
OnDotNetException = (e, a, m) => new JSError { Message = GetMessage(a, m) }
};
var exception = new Exception("Some really sensitive data in here");
// Act
runtime.EndInvokeDotNet("0", false, exception, "Assembly", "Method");
// Assert
var call = runtime.BeginInvokeCalls.Single();
Assert.Equal(0, call.AsyncHandle);
Assert.Equal("DotNet.jsCallDispatcher.endInvokeDotNetFromJS", call.Identifier);
Assert.Equal($"[\"0\",false,{{\"message\":\"{expectedMessage.Replace("'", "\\u0027")}\"}}]", call.ArgsJson);
}
private class JSError
{
public string Message { get; set; }
}
class TestJSRuntime : JSRuntimeBase
{
public List<BeginInvokeAsyncArgs> BeginInvokeCalls = new List<BeginInvokeAsyncArgs>();
public TimeSpan? DefaultTimeout
{
set
{
base.DefaultAsyncTimeout = value;
}
}
public class BeginInvokeAsyncArgs
{
public long AsyncHandle { get; set; }
@ -179,6 +281,18 @@ namespace Microsoft.JSInterop.Tests
public string ArgsJson { get; set; }
}
public Func<Exception, string, string, object> OnDotNetException { get; set; }
protected override object OnDotNetInvocationException(Exception exception, string assemblyName, string methodName)
{
if (OnDotNetException != null)
{
return OnDotNetException(exception, assemblyName, methodName);
}
return base.OnDotNetInvocationException(exception, assemblyName, methodName);
}
protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson)
{
BeginInvokeCalls.Add(new BeginInvokeAsyncArgs

View File

@ -2,7 +2,9 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
@ -29,6 +31,9 @@ namespace Microsoft.JSInterop.Tests
{
public Task<T> InvokeAsync<T>(string identifier, params object[] args)
=> throw new NotImplementedException();
public Task<TValue> InvokeAsync<TValue>(string identifier, IEnumerable<object> args, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
}
}