Skip to content

Commit c24711c

Browse files
authored
Use Utf8JsonReader in DotNetDispatcher (#2061)
* Use Utf8JsonReader in DotNetDispatcher Fixes dotnet/aspnetcore#10988
1 parent af1a168 commit c24711c

File tree

5 files changed

+430
-163
lines changed

5 files changed

+430
-163
lines changed

src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs

Lines changed: 79 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Linq;
88
using System.Reflection;
99
using System.Runtime.ExceptionServices;
10+
using System.Text;
1011
using System.Text.Json;
1112
using System.Threading.Tasks;
1213

@@ -18,7 +19,6 @@ namespace Microsoft.JSInterop
1819
public static class DotNetDispatcher
1920
{
2021
internal static readonly JsonEncodedText DotNetObjectRefKey = JsonEncodedText.Encode("__dotNetObject");
21-
private static readonly Type[] EndInvokeParameterTypes = new Type[] { typeof(long), typeof(bool), typeof(JSAsyncCallResult) };
2222

2323
private static readonly ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>> _cachedMethodsByAssembly
2424
= new ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>>();
@@ -74,7 +74,6 @@ public static void BeginInvoke(string callId, string assemblyName, string method
7474
// code has to implement its own way of returning async results.
7575
var jsRuntimeBaseInstance = (JSRuntimeBase)JSRuntime.Current;
7676

77-
7877
// Using ExceptionDispatchInfo here throughout because we want to always preserve
7978
// original stack traces.
8079
object syncResult = null;
@@ -165,81 +164,64 @@ private static object InvokeSynchronously(string assemblyName, string methodIden
165164
}
166165
}
167166

168-
private static object[] ParseArguments(string methodIdentifier, string argsJson, Type[] parameterTypes)
167+
internal static object[] ParseArguments(string methodIdentifier, string arguments, Type[] parameterTypes)
169168
{
170169
if (parameterTypes.Length == 0)
171170
{
172171
return Array.Empty<object>();
173172
}
174173

175-
// There's no direct way to say we want to deserialize as an array with heterogenous
176-
// entry types (e.g., [string, int, bool]), so we need to deserialize in two phases.
177-
var jsonDocument = JsonDocument.Parse(argsJson);
178-
var shouldDisposeJsonDocument = true;
179-
try
174+
var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments);
175+
var reader = new Utf8JsonReader(utf8JsonBytes);
176+
if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
180177
{
181-
if (jsonDocument.RootElement.ValueKind != JsonValueKind.Array)
182-
{
183-
throw new ArgumentException($"Expected a JSON array but got {jsonDocument.RootElement.ValueKind}.");
184-
}
178+
throw new JsonException("Invalid JSON");
179+
}
185180

186-
var suppliedArgsLength = jsonDocument.RootElement.GetArrayLength();
181+
var suppliedArgs = new object[parameterTypes.Length];
187182

188-
if (suppliedArgsLength != parameterTypes.Length)
183+
var index = 0;
184+
while (index < parameterTypes.Length && reader.Read() && reader.TokenType != JsonTokenType.EndArray)
185+
{
186+
var parameterType = parameterTypes[index];
187+
if (reader.TokenType == JsonTokenType.StartObject && IsIncorrectDotNetObjectRefUse(parameterType, reader))
189188
{
190-
throw new ArgumentException($"In call to '{methodIdentifier}', expected {parameterTypes.Length} parameters but received {suppliedArgsLength}.");
189+
throw new InvalidOperationException($"In call to '{methodIdentifier}', parameter of type '{parameterType.Name}' at index {(index + 1)} must be declared as type 'DotNetObjectRef<{parameterType.Name}>' to receive the incoming value.");
191190
}
192191

193-
// Second, convert each supplied value to the type expected by the method
194-
var suppliedArgs = new object[parameterTypes.Length];
195-
var index = 0;
196-
foreach (var item in jsonDocument.RootElement.EnumerateArray())
197-
{
198-
var parameterType = parameterTypes[index];
199-
200-
if (parameterType == typeof(JSAsyncCallResult))
201-
{
202-
// We will pass the JsonDocument instance to JAsyncCallResult and make JSRuntimeBase
203-
// responsible for disposing it.
204-
shouldDisposeJsonDocument = false;
205-
// For JS async call results, we have to defer the deserialization until
206-
// later when we know what type it's meant to be deserialized as
207-
suppliedArgs[index] = new JSAsyncCallResult(jsonDocument, item);
208-
}
209-
else if (IsIncorrectDotNetObjectRefUse(item, parameterType))
210-
{
211-
throw new InvalidOperationException($"In call to '{methodIdentifier}', parameter of type '{parameterType.Name}' at index {(index + 1)} must be declared as type 'DotNetObjectRef<{parameterType.Name}>' to receive the incoming value.");
212-
}
213-
else
214-
{
215-
suppliedArgs[index] = JsonSerializer.Deserialize(item.GetRawText(), parameterType, JsonSerializerOptionsProvider.Options);
216-
}
217-
218-
index++;
219-
}
220-
221-
if (shouldDisposeJsonDocument)
222-
{
223-
jsonDocument.Dispose();
224-
}
192+
suppliedArgs[index] = JsonSerializer.Deserialize(ref reader, parameterType, JsonSerializerOptionsProvider.Options);
193+
index++;
194+
}
225195

226-
return suppliedArgs;
196+
if (index < parameterTypes.Length)
197+
{
198+
// If we parsed fewer parameters, we can always make a definitive claim about how many parameters were received.
199+
throw new ArgumentException($"The call to '{methodIdentifier}' expects '{parameterTypes.Length}' parameters, but received '{index}'.");
227200
}
228-
catch
201+
202+
if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray)
229203
{
230-
// Always dispose the JsonDocument in case of an error.
231-
jsonDocument?.Dispose();
232-
throw;
204+
// Either we received more parameters than we expected or the JSON is malformed.
205+
throw new JsonException($"Unexpected JSON token {reader.TokenType}. Ensure that the call to `{methodIdentifier}' is supplied with exactly '{parameterTypes.Length}' parameters.");
233206
}
234207

235-
static bool IsIncorrectDotNetObjectRefUse(JsonElement item, Type parameterType)
208+
return suppliedArgs;
209+
210+
// Note that the JsonReader instance is intentionally not passed by ref (or an in parameter) since we want a copy of the original reader.
211+
static bool IsIncorrectDotNetObjectRefUse(Type parameterType, Utf8JsonReader jsonReader)
236212
{
237213
// Check for incorrect use of DotNetObjectRef<T> at the top level. We know it's
238214
// an incorrect use if there's a object that looks like { '__dotNetObject': <some number> },
239215
// but we aren't assigning to DotNetObjectRef{T}.
240-
return item.ValueKind == JsonValueKind.Object &&
241-
item.TryGetProperty(DotNetObjectRefKey.EncodedUtf8Bytes, out _) &&
242-
!typeof(IDotNetObjectRef).IsAssignableFrom(parameterType);
216+
if (jsonReader.Read() &&
217+
jsonReader.TokenType == JsonTokenType.PropertyName &&
218+
jsonReader.ValueTextEquals(DotNetObjectRefKey.EncodedUtf8Bytes))
219+
{
220+
// The JSON payload has the shape we expect from a DotNetObjectRef instance.
221+
return !parameterType.IsGenericType || parameterType.GetGenericTypeDefinition() != typeof(DotNetObjectRef<>);
222+
}
223+
224+
return false;
243225
}
244226
}
245227

