Skip to content
This repository has been archived by the owner on Feb 25, 2021. It is now read-only.

JavaScript interop v3 #1033

Merged
merged 2 commits into from
Jun 25, 2018
Merged

JavaScript interop v3 #1033

merged 2 commits into from
Jun 25, 2018

Conversation

SteveSandersonMS
Copy link
Member

This is one of the big pieces needed for #959. It changes how the JS interop works so it's factored out of the Blazor codebase itself, and makes "async" calls the mainstream lowest-common-denominator case (with sync and unmarshalled calls still available via certain casts if you really need that).

It's a monstrously big PR, but it does all have to happen in one go because once you change the underpinnings there are so many consequences. @rynowak, we can discuss whether or how you want to review this.

I'll write up a list of changes (especially the breaking API changes) in here later. For now I just want the CI run to start so am submitting this.

@SteveSandersonMS SteveSandersonMS added this to the 0.5.0 milestone Jun 22, 2018
DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, argsJson) => {
monoPlatform.callMethod(dotNetDispatcherBeginInvokeMethodHandle, null, [
callId ? monoPlatform.toDotNetString(callId.toString()) : null, // TODO: Implement a way to pass a number to a long without stringification
Copy link
Member Author

Choose a reason for hiding this comment

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

Am still planning to do this "TODO" item.

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed my mind. The two approaches I considered both don't fit well.

  • Allocating the 8 bytes on the stack and passing directly as a raw value parameter: The existing code that prepares the params array on the stack assumes 4 bytes per param, and generalising it will come at a perf cost for every invocation, plus complicate the Platform interface considerably.
  • Passing as a boxed long: Mono doesn't currently expose any convenient APIs for allocating a heap object of an arbitrary type. And even if it did that's still a heap allocation, so the perf gain over passing as a string is not massive.

For now I'm keeping the "pass as string" behavior. It's not optimal for perf but it only affects this one code path. We can reconsider in the future if we decide it's consequential.

return RegisteredFunction.Invoke<string>(
ShowPromptIdentifier,
return JSRuntime.Current.InvokeAsync<string>(
"TestContentPackage.showPrompt", // Keep in sync with identifiers in the.js file
Copy link
Member Author

Choose a reason for hiding this comment

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

After we merge this PR to dev and there is a new build on the dev feed, we need to remember to go back to the BlazorLib project template and make the equivalent change to this. Can't do so until there is a new build on the dev feed with the new APIs in it.

Copy link
Member

Choose a reason for hiding this comment

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

Reminder - we need to do this 😆

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, done in 36dc336

Awkwardly the new convention of making the JS functions globally-reachable via an expression of the form window.something.somethingElse.etcetera means there's no longer a simple way to auto-generate different per-project namespaces matching the project name in JS code. This is because AFAICT dotnet new doesn't have a built-in way of saying you want to inject the "safe" version of the project name in specific places, so we can't use the project name in the JS code because it might contain dots, and our lookup logic interprets dots as property name separators.

Net result is that I've now hard-coded the namespace of the showPrompt example function as exampleJsFunctions, which I think is adequate to make the point that developers should change it to something that makes sense in their project.

The only way this would be a problem is if people actually start shipping NuGet packages in which the JS functions are still attached to a global called exampleJsFunctions, and then trying to use multiple such packages would lead to name clashes. But I think that's extremely unlikely and would obviously be a package authoring error.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I would not bother with it... A comment in the docs saying that people should change to a meaningful (and safe) name is necessary for sanity...

@@ -4,13 +4,14 @@
"description": "",
"main": "index.js",
"scripts": {
"build": "webpack",
"build": "webpack --mode development",
Copy link
Member

Choose a reason for hiding this comment

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

Assuming this is temporary 😆

Copy link
Member Author

Choose a reason for hiding this comment

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

This is just retaining the existing behavior. We're tracking "do minification" in #1003.

renderBatch,
http: httpInternalFunctions,
uriHelper: uriHelperInternalFunctions
}
Copy link
Member

Choose a reason for hiding this comment

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

I can see that you're doing a relayer on some of the internals of blazor.js - I'm mostly ignoring these since I'm not sure I would have any meaningful feedback.

Copy link
Member

Choose a reason for hiding this comment

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

So as I understand it - you're making everything was a registered function into a function that's reachable on some path from the root (window) since that's the new mechanism we're using to bind functions.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, exactly.


namespace Microsoft.AspNetCore.Blazor.Browser.Interop
{
internal class ArgumentList
Copy link
Member

Choose a reason for hiding this comment

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

RIP

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, the updated equivalent to this is: https://github.com/aspnet/Blazor/pull/1033/files#diff-b80ce524aaadc1a948d2f5b50db36160R77

Instead of having to create a special wire format for args arrays and then a lot of .NET-side logic for parsing and extracting values, it was possible just to JSON.serialize the whole args array on the JS side with no preprocessing, and then do a two-phase deserialize on the .NET side. It shouldn't cost anything extra in perf as in effect it was doing this before but just not retaining the information it needed.

{
// When implementing support for an out-of-process JS runtime, we'll need to
// do something here to serialize and transmit the RenderBatch efficiently.
throw new NotImplementedException("TODO: Support BrowserRenderer.UpdateDisplay on other runtimes.");
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't that be another class though (not BrowserRenderer)? I'd imagine this could just do a hard cast like L68

We'd start by writing another class, and then would never arrive at this TODO since we'd be writing another class 😆

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, that's possible! I strongly expect we'll be tweaking the layering a bit when/if we implement another Renderer, since there is probably some aspects of BrowserRenderer that we'd want to share across renderers. The exact layer boundaries probably have to move around a bit.

// Temporarily enable MonoWebAssemblyJSRuntime on this class constructor
// Later it will become part of the app startup and config mechanism
JSRuntime.SetCurrentJSRuntime(new MonoWebAssemblyJSRuntime());
}
Copy link
Member

Choose a reason for hiding this comment

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

I have some thoughts about how this fits in to the overall picture - this hack seems fine for now.

Copy link
Member Author

Choose a reason for hiding this comment

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

Great, looking forwards to tying all this together.

static BrowserServiceProvider()
{
// TODO: Remove once we make this part of the app startup mechanism
ActivateMonoJSRuntime.EnsureActivated();
Copy link
Member

Choose a reason for hiding this comment

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

You might want to return something and do a GC.KeepAlive() on the result. I'm not sure if the linker could remove this or not.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point. Done.

The linker wasn't stripping it out before, but maybe an updated linker might be smart enough to do so. So I agree it's a good idea to GC.KeepAlive on a returned object to ensure it isn't stripped.


<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Microsoft.JSInterop\Microsoft.JSInterop.csproj" />
</ItemGroup>
Copy link
Member

Choose a reason for hiding this comment

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

It looks like the only reason for this reference is that ElementRef does custom serialization. Is that the case? Is this worth thinking harder about? (probably not) I can't imagine anyone will build a Blazor application without the interop layer.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's also to make the JSON utils available, as they are used in a few places in this project.

It might be that one day we can reference some other JSON utils from here and not reference Microsoft.JSInterop. For the time being I expect it's fine as-is.

* Invokes the specified .NET public method synchronously. Not all hosting scenarios support
* synchronous invocation, so if possible use invokeMethodAsync instead.
*
* @param assemblyName The name of the .NET assembly containing the method.
Copy link
Member

Choose a reason for hiding this comment

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

Do you think it's worth specifying in the docs that its the short assembly name (without key and version)? If you're coming from a .NET background you might assume that it's the full name

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point. Done.

}

function parseJsonWithRevivers(json: string): any {
return json ? JSON.parse(json, (key, initialValue) => {
Copy link
Member

Choose a reason for hiding this comment

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

Interesting... I wasn't aware this feature exists 👍

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, typescript language features are amazing... Each version it surprises me more! 👍

Copy link
Member

Choose a reason for hiding this comment

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

Well I this case I think it's browser APIs - but yes, Typescript is amazing. 😻

void *jsCallResult = (void *)EM_ASM_INT ({
char *argsJsonUtf8 = argsJson == NULL ? NULL : mono_string_to_utf8 (argsJson);

MonoString *resultJsonMonoString = (void *)EM_ASM_INT ({
Copy link
Member

Choose a reason for hiding this comment

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

So I am assuming that this block is 'inline assembly' in the form of JS.

S P I C Y

Copy link
Member Author

Choose a reason for hiding this comment

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

Timeout.Infinite);

return tcs.Task;
await Task.Delay(300);
Copy link
Member

Choose a reason for hiding this comment

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

You can also use Task.Yield() here if you want to avoid a slowing down the test significantly. That's the documented way to actually force asynchrony

Copy link
Member Author

Choose a reason for hiding this comment

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

Good tip - done.

@rynowak
Copy link
Member

rynowak commented Jun 23, 2018

Ok. So you really inflated the number of lines changed by moving mono.asm.js. T

hese are the kinds of shenanigans that put @pranavkm in first place in the MVC repo https://github.com/aspnet/Mvc/graphs/contributors - I don't approve #salty

DotNet.attachReviver((key, value) => {
if (value && typeof value === 'object' && value.hasOwnProperty(elementRefKey) && typeof value[elementRefKey] === 'number') {
return getElementByCaptureId(value[elementRefKey]);
Copy link
Member

Choose a reason for hiding this comment

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

Should this be looking at the key? here? I suppose it won't be a problem because the top-value object is always an array?

Copy link
Member Author

@SteveSandersonMS SteveSandersonMS Jun 25, 2018

Choose a reason for hiding this comment

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

If I understand your question correctly then no, it shouldn't be looking at key. If we receive the following:

{"someElem":{"_blazorElementRef":123}}

Then we'd receive a call to the reviver with key="someElem" and value={_blazorElementRef:123}. The key doesn't matter; we just need to check that the value looks like an ElementRef, and return an HTMLElement instance obtained by reading the _blazorElementRef property from value. Then the resulting deserialized object will look like:

{ someElem: AnHTMLElementInstance }

Copy link
Member

Choose a reason for hiding this comment

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

Ah ok, you are correct. Thanks for clarifying

/// <param name="methodIdentifier">The identifier of the method to be invoked. The method must be annotated with a <see cref="JSInvokableAttribute"/> matching this identifier string.</param>
/// <param name="argsJson">A JSON representation of the parameters.</param>
/// <returns>A JSON representation of the return value, or null.</returns>
public static string Invoke(string assemblyName, string methodIdentifier, string argsJson)
Copy link
Member

Choose a reason for hiding this comment

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

It might be worth a comment explaining why this and BeginInvoke don't need [JSInvokable]

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point. Added the following comment to both:

// This method doesn't need [JSInvokable] because the platform is responsible for having
// some way to dispatch calls here. The logic inside here is the thing that checks whether
// the targeted method has [JSInvokable]. It is not itself subject to that restriction,
// because there would be nobody to police that. This method *is* the police.

// Invoke and coerce the result to a Task so the caller can use the same async API
// for both synchronous and asynchronous methods
var task = syncResult is Task syncResultTask ? syncResultTask : Task.FromResult(syncResult);
task.ContinueWith(completedTask =>
Copy link
Member

Choose a reason for hiding this comment

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

Do you anticipate any problems if the continuation executes synchronously? I believe the existing callers of this method currently behave correctly

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think there will be any problem with that, because this method is void, so the caller can't be relying on receiving any synchronous output before the continuation is invoked.

Also since the other side of the interop boundary is getting the continuation as a Promise resolution, that already guarantees as per the Promise spec that resolutions will be delivered asynchronously. So in practice I don't think it will be possible to observe the callback arriving synchronously.

If anyone ever manages to create a scenario where the difference is observable, we can tweak this to force an async continuation.

catch (Exception ex)
{
ex = UnwrapException(ex);
jsRuntimeBaseInstance.EndInvokeDotNet(callId, false, ex.ToString());
Copy link
Member

Choose a reason for hiding this comment

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

Is there any use case here for passing the exception object? It seems little odd to me that this layer would decide how you want to treat exceptions.

Copy link
Member Author

Choose a reason for hiding this comment

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

That's a good point. Agreed and updated.

.Where(method => method.IsDefined(typeof(JSInvokableAttribute), inherit: false))
.ToDictionary(
method => method.GetCustomAttribute<JSInvokableAttribute>(false).Identifier,
method => (method, method.GetParameters().Select(p => p.ParameterType).ToArray())
Copy link
Member

Choose a reason for hiding this comment

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

There's a few other things you can do here that wouldn't be supported - like generics and parameter modifiers (out/ref). Is it worth trying to throw if you put JSInvokable on an invalid method?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not too worried about that, as it will already throw with a clear message when you try to actually invoke it (e.g., "not a closed generic type"). If we try to anticipate all the cases here there might be a lot to consider.

/// <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>
/// <returns>An instance of <typeparamref name="T"/> obtained by JSON-deserializing the return value.</returns>
T Invoke<T>(string identifier, params object[] args);
Copy link
Member

Choose a reason for hiding this comment

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

Would about void-returning methods? Is the expectation that you'd use object as the type?

Copy link
Member

Choose a reason for hiding this comment

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

or (System.Void)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, we have been using <object> for that (the compiler doesn't allow void or System.Void). I'm aware it's a bit clunky and we can add another overload for that if people turn out to want it.

/// <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)
{
// TODO: Auto-timeout
Copy link
Member

Choose a reason for hiding this comment

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

I'd suggest against this if you don't have a clear need in mind.

There are lots of cases where you don't want to have a timeout - "I want to be notified if X happens, but it might never happen".

If you want a timeout, then you want it to be cooperative - in which case you'd want to send it to the JS side as an actual argument - but I don't believe promises have a concept like cancellation - so again, you're building your own protocol.

If you want a timeout but you're not going to make it cooperative (client cancellation) then what you'd want to cancel is any continuations you have scheduled, which is pretty easy to do in .NET.

Copy link
Member Author

Choose a reason for hiding this comment

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

OK, I'll leave it for now, but will have a comment there that it might need to be reviewed.

My concern is that in the server-executed case, it's preferable not to be leaking memory if the JS-side code is just badly written and doesn't notify about completion (e.g., on errors, or if it's a void function and the developer hasn't thought about what they are doing). Plus, an individual user could deliberately change the JS-side code running in their browser so it doesn't notify completion even if the developer wrote their app correctly.

We might decide that a timeout is not the best solution even still. Maybe there should be a default per-JS-runtime limit on the number of pending tasks. This would limit it separately for each connected user.

/// <param name="argsJson">A JSON representation of the arguments.</param>
protected abstract void BeginInvokeJS(long asyncHandle, string identifier, string argsJson);

internal void EndInvokeDotNet(string callId, bool success, object resultOrExceptionMessage)
Copy link
Member

Choose a reason for hiding this comment

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

With these begin-end pattern things, it's possible to have a bug where begin/end are called on different runtime instances - (if anything changes the instance of the runtime during execution). I don't think it's likely that anyone will do that - but just in case.

Copy link
Member Author

@SteveSandersonMS SteveSandersonMS Jun 25, 2018

Choose a reason for hiding this comment

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

It is possible, that's true. But our work on sync context should make it easier to get this right than wrong in typical cases. In the event that a developer somehow still gets it wrong, it should be pretty detectable because there will be a lot of errors of the form "there is no pending task with ID x". If you have an idea for how we should improve this please let me know :)

// This is a single-file self-contained module to avoid the need for a Webpack build

module DotNet {
(window as any).DotNet = DotNet; // Ensure reachable from anywhere
Copy link
Member

Choose a reason for hiding this comment

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

Any reason why this isn't camelCase? just struck me as odd

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm thinking of DotNet as being like a static class. It's conventional for class names to be PascalCase in JS, hence static-method-like APIs such as Promise.race, WebAssembly.compile, etc.

{
if (!_pendingTasks.TryRemove(asyncHandle, out var tcs))
{
throw new ArgumentException($"There is no pending task with handle '{asyncHandle}'.");
Copy link
Member

Choose a reason for hiding this comment

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

I imagine that this exception would only been seen by something like our interop transports - or in the case of mono it can bubble back to JS. We'll have to make sure to handle arbitrary exceptions in those cases unless we want the app to crash.

Copy link
Member Author

Choose a reason for hiding this comment

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

It should be hard to trigger this, but if you somehow do, it implies something is pretty badly wrong.

In client-side execution, a global unhandled exception is a reasonable outcome. For server-side execution, the code that invokes DotNetDispatcher.Invoke/BeginInvoke should try/catch it and do something sensible with unhandled exceptions.

Copy link
Member

Choose a reason for hiding this comment

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

should try/catch it and do something sensible with unhandled exceptions.

I'm interested if you have more thoughts, but the only sensible thing I can think of would be to log, or optionally destroy the circuit (seems bad). We don't have the context of a user-operation when this happens because it happens in on resuming async.

@rynowak
Copy link
Member

rynowak commented Jun 23, 2018

So I'm imagining that for server and B-lectron we'll have a 'channel' that does remote IO and is responsible for calling the same methods that are called by monoplatform.js. That's the location where we'll do things like call in the appropriate sync context and ensure that the runtime is initialized in the call-context. It seems simple enough.

Let me know if you had a different idea.

/// <summary>
/// Represents an instance of a JavaScript runtime to which calls may be dispatched.
/// </summary>
public interface IJSRuntime
Copy link
Member

Choose a reason for hiding this comment

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

Should we expose any sort of features/capabilities here? Did you plan to add that through more interfaces?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, the interfaces mechanism was working fine to distinguish cases where sync or unmarshalled interop is supported. Maybe that will be enough. If not we can figure out what extra capability advertisement would be appropriate when we have scenarios for it.

Copy link
Member

@rynowak rynowak left a comment

Choose a reason for hiding this comment

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

I think this looks great, I don't see any reason why we shouldn't move forward with it immediately - most of the big stuff is here 👍

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Jun 25, 2018

So I'm imagining that for server and B-lectron we'll have a 'channel' that does remote IO and is responsible for calling the same methods that are called by monoplatform.js

Yes, exactly! And on the .NET side it will call the same methods on DotNetDispatcher. It should be relatively simple as the transport doesn't have to deal with asynchrony or serializing. It just has to be able to deliver calls to "methods that take strings" with in-order delivery.

@SteveSandersonMS SteveSandersonMS merged commit e1bbf08 into dev Jun 25, 2018
@SteveSandersonMS SteveSandersonMS deleted the stevesa/js-interop-async branch June 25, 2018 14:14
SteveSandersonMS added a commit to SteveSandersonMS/BlazorMigration that referenced this pull request Nov 27, 2018
* JavaScript interop via new IJSRuntime abstraction

* CR feedback
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants