Skip to content

Blazor Byte Array Interop Support #33015

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

Merged
merged 24 commits into from
Jun 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e538e9b
Blazorserver Byte Array Interop Support
TanayParikh May 25, 2021
137a5f1
PR Feedback
TanayParikh May 26, 2021
e56bf6f
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh May 26, 2021
66815f3
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh May 26, 2021
18ade7c
PR Feedback
TanayParikh May 27, 2021
4a006e6
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh May 28, 2021
8293e92
PR Feedback
TanayParikh May 28, 2021
99c2b2c
Byte Array Interop in WASM (#33104)
TanayParikh May 28, 2021
fc7d066
Fix CI
TanayParikh May 28, 2021
b533959
ByteArrayJsonConverterTest.cs
TanayParikh May 28, 2021
1780543
Additional Tests
TanayParikh Jun 1, 2021
72dae17
Add github issue
TanayParikh Jun 1, 2021
f74f93f
Update DefaultWebAssemblyJSRuntime.cs
TanayParikh Jun 1, 2021
d4fc26e
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh Jun 1, 2021
108b9f2
Update InputFile.ts
TanayParikh Jun 2, 2021
6285ba1
Update release files
TanayParikh Jun 2, 2021
4c3edd6
PR Feedback
TanayParikh Jun 2, 2021
db57f3a
Update WarningSuppression
TanayParikh Jun 2, 2021
514d320
E2E Tests
TanayParikh Jun 2, 2021
11e2ceb
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh Jun 2, 2021
a66eac6
Update roundTripByteArrayAsyncFromJS tests to not use some ES features
TanayParikh Jun 2, 2021
f710bd0
Revert ByteArrayJsonConverter Impl
TanayParikh Jun 3, 2021
02b2b75
PR Feedback
TanayParikh Jun 3, 2021
b6b2fdf
Merge branch 'main' into taparik/byteArrayDirectInterop
TanayParikh Jun 3, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Buffers;
using System.IO;
using MessagePack;
using Microsoft.AspNetCore.SignalR.Protocol;
Expand Down Expand Up @@ -34,6 +35,20 @@ protected override object DeserializeObject(ref MessagePackReader reader, Type t
{
return reader.ReadSingle();
}
else if (type == typeof(byte[]))
{
var bytes = reader.ReadBytes();
if (!bytes.HasValue)
{
return null;
}
else if (bytes.Value.Length == 0)
{
return Array.Empty<byte>();
}

return bytes.Value.ToArray();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still doing ToArray in place of passing through the ReadOnlySequence<byte>.

Concerns with using the ReadOnlySequence relate to the fact that it doesn't implicitly cast to byte[] so when we're examining method parameters we have a type mistmatch.

Additionally, as Javier mentioned in the other PR we need to verify the lifecycle/ownership of the ReadOnlySequence.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concerns with using the ReadOnlySequence relate to the fact that it doesn't implicitly cast to byte[] so when we're examining method parameters we have a type mistmatch.

I think you'd need to change the receiving code (ComponentHub.SupplyByteArray) to accept a ReadOnlySequence<byte> instead of a byte[], so there wouldn't be a type mismatch.

Agreed on verifying the ownership of the buffer before doing that though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, lets keep it as is for the time being and then we can do a quick check and change afterwards.

}
}
catch (Exception ex)
{
Expand Down Expand Up @@ -75,6 +90,10 @@ protected override void Serialize(ref MessagePackWriter writer, Type type, objec
writer.Write(bytes);
break;

case byte[] byteArray:
writer.Write(byteArray);
break;

default:
throw new FormatException($"Unsupported argument type {type}");
}
Expand Down
41 changes: 41 additions & 0 deletions src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,31 @@ await Renderer.Dispatcher.InvokeAsync(() =>
}
}

// ReceiveByteArray is used in a fire-and-forget context, so it's responsible for its own
// error handling.
internal async Task ReceiveByteArray(int id, byte[] data)
{
AssertInitialized();
AssertNotDisposed();

try
{
await Renderer.Dispatcher.InvokeAsync(() =>
{
Log.ReceiveByteArraySuccess(_logger, id);
DotNetDispatcher.ReceiveByteArray(JSRuntime, id, data);
});
}
catch (Exception ex)
{
// An error completing JS interop means that the user sent invalid data, a well-behaved
// client won't do this.
Log.ReceiveByteArrayException(_logger, id, ex);
await TryNotifyClientErrorAsync(Client, GetClientErrorMessage(ex, "Invalid byte array."));
UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false));
}
}