@@ -248,9 +230,9 @@ static bool IsIncorrectDotNetObjectRefUse(JsonElement item, Type parameterType)
248230
/// associated <see cref="Task"/> as completed.
249231
/// </summary>
250232
/// <remarks>
251-
/// All exceptions from <see cref="EndInvoke(long, bool, JSAsyncCallResult)"/> are caught
233+
/// All exceptions from <see cref="EndInvoke"/> are caught
252234
/// are delivered via JS interop to the JavaScript side when it requests confirmation, as
253-
/// the mechanism to call <see cref="EndInvoke(long, bool, JSAsyncCallResult)"/> relies on
235+
/// the mechanism to call <see cref="EndInvoke"/> relies on
254236
/// using JS->.NET interop. This overload is meant for directly triggering completion callbacks
255237
/// for .NET -> JS operations without going through JS interop, so the callsite for this
256238
/// method is responsible for handling any possible exception generated from the arguments
@@ -263,16 +245,40 @@ static bool IsIncorrectDotNetObjectRefUse(JsonElement item, Type parameterType)
263245
/// </exception>
264246
public static void EndInvoke(string arguments)
265247
{
266-
var parsedArgs = ParseArguments(
267-
nameof(EndInvoke),
268-
arguments,
269-
EndInvokeParameterTypes);
270-
271-
EndInvoke((long)parsedArgs[0], (bool)parsedArgs[1], (JSAsyncCallResult)parsedArgs[2]);
248+
var jsRuntimeBase = (JSRuntimeBase)JSRuntime.Current;
249+
ParseEndInvokeArguments(jsRuntimeBase, arguments);
272250
}
273251

274-
private static void EndInvoke(long asyncHandle, bool succeeded, JSAsyncCallResult result)
275-
=> ((JSRuntimeBase)JSRuntime.Current).EndInvokeJS(asyncHandle, succeeded, result);
252+
internal static void ParseEndInvokeArguments(JSRuntimeBase jsRuntimeBase, string arguments)
253+
{
254+
var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments);
255+
256+
// The payload that we're trying to parse is of the format
257+
// [ taskId: long, success: boolean, value: string? | object ]
258+
// where value is the .NET type T originally specified on InvokeAsync<T> or the error string if success is false.
259+
// We parse the first two arguments and call in to JSRuntimeBase to deserialize the actual value.
260+
261+
var reader = new Utf8JsonReader(utf8JsonBytes);
262+
263+
if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
264+
{
265+
throw new JsonException("Invalid JSON");
266+
}
267+
268+
reader.Read();
269+
var taskId = reader.GetInt64();
270+
271+
reader.Read();
272+
var success = reader.GetBoolean();
273+
274+
reader.Read();
275+
jsRuntimeBase.EndInvokeJS(taskId, success, ref reader);
276+
277+
if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray)
278+
{
279+
throw new JsonException("Invalid JSON");
280+
}
281+
}
276282

277283
/// <summary>
278284
/// Releases the reference to the specified .NET object. This allows the .NET runtime
@@ -362,7 +368,13 @@ private static Assembly GetRequiredLoadedAssembly(AssemblyKey assemblyKey)
362368
// In some edge cases this might force developers to explicitly call something on the
363369
// target assembly (from .NET) before they can invoke its allowed methods from JS.
364370
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
365-
return loadedAssemblies.FirstOrDefault(a => new AssemblyKey(a).Equals(assemblyKey))
371+
372+
// Using LastOrDefault to workaround for https://github.com/dotnet/arcade/issues/2816.
373+
// In most ordinary scenarios, we wouldn't have two instances of the same Assembly in the AppDomain
374+
// so this doesn't change the outcome.
375+
var assembly = loadedAssemblies.LastOrDefault(a => new AssemblyKey(a).Equals(assemblyKey));
376+
377+
return assembly
366378
?? throw new ArgumentException($"There is no loaded assembly with the name '{assemblyKey.AssemblyName}'.");
367379
}
368380

@@ -396,6 +408,5 @@ public bool Equals(AssemblyKey other)
396408

397409
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(AssemblyName);
398410
}
399-
400411
}
401412
}

src/JSInterop/Microsoft.JSInterop/src/JSAsyncCallResult.cs

Lines changed: 0 additions & 36 deletions
This file was deleted.

src/JSInterop/Microsoft.JSInterop/src/JSRuntimeBase.cs

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using System.Collections.Concurrent;
66
using System.Collections.Generic;
77
using System.Linq;
8-
using System.Runtime.ExceptionServices;
98
using System.Text.Json;
109
using System.Threading;
1110
using System.Threading.Tasks;
@@ -139,41 +138,37 @@ protected internal abstract void EndInvokeDotNet(
139138
string methodIdentifier,
140139
long dotNetObjectId);
141140

142-
internal void EndInvokeJS(long taskId, bool succeeded, JSAsyncCallResult asyncCallResult)
141+
internal void EndInvokeJS(long taskId, bool succeeded, ref Utf8JsonReader jsonReader)
143142
{
144-
using (asyncCallResult?.JsonDocument)
143+
if (!_pendingTasks.TryRemove(taskId, out var tcs))
145144
{
146-
if (!_pendingTasks.TryRemove(taskId, out var tcs))
147-
{
148-
// We should simply return if we can't find an id for the invocation.
149-
// This likely means that the method that initiated the call defined a timeout and stopped waiting.
150-
return;
151-
}
145+
// We should simply return if we can't find an id for the invocation.
146+
// This likely means that the method that initiated the call defined a timeout and stopped waiting.
147+
return;
148+
}
152149

153-
CleanupTasksAndRegistrations(taskId);
150+
CleanupTasksAndRegistrations(taskId);
154151

152+
try
153+
{
155154
if (succeeded)
156155
{
157156
var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs);
158-
try
159-
{
160-
var result = asyncCallResult != null ?
161-
JsonSerializer.Deserialize(asyncCallResult.JsonElement.GetRawText(), resultType, JsonSerializerOptionsProvider.Options) :
162-
null;
163-
TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result);
164-
}
165-
catch (Exception exception)
166-
{
167-
var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details.";
168-
TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception));
169-
}
157+
158+
var result = JsonSerializer.Deserialize(ref jsonReader, resultType, JsonSerializerOptionsProvider.Options);
159+
TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result);
170160
}
171161
else
172162
{
173-
var exceptionText = asyncCallResult?.JsonElement.ToString() ?? string.Empty;
163+
var exceptionText = jsonReader.GetString() ?? string.Empty;
174164
TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(exceptionText));
175165
}
176166
}
167+
catch (Exception exception)
168+
{
169+
var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details.";
170+
TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception));
171+
}
177172
}
178173
}
179174
}

0 commit comments

Comments
 (0)