From ee415b34849936016bf57b97f0dec5cd15a6c44f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Sat, 12 Jun 2021 20:30:59 -0700 Subject: [PATCH 1/4] Handle more cases with the new entry point pattern - Handle an exception being thrown from main before start is called and make sure it propagates to the WebApplicationFactory. - Don't hang if the application doesn't call Start before it completes. --- AspNetCore.sln | 15 +++++++++ .../Mvc.Testing/src/DeferredHostBuilder.cs | 33 ++++++++++++++----- .../Mvc.Testing/src/WebApplicationFactory.cs | 6 +++- ...soft.AspNetCore.Mvc.FunctionalTests.csproj | 1 + ...WithWebApplicationBuilderExceptionTests.cs | 28 ++++++++++++++++ .../FakeEntryPoint.cs | 14 ++++++++ .../Program.cs | 11 +++++++ .../Properties/launchSettings.json | 27 +++++++++++++++ ...eWithWebApplicationBuilderException.csproj | 10 ++++++ .../readme.md | 4 +++ 10 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderExceptionTests.cs create mode 100644 src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/FakeEntryPoint.cs create mode 100644 src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/Program.cs create mode 100644 src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/Properties/launchSettings.json create mode 100644 src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj create mode 100644 src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/readme.md diff --git a/AspNetCore.sln b/AspNetCore.sln index 6d8858e42d93..3caea6eb3817 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1624,6 +1624,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhotinoTestApp", "src\Compo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Components.WebView.Photino", "src\Components\WebView\Samples\PhotinoPlatform\src\Microsoft.AspNetCore.Components.WebView.Photino.csproj", "{B1AA24A4-5E02-4DC1-B57F-6EB03F91E4DD}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SimpleWebSiteWithWebApplicationBuilderException", "src\Mvc\test\WebSites\SimpleWebSiteWithWebApplicationBuilderException\SimpleWebSiteWithWebApplicationBuilderException.csproj", "{5C641396-7E92-4F5C-A5A1-B4CDF480539B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -7731,6 +7733,18 @@ Global {B1AA24A4-5E02-4DC1-B57F-6EB03F91E4DD}.Release|x64.Build.0 = Release|Any CPU {B1AA24A4-5E02-4DC1-B57F-6EB03F91E4DD}.Release|x86.ActiveCfg = Release|Any CPU {B1AA24A4-5E02-4DC1-B57F-6EB03F91E4DD}.Release|x86.Build.0 = Release|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Debug|x64.Build.0 = Debug|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Debug|x86.Build.0 = Debug|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|Any CPU.Build.0 = Release|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|x64.ActiveCfg = Release|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|x64.Build.0 = Release|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|x86.ActiveCfg = Release|Any CPU + {5C641396-7E92-4F5C-A5A1-B4CDF480539B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -8535,6 +8549,7 @@ Global {3EC71A0E-6515-4A5A-B759-F0BCF1BCFC56} = {44963D50-8B58-44E6-918D-788BCB406695} {558C46DE-DE16-41D5-8DB7-D6D748E32977} = {3EC71A0E-6515-4A5A-B759-F0BCF1BCFC56} {B1AA24A4-5E02-4DC1-B57F-6EB03F91E4DD} = {44963D50-8B58-44E6-918D-788BCB406695} + {5C641396-7E92-4F5C-A5A1-B4CDF480539B} = {088C37A5-30D2-40FB-B031-D163CFBED006} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs b/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs index f84f8e792ca0..92b2b089cfdc 100644 --- a/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs +++ b/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs @@ -18,6 +18,10 @@ internal class DeferredHostBuilder : IHostBuilder private Action _configure; private Func? _hostFactory; + // This task represents a call to IHost.Start, we create it here preemptively in case the application + // exits due to an exception or because it didn't wait for the shutdown signal + private readonly TaskCompletionSource _hostStartTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + public DeferredHostBuilder() { _configure = b => @@ -37,7 +41,7 @@ public IHost Build() var host = (IHost)_hostFactory!(Array.Empty()); // We can't return the host directly since we need to defer the call to StartAsync - return new DeferredHost(host); + return new DeferredHost(host, _hostStartTcs); } public IHostBuilder ConfigureAppConfiguration(Action configureDelegate) @@ -81,6 +85,19 @@ public void ConfigureHostBuilder(object hostBuilder) _configure(((IHostBuilder)hostBuilder)); } + public void EntryPointCompleted(Exception? exception) + { + // If the entry point completed we'll set the tcs just in case the application doesn't call IHost.Start/StartAsync. + if (exception is not null) + { + _hostStartTcs.TrySetException(exception); + } + else + { + _hostStartTcs.TrySetResult(); + } + } + public void SetHostFactory(Func hostFactory) { _hostFactory = hostFactory; @@ -89,10 +106,12 @@ public void SetHostFactory(Func hostFactory) private class DeferredHost : IHost, IAsyncDisposable { private readonly IHost _host; + private readonly TaskCompletionSource _hostStartedTcs; - public DeferredHost(IHost host) + public DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) { _host = host; + _hostStartedTcs = hostStartedTcs; } public IServiceProvider Services => _host.Services; @@ -109,20 +128,18 @@ public ValueTask DisposeAsync() return default; } - public Task StartAsync(CancellationToken cancellationToken = default) + public async Task StartAsync(CancellationToken cancellationToken = default) { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // Wait on the existing host to start running and have this call wait on that. This avoids starting the actual host too early and // leaves the application in charge of calling start. - using var reg = cancellationToken.UnsafeRegister(_ => tcs.TrySetCanceled(), null); + using var reg = cancellationToken.UnsafeRegister(_ => _hostStartedTcs.TrySetCanceled(), null); // REVIEW: This will deadlock if the application creates the host but never calls start. This is mitigated by the cancellationToken // but it's rarely a valid token for Start - _host.Services.GetRequiredService().ApplicationStarted.UnsafeRegister(_ => tcs.TrySetResult(), null); + using var reg2 = _host.Services.GetRequiredService().ApplicationStarted.UnsafeRegister(_ => _hostStartedTcs.TrySetResult(), null); - return tcs.Task; + await _hostStartedTcs.Task; } public Task StopAsync(CancellationToken cancellationToken = default) => _host.StopAsync(cancellationToken); diff --git a/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs b/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs index 024f6d62431e..3e3e4df1e056 100644 --- a/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs +++ b/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs @@ -161,7 +161,11 @@ private void EnsureServer() { var deferredHostBuilder = new DeferredHostBuilder(); // This helper call does the hard work to determine if we can fallback to diagnostic source events to get the host instance - var factory = HostFactoryResolver.ResolveHostFactory(typeof(TEntryPoint).Assembly, stopApplication: false, configureHostBuilder: deferredHostBuilder.ConfigureHostBuilder); + var factory = HostFactoryResolver.ResolveHostFactory( + typeof(TEntryPoint).Assembly, + stopApplication: false, + configureHostBuilder: deferredHostBuilder.ConfigureHostBuilder, + entrypointCompleted: deferredHostBuilder.EntryPointCompleted); if (factory is not null) { diff --git a/src/Mvc/test/Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj b/src/Mvc/test/Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj index 0d7f871ef545..b70a060cae94 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj +++ b/src/Mvc/test/Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj @@ -38,6 +38,7 @@ + diff --git a/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderExceptionTests.cs b/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderExceptionTests.cs new file mode 100644 index 000000000000..baca3e522ad9 --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderExceptionTests.cs @@ -0,0 +1,28 @@ +// 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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class SimpleWithWebApplicationBuilderExceptionTests : IClassFixture> + { + private MvcTestFixture _fixture; + + public SimpleWithWebApplicationBuilderExceptionTests(MvcTestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void ExceptionThrownFromApplicationCanBeObserved() + { + var ex = Assert.Throws(() => _fixture.CreateClient()); + Assert.Equal("This application failed to start", ex.Message); + } + } +} diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/FakeEntryPoint.cs b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/FakeEntryPoint.cs new file mode 100644 index 000000000000..94550b75cd98 --- /dev/null +++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/FakeEntryPoint.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +/// +/// This is a class we use to reference this assembly statically from tests +/// +namespace SimpleWebSiteWithWebApplicationBuilderException +{ + public class FakeStartup + { + } +} diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/Program.cs b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/Program.cs new file mode 100644 index 000000000000..a77fa70765ef --- /dev/null +++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/Program.cs @@ -0,0 +1,11 @@ +// 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 Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(args); + +app.MapGet("/", (Func)(() => "Hello World")); + +throw new InvalidOperationException("This application failed to start"); diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/Properties/launchSettings.json b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/Properties/launchSettings.json new file mode 100644 index 000000000000..3c71eb74877d --- /dev/null +++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:51807/", + "sslPort": 44365 + } + }, + "profiles": { + "SimpleWebSite": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj new file mode 100644 index 000000000000..58eb21f2db2d --- /dev/null +++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj @@ -0,0 +1,10 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/readme.md b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/readme.md new file mode 100644 index 000000000000..b1877b5ff61f --- /dev/null +++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/readme.md @@ -0,0 +1,4 @@ +SimpleWebSiteWithWebApplicationBuilderException +=== +This sample web project illustrates a minimal site using WebApplicationBuilder that throws in main. +Please build from root (`.\build.cmd` on Windows; `./build.sh` elsewhere) before using this site. From 29731673a35a951c44a814bbd935b69e752108f2 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 17 Jun 2021 08:28:19 -0700 Subject: [PATCH 2/4] Does this help --- .../Mvc.Testing/src/HostFactoryResolver.cs | 330 ++++++++++++++++++ .../Microsoft.AspNetCore.Mvc.Testing.csproj | 2 +- 2 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 src/Mvc/Mvc.Testing/src/HostFactoryResolver.cs diff --git a/src/Mvc/Mvc.Testing/src/HostFactoryResolver.cs b/src/Mvc/Mvc.Testing/src/HostFactoryResolver.cs new file mode 100644 index 000000000000..26b800f4da36 --- /dev/null +++ b/src/Mvc/Mvc.Testing/src/HostFactoryResolver.cs @@ -0,0 +1,330 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace Microsoft.Extensions.Hosting +{ + internal sealed class HostFactoryResolver + { + private const BindingFlags DeclaredOnlyLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + public const string BuildWebHost = nameof(BuildWebHost); + public const string CreateWebHostBuilder = nameof(CreateWebHostBuilder); + public const string CreateHostBuilder = nameof(CreateHostBuilder); + + // The amount of time we wait for the diagnostic source events to fire + private static readonly TimeSpan s_defaultWaitTimeout = TimeSpan.FromSeconds(5); + + public static Func? ResolveWebHostFactory(Assembly assembly) + { + return ResolveFactory(assembly, BuildWebHost); + } + + public static Func? ResolveWebHostBuilderFactory(Assembly assembly) + { + return ResolveFactory(assembly, CreateWebHostBuilder); + } + + public static Func? ResolveHostBuilderFactory(Assembly assembly) + { + return ResolveFactory(assembly, CreateHostBuilder); + } + + // This helpers encapsulates all of the complex logic required to: + // 1. Execute the entry point of the specified assembly in a different thread. + // 2. Wait for the diagnostic source events to fire + // 3. Give the caller a chance to execute logic to mutate the IHostBuilder + // 4. Resolve the instance of the applications's IHost + // 5. Allow the caller to determine if the entry point has completed + public static Func? ResolveHostFactory(Assembly assembly, + TimeSpan? waitTimeout = null, + bool stopApplication = true, + Action? configureHostBuilder = null, + Action? entrypointCompleted = null) + { + if (assembly.EntryPoint is null) + { + return null; + } + + try + { + // Attempt to load hosting and check the version to make sure the events + // even have a chance of firing (they were added in .NET >= 6) + var hostingAssembly = Assembly.Load("Microsoft.Extensions.Hosting"); + if (hostingAssembly.GetName().Version is Version version && version.Major < 6) + { + return null; + } + + // We're using a version >= 6 so the events can fire. If they don't fire + // then it's because the application isn't using the hosting APIs + } + catch + { + // There was an error loading the extensions assembly, return null. + return null; + } + + return args => new HostingListener(args, assembly.EntryPoint, waitTimeout ?? s_defaultWaitTimeout, stopApplication, configureHostBuilder, entrypointCompleted).CreateHost(); + } + + private static Func? ResolveFactory(Assembly assembly, string name) + { + var programType = assembly?.EntryPoint?.DeclaringType; + if (programType == null) + { + return null; + } + + var factory = programType.GetMethod(name, DeclaredOnlyLookup); + if (!IsFactory(factory)) + { + return null; + } + + return args => (T)factory!.Invoke(null, new object[] { args })!; + } + + // TReturn Factory(string[] args); + private static bool IsFactory(MethodInfo? factory) + { + return factory != null + && typeof(TReturn).IsAssignableFrom(factory.ReturnType) + && factory.GetParameters().Length == 1 + && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType); + } + + // Used by EF tooling without any Hosting references. Looses some return type safety checks. + public static Func? ResolveServiceProviderFactory(Assembly assembly, TimeSpan? waitTimeout = null) + { + // Prefer the older patterns by default for back compat. + var webHostFactory = ResolveWebHostFactory(assembly); + if (webHostFactory != null) + { + return args => + { + var webHost = webHostFactory(args); + return GetServiceProvider(webHost); + }; + } + + var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); + if (webHostBuilderFactory != null) + { + return args => + { + var webHostBuilder = webHostBuilderFactory(args); + var webHost = Build(webHostBuilder); + return GetServiceProvider(webHost); + }; + } + + var hostBuilderFactory = ResolveHostBuilderFactory(assembly); + if (hostBuilderFactory != null) + { + return args => + { + var hostBuilder = hostBuilderFactory(args); + var host = Build(hostBuilder); + return GetServiceProvider(host); + }; + } + + var hostFactory = ResolveHostFactory(assembly, waitTimeout: waitTimeout); + if (hostFactory != null) + { + return args => + { + var host = hostFactory(args); + return GetServiceProvider(host); + }; + } + + return null; + } + + private static object? Build(object builder) + { + var buildMethod = builder.GetType().GetMethod("Build"); + return buildMethod?.Invoke(builder, Array.Empty()); + } + + private static IServiceProvider? GetServiceProvider(object? host) + { + if (host == null) + { + return null; + } + var hostType = host.GetType(); + var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup); + return (IServiceProvider?)servicesProperty?.GetValue(host); + } + + private sealed class HostingListener : IObserver, IObserver> + { + private readonly string[] _args; + private readonly MethodInfo _entryPoint; + private readonly TimeSpan _waitTimeout; + private readonly bool _stopApplication; + + private readonly TaskCompletionSource _hostTcs = new(); + private IDisposable? _disposable; + private Action? _configure; + private Action? _entrypointCompleted; + private static readonly AsyncLocal _currentListener = new(); + + public HostingListener(string[] args, MethodInfo entryPoint, TimeSpan waitTimeout, bool stopApplication, Action? configure, Action? entrypointCompleted) + { + _args = args; + _entryPoint = entryPoint; + _waitTimeout = waitTimeout; + _stopApplication = stopApplication; + _configure = configure; + _entrypointCompleted = entrypointCompleted; + } + + public object CreateHost() + { + using var subscription = DiagnosticListener.AllListeners.Subscribe(this); + + // Kick off the entry point on a new thread so we don't block the current one + // in case we need to timeout the execution + var thread = new Thread(() => + { + Exception? exception = null; + + try + { + // Set the async local to the instance of the HostingListener so we can filter events that + // aren't scoped to this execution of the entry point. + _currentListener.Value = this; + + var parameters = _entryPoint.GetParameters(); + if (parameters.Length == 0) + { + _entryPoint.Invoke(null, Array.Empty()); + } + else + { + _entryPoint.Invoke(null, new object[] { _args }); + } + + // Try to set an exception if the entry point returns gracefully, this will force + // build to throw + _hostTcs.TrySetException(new InvalidOperationException("Unable to build IHost")); + } + catch (TargetInvocationException tie) when (tie.InnerException is StopTheHostException) + { + // The host was stopped by our own logic + } + catch (TargetInvocationException tie) + { + exception = tie.InnerException ?? tie; + + // Another exception happened, propagate that to the caller + _hostTcs.TrySetException(exception); + } + catch (Exception ex) + { + exception = ex; + + // Another exception happened, propagate that to the caller + _hostTcs.TrySetException(ex); + } + finally + { + // Signal that the entry point is completed + _entrypointCompleted?.Invoke(exception); + } + }) + { + // Make sure this doesn't hang the process + IsBackground = true + }; + + // Start the thread + thread.Start(); + + try + { + // Wait before throwing an exception + if (!_hostTcs.Task.Wait(_waitTimeout)) + { + throw new InvalidOperationException("Unable to build IHost"); + } + } + catch (AggregateException) when (_hostTcs.Task.IsCompleted) + { + // Lets this propagate out of the call to GetAwaiter().GetResult() + } + + Debug.Assert(_hostTcs.Task.IsCompleted); + + return _hostTcs.Task.GetAwaiter().GetResult(); + } + + public void OnCompleted() + { + _disposable?.Dispose(); + } + + public void OnError(Exception error) + { + + } + + public void OnNext(DiagnosticListener value) + { + if (_currentListener.Value != this) + { + // Ignore events that aren't for this listener + return; + } + + if (value.Name == "Microsoft.Extensions.Hosting") + { + _disposable = value.Subscribe(this); + } + } + + public void OnNext(KeyValuePair value) + { + if (_currentListener.Value != this) + { + // Ignore events that aren't for this listener + return; + } + + if (value.Key == "HostBuilding") + { + _configure?.Invoke(value.Value!); + } + + if (value.Key == "HostBuilt") + { + _hostTcs.TrySetResult(value.Value!); + + if (_stopApplication) + { + // Stop the host from running further + throw new StopTheHostException(); + } + } + } + + private sealed class StopTheHostException : Exception + { + + } + } + } +} \ No newline at end of file diff --git a/src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj b/src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj index d417bc7e3caf..2dfdd66bb0cf 100644 --- a/src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj +++ b/src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj @@ -15,7 +15,7 @@ - + From fc3a386d730c29edfff5c3e25e68ebc77fd87e32 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 17 Jun 2021 22:32:22 -0700 Subject: [PATCH 3/4] Revert "Does this help" This reverts commit 29731673a35a951c44a814bbd935b69e752108f2. --- .../Mvc.Testing/src/HostFactoryResolver.cs | 330 ------------------ .../Microsoft.AspNetCore.Mvc.Testing.csproj | 2 +- 2 files changed, 1 insertion(+), 331 deletions(-) delete mode 100644 src/Mvc/Mvc.Testing/src/HostFactoryResolver.cs diff --git a/src/Mvc/Mvc.Testing/src/HostFactoryResolver.cs b/src/Mvc/Mvc.Testing/src/HostFactoryResolver.cs deleted file mode 100644 index 26b800f4da36..000000000000 --- a/src/Mvc/Mvc.Testing/src/HostFactoryResolver.cs +++ /dev/null @@ -1,330 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -#nullable enable - -namespace Microsoft.Extensions.Hosting -{ - internal sealed class HostFactoryResolver - { - private const BindingFlags DeclaredOnlyLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; - - public const string BuildWebHost = nameof(BuildWebHost); - public const string CreateWebHostBuilder = nameof(CreateWebHostBuilder); - public const string CreateHostBuilder = nameof(CreateHostBuilder); - - // The amount of time we wait for the diagnostic source events to fire - private static readonly TimeSpan s_defaultWaitTimeout = TimeSpan.FromSeconds(5); - - public static Func? ResolveWebHostFactory(Assembly assembly) - { - return ResolveFactory(assembly, BuildWebHost); - } - - public static Func? ResolveWebHostBuilderFactory(Assembly assembly) - { - return ResolveFactory(assembly, CreateWebHostBuilder); - } - - public static Func? ResolveHostBuilderFactory(Assembly assembly) - { - return ResolveFactory(assembly, CreateHostBuilder); - } - - // This helpers encapsulates all of the complex logic required to: - // 1. Execute the entry point of the specified assembly in a different thread. - // 2. Wait for the diagnostic source events to fire - // 3. Give the caller a chance to execute logic to mutate the IHostBuilder - // 4. Resolve the instance of the applications's IHost - // 5. Allow the caller to determine if the entry point has completed - public static Func? ResolveHostFactory(Assembly assembly, - TimeSpan? waitTimeout = null, - bool stopApplication = true, - Action? configureHostBuilder = null, - Action? entrypointCompleted = null) - { - if (assembly.EntryPoint is null) - { - return null; - } - - try - { - // Attempt to load hosting and check the version to make sure the events - // even have a chance of firing (they were added in .NET >= 6) - var hostingAssembly = Assembly.Load("Microsoft.Extensions.Hosting"); - if (hostingAssembly.GetName().Version is Version version && version.Major < 6) - { - return null; - } - - // We're using a version >= 6 so the events can fire. If they don't fire - // then it's because the application isn't using the hosting APIs - } - catch - { - // There was an error loading the extensions assembly, return null. - return null; - } - - return args => new HostingListener(args, assembly.EntryPoint, waitTimeout ?? s_defaultWaitTimeout, stopApplication, configureHostBuilder, entrypointCompleted).CreateHost(); - } - - private static Func? ResolveFactory(Assembly assembly, string name) - { - var programType = assembly?.EntryPoint?.DeclaringType; - if (programType == null) - { - return null; - } - - var factory = programType.GetMethod(name, DeclaredOnlyLookup); - if (!IsFactory(factory)) - { - return null; - } - - return args => (T)factory!.Invoke(null, new object[] { args })!; - } - - // TReturn Factory(string[] args); - private static bool IsFactory(MethodInfo? factory) - { - return factory != null - && typeof(TReturn).IsAssignableFrom(factory.ReturnType) - && factory.GetParameters().Length == 1 - && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType); - } - - // Used by EF tooling without any Hosting references. Looses some return type safety checks. - public static Func? ResolveServiceProviderFactory(Assembly assembly, TimeSpan? waitTimeout = null) - { - // Prefer the older patterns by default for back compat. - var webHostFactory = ResolveWebHostFactory(assembly); - if (webHostFactory != null) - { - return args => - { - var webHost = webHostFactory(args); - return GetServiceProvider(webHost); - }; - } - - var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); - if (webHostBuilderFactory != null) - { - return args => - { - var webHostBuilder = webHostBuilderFactory(args); - var webHost = Build(webHostBuilder); - return GetServiceProvider(webHost); - }; - } - - var hostBuilderFactory = ResolveHostBuilderFactory(assembly); - if (hostBuilderFactory != null) - { - return args => - { - var hostBuilder = hostBuilderFactory(args); - var host = Build(hostBuilder); - return GetServiceProvider(host); - }; - } - - var hostFactory = ResolveHostFactory(assembly, waitTimeout: waitTimeout); - if (hostFactory != null) - { - return args => - { - var host = hostFactory(args); - return GetServiceProvider(host); - }; - } - - return null; - } - - private static object? Build(object builder) - { - var buildMethod = builder.GetType().GetMethod("Build"); - return buildMethod?.Invoke(builder, Array.Empty()); - } - - private static IServiceProvider? GetServiceProvider(object? host) - { - if (host == null) - { - return null; - } - var hostType = host.GetType(); - var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup); - return (IServiceProvider?)servicesProperty?.GetValue(host); - } - - private sealed class HostingListener : IObserver, IObserver> - { - private readonly string[] _args; - private readonly MethodInfo _entryPoint; - private readonly TimeSpan _waitTimeout; - private readonly bool _stopApplication; - - private readonly TaskCompletionSource _hostTcs = new(); - private IDisposable? _disposable; - private Action? _configure; - private Action? _entrypointCompleted; - private static readonly AsyncLocal _currentListener = new(); - - public HostingListener(string[] args, MethodInfo entryPoint, TimeSpan waitTimeout, bool stopApplication, Action? configure, Action? entrypointCompleted) - { - _args = args; - _entryPoint = entryPoint; - _waitTimeout = waitTimeout; - _stopApplication = stopApplication; - _configure = configure; - _entrypointCompleted = entrypointCompleted; - } - - public object CreateHost() - { - using var subscription = DiagnosticListener.AllListeners.Subscribe(this); - - // Kick off the entry point on a new thread so we don't block the current one - // in case we need to timeout the execution - var thread = new Thread(() => - { - Exception? exception = null; - - try - { - // Set the async local to the instance of the HostingListener so we can filter events that - // aren't scoped to this execution of the entry point. - _currentListener.Value = this; - - var parameters = _entryPoint.GetParameters(); - if (parameters.Length == 0) - { - _entryPoint.Invoke(null, Array.Empty()); - } - else - { - _entryPoint.Invoke(null, new object[] { _args }); - } - - // Try to set an exception if the entry point returns gracefully, this will force - // build to throw - _hostTcs.TrySetException(new InvalidOperationException("Unable to build IHost")); - } - catch (TargetInvocationException tie) when (tie.InnerException is StopTheHostException) - { - // The host was stopped by our own logic - } - catch (TargetInvocationException tie) - { - exception = tie.InnerException ?? tie; - - // Another exception happened, propagate that to the caller - _hostTcs.TrySetException(exception); - } - catch (Exception ex) - { - exception = ex; - - // Another exception happened, propagate that to the caller - _hostTcs.TrySetException(ex); - } - finally - { - // Signal that the entry point is completed - _entrypointCompleted?.Invoke(exception); - } - }) - { - // Make sure this doesn't hang the process - IsBackground = true - }; - - // Start the thread - thread.Start(); - - try - { - // Wait before throwing an exception - if (!_hostTcs.Task.Wait(_waitTimeout)) - { - throw new InvalidOperationException("Unable to build IHost"); - } - } - catch (AggregateException) when (_hostTcs.Task.IsCompleted) - { - // Lets this propagate out of the call to GetAwaiter().GetResult() - } - - Debug.Assert(_hostTcs.Task.IsCompleted); - - return _hostTcs.Task.GetAwaiter().GetResult(); - } - - public void OnCompleted() - { - _disposable?.Dispose(); - } - - public void OnError(Exception error) - { - - } - - public void OnNext(DiagnosticListener value) - { - if (_currentListener.Value != this) - { - // Ignore events that aren't for this listener - return; - } - - if (value.Name == "Microsoft.Extensions.Hosting") - { - _disposable = value.Subscribe(this); - } - } - - public void OnNext(KeyValuePair value) - { - if (_currentListener.Value != this) - { - // Ignore events that aren't for this listener - return; - } - - if (value.Key == "HostBuilding") - { - _configure?.Invoke(value.Value!); - } - - if (value.Key == "HostBuilt") - { - _hostTcs.TrySetResult(value.Value!); - - if (_stopApplication) - { - // Stop the host from running further - throw new StopTheHostException(); - } - } - } - - private sealed class StopTheHostException : Exception - { - - } - } - } -} \ No newline at end of file diff --git a/src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj b/src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj index 2dfdd66bb0cf..d417bc7e3caf 100644 --- a/src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj +++ b/src/Mvc/Mvc.Testing/src/Microsoft.AspNetCore.Mvc.Testing.csproj @@ -15,7 +15,7 @@ - + From 02bd0f6b4f4adbd9355c38fbe60d3416add4b55e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 17 Jun 2021 22:35:40 -0700 Subject: [PATCH 4/4] Actually fix the issue... --- src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs b/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs index 92b2b089cfdc..1a9fea4d99eb 100644 --- a/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs +++ b/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs @@ -118,14 +118,14 @@ public DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) public void Dispose() => _host.Dispose(); - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { if (_host is IAsyncDisposable disposable) { - return disposable.DisposeAsync(); + await disposable.DisposeAsync().ConfigureAwait(false); + return; } Dispose(); - return default; } public async Task StartAsync(CancellationToken cancellationToken = default) @@ -139,7 +139,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) // but it's rarely a valid token for Start using var reg2 = _host.Services.GetRequiredService().ApplicationStarted.UnsafeRegister(_ => _hostStartedTcs.TrySetResult(), null); - await _hostStartedTcs.Task; + await _hostStartedTcs.Task.ConfigureAwait(false); } public Task StopAsync(CancellationToken cancellationToken = default) => _host.StopAsync(cancellationToken);