Skip to content

Support async main #17673

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 13 commits into from
Dec 12, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
89 changes: 89 additions & 0 deletions src/Components/Blazor/Blazor/src/Hosting/EntrypointInvoker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 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.Linq;
using System.Reflection;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Blazor.Hosting
{
internal static class EntrypointInvoker
{
// This method returns void because currently the JS side is not listening to any result,
// nor will it handle any exceptions. We handle all exceptions internally to this method.
// In the future we may want Blazor.start to return something that exposes the possibly-async
// entrypoint result to the JS caller. There's no requirement to do that today, and if we
// do change this it will be non-breaking.
public static void InvokeEntrypoint(string assemblyName, string[] args)
{
object entrypointResult;
try
{
var assembly = Assembly.Load(assemblyName);
var entrypoint = FindUnderlyingEntrypoint(assembly);
var @params = entrypoint.GetParameters().Length == 1 ? new object[] { args } : new object[] { };
entrypointResult = entrypoint.Invoke(null, @params);
}
catch (Exception syncException)
{
HandleStartupException(syncException);
return;
}

// If the entrypoint is async, handle async exceptions in the same way that we would
// have handled sync ones
if (entrypointResult is Task entrypointTask)
{
entrypointTask.ContinueWith(task =>
{
if (task.Exception != null)
{
HandleStartupException(task.Exception);
}
});
}
}

private static MethodBase FindUnderlyingEntrypoint(Assembly assembly)
{
// This is the entrypoint declared in .NET metadata. In the case of async main, it's the
// compiler-generated wrapper method. Otherwise it's the developer-defined method.
var metadataEntrypointMethodBase = assembly.EntryPoint;

// For "async Task Main", the C# compiler generates a method called "<Main>"
// that is marked as the assembly entrypoint. Detect this case, and instead of
// calling "<Whatever>", call the sibling "Whatever".
Copy link
Member

Choose a reason for hiding this comment

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

What's the reason why we need to call Main instead of <Main> ? Is this because <Main> (generated wrapper) contains a Task.GetAwaiter.GetResult()?

Copy link
Member Author

Choose a reason for hiding this comment

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

Precisely

if (metadataEntrypointMethodBase.IsSpecialName)
{
var origName = metadataEntrypointMethodBase.Name;
var origNameLength = origName.Length;
if (origNameLength > 2)
{
var candidateMethodName = origName.Substring(1, origNameLength - 2);
var candidateMethod = metadataEntrypointMethodBase.DeclaringType.GetMethod(
candidateMethodName,
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic,
null,
metadataEntrypointMethodBase.GetParameters().Select(p => p.ParameterType).ToArray(),
null);

if (candidateMethod != null)
{
return candidateMethod;
}
}
}

// Either it's not async main, or for some reason we couldn't locate the underlying entrypoint,
// so use the one from assembly metadata.
return metadataEntrypointMethodBase;
}

private static void HandleStartupException(Exception exception)
{
// Logs to console, and causes the error UI to appear
Console.Error.WriteLine(exception);
}
}
}
153 changes: 153 additions & 0 deletions src/Components/Blazor/Blazor/test/Hosting/EntrypointInvokerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// 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.IO;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Xunit;

