Skip to content

Commit 3a93704

Browse files
Support async main (#17673)
1 parent 3fba107 commit 3a93704

File tree

16 files changed

+381
-25
lines changed

16 files changed

+381
-25
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Reflection;
7+
using System.Threading.Tasks;
8+
9+
namespace Microsoft.AspNetCore.Blazor.Hosting
10+
{
11+
internal static class EntrypointInvoker
12+
{
13+
// This method returns void because currently the JS side is not listening to any result,
14+
// nor will it handle any exceptions. We handle all exceptions internally to this method.
15+
// In the future we may want Blazor.start to return something that exposes the possibly-async
16+
// entrypoint result to the JS caller. There's no requirement to do that today, and if we
17+
// do change this it will be non-breaking.
18+
public static void InvokeEntrypoint(string assemblyName, string[] args)
19+
{
20+
object entrypointResult;
21+
try
22+
{
23+
var assembly = Assembly.Load(assemblyName);
24+
var entrypoint = FindUnderlyingEntrypoint(assembly);
25+
var @params = entrypoint.GetParameters().Length == 1 ? new object[] { args } : new object[] { };
26+
entrypointResult = entrypoint.Invoke(null, @params);
27+
}
28+
catch (Exception syncException)
29+
{
30+
HandleStartupException(syncException);
31+
return;
32+
}
33+
34+
// If the entrypoint is async, handle async exceptions in the same way that we would
35+
// have handled sync ones
36+
if (entrypointResult is Task entrypointTask)
37+
{
38+
entrypointTask.ContinueWith(task =>
39+
{
40+
if (task.Exception != null)
41+
{
42+
HandleStartupException(task.Exception);
43+
}
44+
});
45+
}
46+
}
47+
48+
private static MethodBase FindUnderlyingEntrypoint(Assembly assembly)
49+
{
50+
// This is the entrypoint declared in .NET metadata. In the case of async main, it's the
51+
// compiler-generated wrapper method. Otherwise it's the developer-defined method.
52+
var metadataEntrypointMethodBase = assembly.EntryPoint;
53+
54+
// For "async Task Main", the C# compiler generates a method called "<Main>"
55+
// that is marked as the assembly entrypoint. Detect this case, and instead of
56+
// calling "<Whatever>", call the sibling "Whatever".
57+
if (metadataEntrypointMethodBase.IsSpecialName)
58+
{
59+
var origName = metadataEntrypointMethodBase.Name;
60+
var origNameLength = origName.Length;
61+
if (origNameLength > 2)
62+
{
63+
var candidateMethodName = origName.Substring(1, origNameLength - 2);
64+
var candidateMethod = metadataEntrypointMethodBase.DeclaringType.GetMethod(
65+
candidateMethodName,
66+
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic,
67+
null,
68+
metadataEntrypointMethodBase.GetParameters().Select(p => p.ParameterType).ToArray(),
69+
null);
70+
71+
if (candidateMethod != null)
72+
{
73+
return candidateMethod;
74+
}
75+
}
76+
}
77+
78+
// Either it's not async main, or for some reason we couldn't locate the underlying entrypoint,
79+
// so use the one from assembly metadata.
80+
return metadataEntrypointMethodBase;
81+
}
82+
83+
private static void HandleStartupException(Exception exception)
84+
{
85+
// Logs to console, and causes the error UI to appear
86+
Console.Error.WriteLine(exception);
87+
}
88+
}
89+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.IO;
6+
using System.Reflection;
7+
using System.Runtime.Loader;
8+
using System.Threading.Tasks;
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.CSharp;
11+
using Xunit;
12+
13+
namespace Microsoft.AspNetCore.Blazor.Hosting
14+
{
15+
public class EntrypointInvokerTest
16+
{
17+
[Theory]
18+
[InlineData(false, false)]
19+
[InlineData(false, true)]
20+
[InlineData(true, false)]
21+
[InlineData(true, true)]
22+
public void InvokesEntrypoint_Sync_Success(bool hasReturnValue, bool hasParams)
23+
{
24+
// Arrange
25+
var returnType = hasReturnValue ? "int" : "void";
26+
var paramsDecl = hasParams ? "string[] args" : string.Empty;
27+
var returnStatement = hasReturnValue ? "return 123;" : "return;";
28+
var assembly = CompileToAssembly(@"
29+
static " + returnType + @" Main(" + paramsDecl + @")
30+
{
31+
DidMainExecute = true;
32+
" + returnStatement + @"
33+
}", out var didMainExecute);
34+
35+
// Act
36+
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
37+
38+
// Assert
39+
Assert.True(didMainExecute());
40+
}
41+
42+
[Theory]
43+
[InlineData(false, false)]
44+
[InlineData(false, true)]
45+
[InlineData(true, false)]
46+
[InlineData(true, true)]
47+
public void InvokesEntrypoint_Async_Success(bool hasReturnValue, bool hasParams)
48+
{
49+
// Arrange
50+
var returnTypeGenericParam = hasReturnValue ? "<int>" : string.Empty;
51+
var paramsDecl = hasParams ? "string[] args" : string.Empty;
52+
var returnStatement = hasReturnValue ? "return 123;" : "return;";
53+
var assembly = CompileToAssembly(@"
54+
public static TaskCompletionSource<object> ContinueTcs { get; } = new TaskCompletionSource<object>();
55+
56+
static async Task" + returnTypeGenericParam + @" Main(" + paramsDecl + @")
57+
{
58+
await ContinueTcs.Task;
59+
DidMainExecute = true;
60+
" + returnStatement + @"
61+
}", out var didMainExecute);
62+
63+
// Act/Assert 1: Waits for task
64+
// The fact that we're not blocking here proves that we're not executing the
65+
// metadata-declared entrypoint, as that would block
66+
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
67+
Assert.False(didMainExecute());
68+
69+
// Act/Assert 2: Continues
70+
var tcs = (TaskCompletionSource<object>)assembly.GetType("SomeApp.Program").GetProperty("ContinueTcs").GetValue(null);
71+
tcs.SetResult(null);
72+
Assert.True(didMainExecute());
73+
}
74+
75+
[Fact]
76+
public void InvokesEntrypoint_Sync_Exception()
77+
{
78+
// Arrange
79+
var assembly = CompileToAssembly(@"
80+
public static void Main()
81+
{
82+
DidMainExecute = true;
83+
throw new InvalidTimeZoneException(""Test message"");
84+
}", out var didMainExecute);
85+
86+
// Act/Assert
87+
// The fact that this doesn't throw shows that EntrypointInvoker is doing something
88+
// to handle the exception. We can't assert about what it does here, because that
89+
// would involve capturing console output, which isn't safe in unit tests. Instead
90+
// we'll check this in E2E tests.
91+
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
92+
Assert.True(didMainExecute());
93+
}
94+
95+
[Fact]
96+
public void InvokesEntrypoint_Async_Exception()
97+
{
98+
// Arrange
99+
var assembly = CompileToAssembly(@"
100+
public static TaskCompletionSource<object> ContinueTcs { get; } = new TaskCompletionSource<object>();
101+
102+
public static async Task Main()
103+
{
104+
await ContinueTcs.Task;
105+
DidMainExecute = true;
106+
throw new InvalidTimeZoneException(""Test message"");
107+
}", out var didMainExecute);
108+
109+
// Act/Assert 1: Waits for task
110+
EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
111+
Assert.False(didMainExecute());
112+
113+
// Act/Assert 2: Continues
114+
// As above, we can't directly observe the exception handling behavior here,
115+
// so this is covered in E2E tests instead.
116+
var tcs = (TaskCompletionSource<object>)assembly.GetType("SomeApp.Program").GetProperty("ContinueTcs").GetValue(null);
117+
tcs.SetResult(null);
118+
Assert.True(didMainExecute());
119+
}
120+
121+
private static Assembly CompileToAssembly(string mainMethod, out Func<bool> didMainExecute)
122+
{
123+
var syntaxTree = CSharpSyntaxTree.ParseText(@"
124+
using System;
125+
using System.Threading.Tasks;
126+
127+
namespace SomeApp
128+
{
129+
public static class Program
130+
{
131+
public static bool DidMainExecute { get; private set; }
132+
133+
" + mainMethod + @"
134+
}
135+
}");
136+
137+
var compilation = CSharpCompilation.Create(
138+
$"TestAssembly-{Guid.NewGuid().ToString("D")}",
139+
new[] { syntaxTree },
140+
new[] { MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location) },
141+
new CSharpCompilationOptions(OutputKind.ConsoleApplication));
142+
using var ms = new MemoryStream();
143+
var compilationResult = compilation.Emit(ms);
144+
ms.Seek(0, SeekOrigin.Begin);
145+
var assembly = AssemblyLoadContext.Default.LoadFromStream(ms);
146+
147+
var didMainExecuteProp = assembly.GetType("SomeApp.Program").GetProperty("DidMainExecute");
148+
didMainExecute = () => (bool)didMainExecuteProp.GetValue(null);
149+
150+
return assembly;
151+
}
152+
}
153+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
55
</PropertyGroup>
66

77
<ItemGroup>
88
<Reference Include="Microsoft.AspNetCore.Blazor" />
9+
<Reference Include="Microsoft.CodeAnalysis.CSharp" />
910
</ItemGroup>
1011

1112
</Project>

src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!DOCTYPE html>
1+
<!DOCTYPE html>
22
<html>
33
<head>
44
<meta charset="utf-8" />
@@ -11,6 +11,12 @@
1111
<body>
1212
<app>Loading...</app>
1313

14+
<div id="blazor-error-ui">
15+
An unhandled exception has occurred. See browser dev tools for details.
16+
<a href="" class="reload">Reload</a>
17+
<a class="dismiss">🗙</a>
18+
</div>
19+
1420
<script src="_framework/blazor.webassembly.js"></script>
1521
</body>
1622
</html>

src/Components/Components.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mono.WebAssembly.Interop",
248248
EndProject
249249
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mono.WebAssembly.Interop", "Blazor\Mono.WebAssembly.Interop\src\Mono.WebAssembly.Interop.csproj", "{D141CFEE-D10A-406B-8963-F86FA13732E3}"
250250
EndProject
251+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ComponentsApp.Server", "test\testassets\ComponentsApp.Server\ComponentsApp.Server.csproj", "{F2E27E1C-2E47-42C1-9AC7-36265A381717}"
252+
EndProject
251253
Global
252254
GlobalSection(SolutionConfigurationPlatforms) = preSolution
253255
Debug|Any CPU = Debug|Any CPU
@@ -1518,6 +1520,18 @@ Global
15181520
{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x64.Build.0 = Release|Any CPU
15191521
{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x86.ActiveCfg = Release|Any CPU
15201522
{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x86.Build.0 = Release|Any CPU
1523+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1524+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|Any CPU.Build.0 = Debug|Any CPU
1525+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x64.ActiveCfg = Debug|Any CPU
1526+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x64.Build.0 = Debug|Any CPU
1527+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x86.ActiveCfg = Debug|Any CPU
1528+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x86.Build.0 = Debug|Any CPU
1529+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|Any CPU.ActiveCfg = Release|Any CPU
1530+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|Any CPU.Build.0 = Release|Any CPU
1531+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x64.ActiveCfg = Release|Any CPU
1532+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x64.Build.0 = Release|Any CPU
1533+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x86.ActiveCfg = Release|Any CPU
1534+
{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x86.Build.0 = Release|Any CPU
15211535
EndGlobalSection
15221536
GlobalSection(SolutionProperties) = preSolution
15231537
HideSolutionNode = FALSE
@@ -1632,6 +1646,7 @@ Global
16321646
{A5617A9D-C71E-44DE-936C-27611EB40A02} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
16331647
{21BB9C13-20C1-4F2B-80E4-D7C64AA3BD05} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
16341648
{D141CFEE-D10A-406B-8963-F86FA13732E3} = {21BB9C13-20C1-4F2B-80E4-D7C64AA3BD05}
1649+
{F2E27E1C-2E47-42C1-9AC7-36265A381717} = {44E0D4F3-4430-4175-B482-0D1AEE4BB699}
16351650
EndGlobalSection
16361651
GlobalSection(ExtensibilityGlobals) = postSolution
16371652
SolutionGuid = {CC3C47E1-AD1A-4619-9CD3-E08A0148E5CE}

src/Components/Web.JS/src/Boot.WebAssembly.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,7 @@ interface BootJsonData {
7373

7474
window['Blazor'].start = boot;
7575
if (shouldAutoStart()) {
76-
boot();
76+
boot().catch(error => {
77+
Module.printErr(error); // Logs it, and causes the error UI to appear
78+
});
7779
}

src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ let assembly_load: (assemblyName: string) => number;
1111
let find_class: (assemblyHandle: number, namespace: string, className: string) => number;
1212
let find_method: (typeHandle: number, methodName: string, unknownArg: number) => MethodHandle;
1313
let invoke_method: (method: MethodHandle, target: System_Object, argsArrayPtr: number, exceptionFlagIntPtr: number) => System_Object;
14-
let mono_call_assembly_entry_point: (assemblyName: string, args: System_Object[]) => System_Object;
15-
let mono_obj_array_new: (length: number) => System_Object;
14+
let mono_string_array_new: (length: number) => System_Array<System_String>;
1615
let mono_string_get_utf8: (managedString: System_String) => Mono.Utf8Ptr;
1716
let mono_string: (jsString: string) => System_String;
1817
const appBinDirName = 'appBinDir';
@@ -41,9 +40,18 @@ export const monoPlatform: Platform = {
4140

4241
findMethod: findMethod,
4342

44-
callEntryPoint: function callEntryPoint(assemblyName: string): System_Object {
45-
const empty_array = mono_obj_array_new(0);
46-
return mono_call_assembly_entry_point(assemblyName, [empty_array]);
43+
callEntryPoint: function callEntryPoint(assemblyName: string) {
44+
// Instead of using Module.mono_call_assembly_entry_point, we have our own logic for invoking
45+
// the entrypoint which adds support for async main.
46+
// Currently we disregard the return value from the entrypoint, whether it's sync or async.
47+
// In the future, we might want Blazor.start to return a Promise<Promise<value>>, where the
48+
// outer promise reflects the startup process, and the inner one reflects the possibly-async
49+
// .NET entrypoint method.
50+
const invokeEntrypoint = findMethod('Microsoft.AspNetCore.Blazor', 'Microsoft.AspNetCore.Blazor.Hosting', 'EntrypointInvoker', 'InvokeEntrypoint');
51+
this.callMethod(invokeEntrypoint, null, [
52+
this.toDotNetString(assemblyName),
53+
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[].
54+
]);
4755
},
4856

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

266-
mono_call_assembly_entry_point = Module.mono_call_assembly_entry_point;
267-
268274
mono_string_get_utf8 = Module.cwrap('mono_wasm_string_get_utf8', 'number', ['number']);
269275
mono_string = Module.cwrap('mono_wasm_string_from_js', 'number', ['string']);
270-
mono_obj_array_new = Module.cwrap ('mono_wasm_obj_array_new', 'number', ['number']);
276+
mono_string_array_new = Module.cwrap('mono_wasm_string_array_new', 'number', ['number']);
271277

272278
MONO.loaded_files = [];
273279

src/Components/Web.JS/src/Platform/Platform.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export interface Platform {
22
start(loadAssemblyUrls: string[]): Promise<void>;
33

4-
callEntryPoint(assemblyName: string): System_Object;
4+
callEntryPoint(assemblyName: string): void;
55
findMethod(assemblyName: string, namespace: string, className: string, methodName: string): MethodHandle;
66
callMethod(method: MethodHandle, target: System_Object | null, args: (System_Object | null)[]): System_Object;
77

src/Components/test/E2ETest/Tests/ErrorNotificationServerSideTest.cs renamed to src/Components/test/E2ETest/ServerExecutionTests/ServerErrorNotificationTest.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,15 @@
55
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
66
using Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests;
77
using Microsoft.AspNetCore.E2ETesting;
8-
using OpenQA.Selenium;
98
using Xunit;
109
using Xunit.Abstractions;
1110

1211
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
1312
{
1413
[Collection("ErrorNotification")] // When the clientside and serverside tests run together it seems to cause failures, possibly due to connection lose on exception.
15-
public class ErrorNotificationServerSideTest : ErrorNotificationClientSideTest
14+
public class ServerErrorNotificationTest : ErrorNotificationTest
1615
{
17-
public ErrorNotificationServerSideTest(
16+
public ServerErrorNotificationTest(
1817
BrowserFixture browserFixture,
1918
ToggleExecutionModeServerFixture<Program> serverFixture,
2019
ITestOutputHelper output)

0 commit comments

Comments
 (0)