// DispatchEvent is used in a fire-and-forget context, so it's responsible for its own
// error handling.
public async Task DispatchEvent(string eventDescriptorJson, string eventArgsJson)
Expand Down Expand Up @@ -603,6 +628,8 @@ private static class Log
private static readonly Action<ILogger, Exception> _endInvokeDispatchException;
private static readonly Action<ILogger, long, string, Exception> _endInvokeJSFailed;
private static readonly Action<ILogger, long, Exception> _endInvokeJSSucceeded;
private static readonly Action<ILogger, long, Exception> _receiveByteArraySuccess;
private static readonly Action<ILogger, long, Exception> _receiveByteArrayException;
private static readonly Action<ILogger, Exception> _dispatchEventFailedToParseEventData;
private static readonly Action<ILogger, string, Exception> _dispatchEventFailedToDispatchEvent;
private static readonly Action<ILogger, string, CircuitId, Exception> _locationChange;
Expand Down Expand Up @@ -645,6 +672,8 @@ private static class EventIds
public static readonly EventId LocationChangeFailed = new EventId(210, "LocationChangeFailed");
public static readonly EventId LocationChangeFailedInCircuit = new EventId(211, "LocationChangeFailedInCircuit");
public static readonly EventId OnRenderCompletedFailed = new EventId(212, "OnRenderCompletedFailed");
public static readonly EventId ReceiveByteArraySucceeded = new EventId(213, "ReceiveByteArraySucceeded");
public static readonly EventId ReceiveByteArrayException = new EventId(214, "ReceiveByteArrayException");
}

static Log()
Expand Down Expand Up @@ -764,6 +793,16 @@ static Log()
EventIds.EndInvokeJSSucceeded,
"The JS interop call with callback id '{AsyncCall}' succeeded.");

_receiveByteArraySuccess = LoggerMessage.Define<long>(
LogLevel.Debug,
EventIds.ReceiveByteArraySucceeded,
"The ReceiveByteArray call with id '{id}' succeeded.");

_receiveByteArrayException = LoggerMessage.Define<long>(
LogLevel.Debug,
EventIds.ReceiveByteArrayException,
"The ReceiveByteArray call with id '{id}' failed.");

