-
Notifications
You must be signed in to change notification settings - Fork 10.4k
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
Support async main #17673
Changes from all commits
5cfaa09
6f4f69a
d606b5f
f34e287
f1ff236
2738411
a0cc4c3
293d547
8ded549
b8e0cb2
17e9790
f8b30c8
3522a06
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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". | ||
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); | ||
} | ||
} | ||
} |
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 |
---|---|---|
|
@@ -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'; | ||
|
@@ -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 { | ||
|
@@ -263,11 +271,9 @@ function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: () | |
'number', | ||
]); | ||
|
||
mono_call_assembly_entry_point = Module.mono_call_assembly_entry_point; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can image instead that There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
I don't think there's any problem on the JS side with needing to know the type in advance. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 MonoReflectionAssembly* mono_assembly_get_object (MonoDomain *domain, MonoAssembly *assembly); There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = []; | ||
|
||
|
There was a problem hiding this comment.
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 aTask.GetAwaiter.GetResult()
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Precisely