namespace Microsoft.AspNetCore.Blazor.Hosting
{
public class EntrypointInvokerTest
{
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void InvokesEntrypoint_Sync_Success(bool hasReturnValue, bool hasParams)
{
// Arrange
var returnType = hasReturnValue ? "int" : "void";
var paramsDecl = hasParams ? "string[] args" : string.Empty;
var returnStatement = hasReturnValue ? "return 123;" : "return;";
var assembly = CompileToAssembly(@"
static " + returnType + @" Main(" + paramsDecl + @")
{
DidMainExecute = true;
" + returnStatement + @"
}", out var didMainExecute);

// Act
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });

// Assert
Assert.True(didMainExecute());
}

[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void InvokesEntrypoint_Async_Success(bool hasReturnValue, bool hasParams)
{
// Arrange
var returnTypeGenericParam = hasReturnValue ? "<int>" : string.Empty;
var paramsDecl = hasParams ? "string[] args" : string.Empty;
var returnStatement = hasReturnValue ? "return 123;" : "return;";
var assembly = CompileToAssembly(@"
public static TaskCompletionSource<object> ContinueTcs { get; } = new TaskCompletionSource<object>();

static async Task" + returnTypeGenericParam + @" Main(" + paramsDecl + @")
{
await ContinueTcs.Task;
DidMainExecute = true;
" + returnStatement + @"
}", out var didMainExecute);

// Act/Assert 1: Waits for task
// The fact that we're not blocking here proves that we're not executing the
// metadata-declared entrypoint, as that would block
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
Assert.False(didMainExecute());

// Act/Assert 2: Continues
var tcs = (TaskCompletionSource<object>)assembly.GetType("SomeApp.Program").GetProperty("ContinueTcs").GetValue(null);
tcs.SetResult(null);
Assert.True(didMainExecute());
}

[Fact]
public void InvokesEntrypoint_Sync_Exception()
{
// Arrange
var assembly = CompileToAssembly(@"
public static void Main()
{
DidMainExecute = true;
throw new InvalidTimeZoneException(""Test message"");
}", out var didMainExecute);

// Act/Assert
// The fact that this doesn't throw shows that EntrypointInvoker is doing something
// to handle the exception. We can't assert about what it does here, because that
// would involve capturing console output, which isn't safe in unit tests. Instead
// we'll check this in E2E tests.
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
Assert.True(didMainExecute());
}

[Fact]
public void InvokesEntrypoint_Async_Exception()
{
// Arrange
var assembly = CompileToAssembly(@"
public static TaskCompletionSource<object> ContinueTcs { get; } = new TaskCompletionSource<object>();

public static async Task Main()
{
await ContinueTcs.Task;
DidMainExecute = true;
throw new InvalidTimeZoneException(""Test message"");
}", out var didMainExecute);

// Act/Assert 1: Waits for task
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
Assert.False(didMainExecute());

// Act/Assert 2: Continues
// As above, we can't directly observe the exception handling behavior here,
// so this is covered in E2E tests instead.
var tcs = (TaskCompletionSource<object>)assembly.GetType("SomeApp.Program").GetProperty("ContinueTcs").GetValue(null);
tcs.SetResult(null);
Assert.True(didMainExecute());
}

private static Assembly CompileToAssembly(string mainMethod, out Func<bool> didMainExecute)
{
var syntaxTree = CSharpSyntaxTree.ParseText(@"
using System;
using System.Threading.Tasks;

namespace SomeApp
{
public static class Program
{
public static bool DidMainExecute { get; private set; }

" + mainMethod + @"
}
}");

var compilation = CSharpCompilation.Create(
$"TestAssembly-{Guid.NewGuid().ToString("D")}",
new[] { syntaxTree },
new[] { MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location) },
new CSharpCompilationOptions(OutputKind.ConsoleApplication));
using var ms = new MemoryStream();
var compilationResult = compilation.Emit(ms);
ms.Seek(0, SeekOrigin.Begin);
var assembly = AssemblyLoadContext.Default.LoadFromStream(ms);

var didMainExecuteProp = assembly.GetType("SomeApp.Program").GetProperty("DidMainExecute");
didMainExecute = () => (bool)didMainExecuteProp.GetValue(null);

return assembly;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Blazor" />
<Reference Include="Microsoft.CodeAnalysis.CSharp" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
Expand All @@ -11,6 +11,12 @@
<body>
<app>Loading...</app>

<div id="blazor-error-ui">
An unhandled exception has occurred. See browser dev tools for details.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>

<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
15 changes: 15 additions & 0 deletions src/Components/Components.sln
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mono.WebAssembly.Interop",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mono.WebAssembly.Interop", "Blazor\Mono.WebAssembly.Interop\src\Mono.WebAssembly.Interop.csproj", "{D141CFEE-D10A-406B-8963-F86FA13732E3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ComponentsApp.Server", "test\testassets\ComponentsApp.Server\ComponentsApp.Server.csproj", "{F2E27E1C-2E47-42C1-9AC7-36265A381717}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -1518,6 +1520,18 @@ Global
{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x64.Build.0 = Release|Any CPU
{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x86.ActiveCfg = Release|Any CPU
{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x86.Build.0 = Release|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x64.ActiveCfg = Debug|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x64.Build.0 = Debug|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x86.ActiveCfg = Debug|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x86.Build.0 = Debug|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|Any CPU.Build.0 = Release|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x64.ActiveCfg = Release|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x64.Build.0 = Release|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x86.ActiveCfg = Release|Any CPU
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1632,6 +1646,7 @@ Global
{A5617A9D-C71E-44DE-936C-27611EB40A02} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
{21BB9C13-20C1-4F2B-80E4-D7C64AA3BD05} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
{D141CFEE-D10A-406B-8963-F86FA13732E3} = {21BB9C13-20C1-4F2B-80E4-D7C64AA3BD05}
{F2E27E1C-2E47-42C1-9AC7-36265A381717} = {44E0D4F3-4430-4175-B482-0D1AEE4BB699}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {CC3C47E1-AD1A-4619-9CD3-E08A0148E5CE}
Expand Down
4 changes: 3 additions & 1 deletion src/Components/Web.JS/src/Boot.WebAssembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,7 @@ interface BootJsonData {

window['Blazor'].start = boot;
if (shouldAutoStart()) {
boot();
boot().catch(error => {
Module.printErr(error); // Logs it, and causes the error UI to appear
});
}
22 changes: 14 additions & 8 deletions src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ let assembly_load: (assemblyName: string) => number;
let find_class: (assemblyHandle: number, namespace: string, className: string) => number;
let find_method: (typeHandle: number, methodName: string, unknownArg: number) => MethodHandle;
let invoke_method: (method: MethodHandle, target: System_Object, argsArrayPtr: number, exceptionFlagIntPtr: number) => System_Object;
let mono_call_assembly_entry_point: (assemblyName: string, args: System_Object[]) => System_Object;
let mono_obj_array_new: (length: number) => System_Object;
let mono_string_array_new: (length: number) => System_Array<System_String>;
let mono_string_get_utf8: (managedString: System_String) => Mono.Utf8Ptr;
let mono_string: (jsString: string) => System_String;
const appBinDirName = 'appBinDir';
Expand Down Expand Up @@ -41,9 +40,18 @@ export const monoPlatform: Platform = {

findMethod: findMethod,

callEntryPoint: function callEntryPoint(assemblyName: string): System_Object {
const empty_array = mono_obj_array_new(0);
return mono_call_assembly_entry_point(assemblyName, [empty_array]);
callEntryPoint: function callEntryPoint(assemblyName: string) {
// Instead of using Module.mono_call_assembly_entry_point, we have our own logic for invoking
// the entrypoint which adds support for async main.
// Currently we disregard the return value from the entrypoint, whether it's sync or async.
// In the future, we might want Blazor.start to return a Promise<Promise<value>>, where the
// outer promise reflects the startup process, and the inner one reflects the possibly-async
// .NET entrypoint method.
const invokeEntrypoint = findMethod('Microsoft.AspNetCore.Blazor', 'Microsoft.AspNetCore.Blazor.Hosting', 'EntrypointInvoker', 'InvokeEntrypoint');
this.callMethod(invokeEntrypoint, null, [
this.toDotNetString(assemblyName),
mono_string_array_new(0) // In the future, we may have a way of supplying arg strings. For now, we always supply an empty string[].
]);
},

callMethod: function callMethod(method: MethodHandle, target: System_Object, args: System_Object[]): System_Object {
Expand Down Expand Up @@ -263,11 +271,9 @@ function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: ()
'number',
]);

mono_call_assembly_entry_point = Module.mono_call_assembly_entry_point;
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 image instead that Module.mono_call_assembly_entry_point would just return a value, which could a Task, int, or Task<int>. That seems problematic because from the JS side we don't know the value's type - which leads back to us wanting to call the entry point ourselves.

Copy link
Member Author

@SteveSandersonMS SteveSandersonMS Dec 9, 2019

Choose a reason for hiding this comment

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

If Mono adds native support for async main, I think they should create a new entrypoint API called something like mono_call_assembly_entry_point_async, which returns a Promise that:

  • resolves with either null/undefined in the case of a void or Task main
  • or resolves with a number for a main of type intor Task<int>
  • or rejects with some representation of an exception for either sync or async failures.

I don't think there's any problem on the JS side with needing to know the type in advance.

Copy link

@vargaz vargaz Dec 9, 2019

Choose a reason for hiding this comment

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

Its possible to do this more reliably by calling the 'mono_assembly_get_object' mono API function which returns an
Assembly object, then calling its EntryPoint property from c#. mono_assembly_get_object () is not currently exposed to js, but we can add it if needed.

MonoReflectionAssembly* mono_assembly_get_object (MonoDomain *domain, MonoAssembly *assembly);

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks @vargaz - that's a great suggestion!

I realised we don't need mono_assembly_get_object either, as we can use Assembly.Load(string) on the .NET side to get the same result. If you think mono_assembly_get_object would be faster for any reason, then yes it would be great to have that exposed to JS. Would it be correct to expect that it wouldn't really be significantly faster, since Assembly.Load(string) is returning the Assembly instance already cached in memory anyway (since we already loaded it inside the bootup js)?

Copy link

Choose a reason for hiding this comment

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

mono_assembly_get_object () would be a bit faster. Will look into adding it.


mono_string_get_utf8 = Module.cwrap('mono_wasm_string_get_utf8', 'number', ['number']);
mono_string = Module.cwrap('mono_wasm_string_from_js', 'number', ['string']);
mono_obj_array_new = Module.cwrap ('mono_wasm_obj_array_new', 'number', ['number']);
mono_string_array_new = Module.cwrap('mono_wasm_string_array_new', 'number', ['number']);

MONO.loaded_files = [];

Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web.JS/src/Platform/Platform.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export interface Platform {
start(loadAssemblyUrls: string[]): Promise<void>;

callEntryPoint(assemblyName: string): System_Object;
callEntryPoint(assemblyName: string): void;
findMethod(assemblyName: string, namespace: string, className: string, methodName: string): MethodHandle;
callMethod(method: MethodHandle, target: System_Object | null, args: (System_Object | null)[]): System_Object;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
[Collection("ErrorNotification")] // When the clientside and serverside tests run together it seems to cause failures, possibly due to connection lose on exception.
public class ErrorNotificationServerSideTest : ErrorNotificationClientSideTest
public class ServerErrorNotificationTest : ErrorNotificationTest
{
public ErrorNotificationServerSideTest(
public ServerErrorNotificationTest(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> serverFixture,
ITestOutputHelper output)
Expand Down
Loading