_dispatchEventFailedToParseEventData = LoggerMessage.Define(
LogLevel.Debug,
EventIds.DispatchEventFailedToParseEventData,
Expand Down Expand Up @@ -826,6 +865,8 @@ public static void CircuitHandlerFailed(ILogger logger, CircuitHandler handler,
public static void EndInvokeDispatchException(ILogger logger, Exception ex) => _endInvokeDispatchException(logger, ex);
public static void EndInvokeJSFailed(ILogger logger, long asyncHandle, string arguments) => _endInvokeJSFailed(logger, asyncHandle, arguments, null);
public static void EndInvokeJSSucceeded(ILogger logger, long asyncCall) => _endInvokeJSSucceeded(logger, asyncCall, null);
internal static void ReceiveByteArraySuccess(ILogger logger, long id) => _receiveByteArraySuccess(logger, id, null);
internal static void ReceiveByteArrayException(ILogger logger, long id, Exception ex) => _receiveByteArrayException(logger, id, ex);
public static void DispatchEventFailedToParseEventData(ILogger logger, Exception ex) => _dispatchEventFailedToParseEventData(logger, ex);
public static void DispatchEventFailedToDispatchEvent(ILogger logger, string eventHandlerId, Exception ex) => _dispatchEventFailedToDispatchEvent(logger, eventHandlerId ?? "", ex);

Expand Down
39 changes: 37 additions & 2 deletions src/Components/Server/src/Circuits/RemoteJSRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,22 @@ internal class RemoteJSRuntime : JSRuntime
private readonly ILogger<RemoteJSRuntime> _logger;
private CircuitClientProxy _clientProxy;
private bool _permanentlyDisconnected;
private readonly long _maximumIncomingBytes;
private int _byteArraysToBeRevivedTotalBytes;

public ElementReferenceContext ElementReferenceContext { get; }

public bool IsInitialized => _clientProxy is not null;

public RemoteJSRuntime(IOptions<CircuitOptions> options, ILogger<RemoteJSRuntime> logger)
public RemoteJSRuntime(
IOptions<CircuitOptions> circuitOptions,
IOptions<HubOptions> hubOptions,
ILogger<RemoteJSRuntime> logger)
{
_options = options.Value;
_options = circuitOptions.Value;
_maximumIncomingBytes = hubOptions.Value.MaximumReceiveMessageSize is null
? long.MaxValue
: hubOptions.Value.MaximumReceiveMessageSize.Value;
_logger = logger;
DefaultAsyncTimeout = _options.JSInteropDefaultCallTimeout;
ElementReferenceContext = new WebElementReferenceContext(this);
Expand Down Expand Up @@ -75,6 +83,11 @@ protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in
}
}

protected override void SendByteArray(int id, byte[] data)
{
_clientProxy.SendAsync("JS.ReceiveByteArray", id, data);
}

protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId)
{
if (_clientProxy is null)
Expand All @@ -99,6 +112,28 @@ protected override void BeginInvokeJS(long asyncHandle, string identifier, strin
_clientProxy.SendAsync("JS.BeginInvokeJS", asyncHandle, identifier, argsJson, (int)resultType, targetInstanceId);
}

protected override void ReceiveByteArray(int id, byte[] data)
{
if (id == 0)
{
// Starting a new transfer, clear out number of bytes read.
_byteArraysToBeRevivedTotalBytes = 0;
}

if (_maximumIncomingBytes - data.Length < _byteArraysToBeRevivedTotalBytes)
{
throw new ArgumentOutOfRangeException("Exceeded the maximum byte array transfer limit for a call.");
}

// We also store the total number of bytes seen so far to compare against
// the MaximumIncomingBytes limit.
// We take the larger of the size of the array or 4, to ensure we're not inundated
// with small/empty arrays.
_byteArraysToBeRevivedTotalBytes += Math.Max(4, data.Length);

base.ReceiveByteArray(id, data);
}

public void MarkPermanentlyDisconnected()
{
_permanentlyDisconnected = true;
Expand Down
11 changes: 11 additions & 0 deletions src/Components/Server/src/ComponentHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ public async ValueTask EndInvokeJSFromDotNet(long asyncHandle, bool succeeded, s
_ = circuitHost.EndInvokeJSFromDotNet(asyncHandle, succeeded, arguments);
}

public async ValueTask ReceiveByteArray(int id, byte[] data)
{
var circuitHost = await GetActiveCircuitAsync();
if (circuitHost == null)
{
return;
}

await circuitHost.ReceiveByteArray(id, data);
}

public async ValueTask DispatchBrowserEvent(string eventDescriptor, string eventArgs)
{
var circuitHost = await GetActiveCircuitAsync();
Expand Down
1 change: 0 additions & 1 deletion src/Components/Server/test/Circuits/CircuitHostTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Web.Rendering;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
Expand Down
5 changes: 2 additions & 3 deletions src/Components/Server/test/Circuits/TestCircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Components.Web.Rendering;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
Expand All @@ -29,7 +28,7 @@ public static CircuitHost Create(
{
serviceScope = serviceScope ?? new AsyncServiceScope(Mock.Of<IServiceScope>());
clientProxy = clientProxy ?? new CircuitClientProxy(Mock.Of<IClientProxy>(), Guid.NewGuid().ToString());
var jsRuntime = new RemoteJSRuntime(Options.Create(new CircuitOptions()), Mock.Of<ILogger<RemoteJSRuntime>>());
var jsRuntime = new RemoteJSRuntime(Options.Create(new CircuitOptions()), Options.Create(new HubOptions()), Mock.Of<ILogger<RemoteJSRuntime>>());

if (remoteRenderer == null)
{
Expand All @@ -42,7 +41,7 @@ public static CircuitHost Create(
null);
}

handlers = handlers ?? Array.Empty<CircuitHandler>();
handlers ??= Array.Empty<CircuitHandler>();
return new TestCircuitHost(
circuitId is null ? new CircuitId(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()) : circuitId.Value,
serviceScope.Value,
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Shared/src/ArrayBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace Ignitor
namespace Microsoft.AspNetCore.Components.WebView
#elif COMPONENTS_SERVER
namespace Microsoft.AspNetCore.Components.Server.Circuits
#elif JS_INTEROP
namespace Microsoft.JSInterop.Infrastructure
#else
namespace Microsoft.AspNetCore.Components.RenderTree
#endif
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.webview.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/Components/Web.JS/src/Boot.Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger
connection.on('JS.AttachComponent', (componentId, selector) => attachRootComponentToLogicalElement(0, circuit.resolveElement(selector), componentId));
connection.on('JS.BeginInvokeJS', DotNet.jsCallDispatcher.beginInvokeJSFromDotNet);
connection.on('JS.EndInvokeDotNet', DotNet.jsCallDispatcher.endInvokeDotNetFromJS);
connection.on('JS.ReceiveByteArray', DotNet.jsCallDispatcher.receiveByteArray);

const renderQueue = RenderQueue.getOrCreate(logger);
connection.on('JS.RenderBatch', (batchId: number, batchData: Uint8Array) => {
Expand Down Expand Up @@ -134,6 +135,9 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger
endInvokeJSFromDotNet: (asyncHandle, succeeded, argsJson): void => {
connection.send('EndInvokeJSFromDotNet', asyncHandle, succeeded, argsJson);
},
sendByteArray: (id: number, data: Uint8Array): void => {
connection.send('ReceiveByteArray', id, data);
},
});

return connection;
Expand Down
22 changes: 20 additions & 2 deletions src/Components/Web.JS/src/Boot.WebAssembly.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
/* eslint-disable array-element-newline */
import { DotNet } from '@microsoft/dotnet-js-interop';
import { Blazor } from './GlobalExports';
import * as Environment from './Environment';
import { monoPlatform } from './Platform/Mono/MonoPlatform';
import { byteArrayBeingTransferred, monoPlatform } from './Platform/Mono/MonoPlatform';
import { renderBatch, getRendererer, attachRootComponentToElement, attachRootComponentToLogicalElement } from './Rendering/Renderer';
import { SharedMemoryRenderBatch } from './Rendering/RenderBatch/SharedMemoryRenderBatch';
import { shouldAutoStart } from './BootCommon';
import { setEventDispatcher } from './Rendering/Events/EventDispatcher';
import { WebAssemblyResourceLoader } from './Platform/WebAssemblyResourceLoader';
import { WebAssemblyConfigLoader } from './Platform/WebAssemblyConfigLoader';
import { BootConfigResult } from './Platform/BootConfig';
import { Pointer, System_Boolean, System_String } from './Platform/Platform';
import { Pointer, System_Array, System_Boolean, System_Byte, System_Int, System_Object, System_String } from './Platform/Platform';
import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions';
import { WebAssemblyComponentAttacher } from './Platform/WebAssemblyComponentAttacher';
import { discoverComponents, discoverPersistedState, WebAssemblyComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
Expand Down Expand Up @@ -45,6 +46,8 @@ async function boot(options?: Partial<WebAssemblyStartOptions>): Promise<void> {
// Configure JS interop
Blazor._internal.invokeJSFromDotNet = invokeJSFromDotNet;
Blazor._internal.endInvokeDotNetFromJS = endInvokeDotNetFromJS;
Blazor._internal.receiveByteArray = receiveByteArray;
Blazor._internal.retrieveByteArray = retrieveByteArray;

// Configure environment for execution under Mono WebAssembly with shared-memory rendering
const platform = Environment.setPlatform(monoPlatform);
Expand Down Expand Up @@ -161,6 +164,21 @@ function endInvokeDotNetFromJS(callId: System_String, success: System_Boolean, r
DotNet.jsCallDispatcher.endInvokeDotNetFromJS(callIdString, successBool, resultJsonOrErrorMessageString);
}

function receiveByteArray(id: System_Int, data: System_Array<System_Byte>): void {
const idLong = id as any as number;
const dataByteArray = monoPlatform.toUint8Array(data);
DotNet.jsCallDispatcher.receiveByteArray(idLong, dataByteArray);
}

function retrieveByteArray(): System_Object {
if (byteArrayBeingTransferred === null) {
throw new Error('Byte array not available for transfer');
}

const typedArray = BINDING.js_typed_array_to_array(byteArrayBeingTransferred);
return typedArray;
}

Blazor.start = boot;
if (shouldAutoStart()) {
boot().catch(error => {
Expand Down
3 changes: 2 additions & 1 deletion src/Components/Web.JS/src/Boot.WebView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { shouldAutoStart } from './BootCommon';
import { internalFunctions as navigationManagerFunctions } from './Services/NavigationManager';
import { setEventDispatcher } from './Rendering/Events/EventDispatcher';
import { startIpcReceiver } from './Platform/WebView/WebViewIpcReceiver';
import { sendBrowserEvent, sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendLocationChanged } from './Platform/WebView/WebViewIpcSender';
import { sendBrowserEvent, sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendByteArray, sendLocationChanged } from './Platform/WebView/WebViewIpcSender';
import { InputFile } from './InputFile';

let started = false;
Expand All @@ -20,6 +20,7 @@ async function boot(): Promise<void> {
DotNet.attachDispatcher({
beginInvokeDotNetFromJS: sendBeginInvokeDotNetFromJS,
endInvokeJSFromDotNet: sendEndInvokeJSFromDotNet,
sendByteArray: sendByteArray,
});

Blazor._internal.InputFile = InputFile;
Expand Down
4 changes: 3 additions & 1 deletion src/Components/Web.JS/src/GlobalExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { InputFile } from './InputFile';
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
import { CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions';
import { Platform, Pointer, System_String, System_Array, System_Object, System_Boolean } from './Platform/Platform';
import { Platform, Pointer, System_String, System_Array, System_Object, System_Boolean, System_Byte, System_Int } from './Platform/Platform';

interface IBlazor {
navigateTo: (uri: string, forceLoad: boolean, replace: boolean) => void;
Expand All @@ -27,6 +27,8 @@ interface IBlazor {
InputFile?: typeof InputFile,
invokeJSFromDotNet?: (callInfo: Pointer, arg0: any, arg1: any, arg2: any) => any;
endInvokeDotNetFromJS?: (callId: System_String, success: System_Boolean, resultJsonOrErrorMessage: System_String) => void;
receiveByteArray?: (id: System_Int, data: System_Array<System_Byte>) => void;
retrieveByteArray?: () => System_Object;
getPersistedState?: () => System_String;
attachRootComponentToElement?: (arg0: any, arg1: any, arg2: any) => void;
registeredComponents?: {
Expand Down
6 changes: 3 additions & 3 deletions src/Components/Web.JS/src/InputFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ async function ensureArrayBufferReadyForSharedMemoryInterop(elem: InputElement,
getFileById(elem, fileId).arrayBuffer = arrayBuffer;
}

async function readFileData(elem: InputElement, fileId: number, startOffset: number, count: number): Promise<string> {
async function readFileData(elem: InputElement, fileId: number, startOffset: number, count: number): Promise<Uint8Array> {
const arrayBuffer = await getArrayBufferFromFileAsync(elem, fileId);
return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer, startOffset, count) as unknown as number[]));
return new Uint8Array(arrayBuffer, startOffset, count);
}
Comment on lines -104 to 107
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required change to our current InputFile implementation as we're expecting a byte[] return value.


export function getFileById(elem: InputElement, fileId: number): BrowserFile {
Expand Down Expand Up @@ -134,4 +134,4 @@ function getArrayBufferFromFileAsync(elem: InputElement, fileId: number): Promis
}

return file.readPromise;
}
}
Loading